diff --git a/api/monitor/ingest.py b/api/monitor/ingest.py
index fe30907..432c075 100644
--- a/api/monitor/ingest.py
+++ b/api/monitor/ingest.py
@@ -300,6 +300,7 @@ async def _ingest_notes(
note_url=record.get("note_url") or "",
cover=cover,
creator_hash=record.get("creator_hash") or "",
+ creator_name=record.get("nickname") or "",
source_kind=record.get("type") or "",
published_at=_as_int(record.get("time")),
first_seen_run_id=run.id,
@@ -328,6 +329,9 @@ async def _ingest_notes(
# 重跑也修不回来。落盘那份由 covers.cache_pending 负责(网络操作不在本模块)。
if cover:
note.cover = cover
+ # 昵称也要刷:作者改昵称是常事,只在首次入库写一次会一直显示旧的。
+ if record.get("nickname"):
+ note.creator_name = record["nickname"]
note.last_seen_run_id = run.id
note.last_seen_at = now
diff --git a/api/monitor/models.py b/api/monitor/models.py
index b4a4ef5..6b50479 100644
--- a/api/monitor/models.py
+++ b/api/monitor/models.py
@@ -220,7 +220,12 @@ class MonitorNote(MonitorBase):
title: Mapped[str] = mapped_column(Text, nullable=False, default="")
note_url: Mapped[str] = mapped_column(Text, nullable=False, default="")
cover: Mapped[str] = mapped_column(Text, nullable=False, default="")
+ # 创作者匿名哈希。爬虫刻意不落原始 user_id(见 tools/user_hash.py),
+ # 所以这是唯一稳定的创作者标识 —— 按博主分组就靠它。
creator_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
+ # 创作者昵称,**已由爬虫脱敏**(张***三 这种)。存的是脱敏后的值,与项目一贯的
+ # 匿名化姿态一致;不存的话分组只能显示一串哈希,根本认不出是谁。
+ creator_name: Mapped[str] = mapped_column(String(200), nullable=False, default="")
source_kind: Mapped[str] = mapped_column(String(16), nullable=False, default="")
published_at: Mapped[Optional[int]] = mapped_column(BigInteger)
diff --git a/api/monitor/service.py b/api/monitor/service.py
index 74d34b6..eaa8e04 100644
--- a/api/monitor/service.py
+++ b/api/monitor/service.py
@@ -397,6 +397,10 @@ async def list_notes(
"note_id": note.note_id,
"title": note.title,
"note_url": note.note_url,
+ # 按博主分组用。creator_hash 是唯一稳定的创作者标识(原始 user_id
+ # 被爬虫刻意匿名化了),creator_name 是已脱敏的昵称。
+ "creator_hash": note.creator_hash,
+ "creator_name": note.creator_name,
# 优先给本地缓存地址:远程地址带签名、会过期(实测隔天即 403),
# 本地那份不会。没有缓存时才退回远程,至少让图先显示出来。
"cover": covers.cover_url(note.note_id, note.cover),
@@ -478,6 +482,9 @@ async def _note_meta_map(
"note_cover": covers.cover_url(row.note_id, row.cover),
"note_url": row.note_url,
"task_id": row.task_id,
+ # 博主维度也带上,评论流才能按 博主 -> 作品 -> 评论 三级展开。
+ "creator_hash": row.creator_hash,
+ "creator_name": row.creator_name,
}
for row in rows
}
@@ -523,6 +530,8 @@ async def list_comments(
"note_title": meta.get(row.note_id, {}).get("note_title", ""),
"note_cover": meta.get(row.note_id, {}).get("note_cover", ""),
"note_url": meta.get(row.note_id, {}).get("note_url", ""),
+ "note_creator_hash": meta.get(row.note_id, {}).get("creator_hash", ""),
+ "note_creator_name": meta.get(row.note_id, {}).get("creator_name", ""),
}
for row in comments
]
@@ -573,6 +582,8 @@ async def comment_note_groups(
"note_title": meta.get(note_id, {}).get("note_title", ""),
"note_cover": meta.get(note_id, {}).get("note_cover", ""),
"note_url": meta.get(note_id, {}).get("note_url", ""),
+ "creator_hash": meta.get(note_id, {}).get("creator_hash", ""),
+ "creator_name": meta.get(note_id, {}).get("creator_name", ""),
"comment_count": count,
"latest_at": latest.get(note_id, 0),
}
diff --git a/webui/src/components/monitor/CommentsFeed.tsx b/webui/src/components/monitor/CommentsFeed.tsx
index af0b199..14ce38d 100644
--- a/webui/src/components/monitor/CommentsFeed.tsx
+++ b/webui/src/components/monitor/CommentsFeed.tsx
@@ -7,6 +7,7 @@ import {
LayoutList,
Rows3,
ThumbsUp,
+ Users,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
@@ -156,6 +157,78 @@ function CollapsibleGroup({
)
}
+/** 把作品分组按博主归并 —— 三级视图的第一级。 */
+function groupByCreator(buckets: CommentBucket[]) {
+ const byCreator = new Map()
+ for (const bucket of buckets) {
+ const key = bucket.creator_hash || '__unknown__'
+ if (!byCreator.has(key)) {
+ byCreator.set(key, { key, name: bucket.creator_name || '', buckets: [] })
+ }
+ byCreator.get(key)!.buckets.push(bucket)
+ }
+ return [...byCreator.values()]
+}
+
+/**
+ * 一级:博主。二级作品、三级评论都收在它下面。
+ *
+ * 分组键是 `creator_hash`(爬虫刻意不落原始 user_id,这是唯一稳定的创作者标识),
+ * 显示名用已脱敏的昵称。默认展开 —— 折叠的默认值不该把数据藏起来。
+ */
+function CreatorGroup({
+ name,
+ buckets,
+ expandedNotes,
+ onToggleNote,
+}: {
+ name: string
+ buckets: CommentBucket[]
+ expandedNotes: Set
+ onToggleNote: (noteId: string) => void
+}) {
+ const [open, setOpen] = useState(true)
+ const commentCount = buckets.reduce((sum, bucket) => sum + bucket.comments.length, 0)
+
+ return (
+
+
+
+ {open && (
+
+ {buckets.map((bucket) => (
+ onToggleNote(bucket.note_id)}
+ />
+ ))}
+
+ )}
+
+ )
+}
+
export function CommentsFeed({ taskId }: CommentsFeedProps) {
const [grouped, setGrouped] = useState(true)
const [noteFilter, setNoteFilter] = useState(ALL_NOTES)
@@ -267,12 +340,15 @@ export function CommentsFeed({ taskId }: CommentsFeedProps) {
) : grouped ? (
- {groups?.map((bucket) => (
- toggleGroup(bucket.note_id)}
+ {/* 三级:博主 -> 作品 -> 评论。一个任务可以配多个博主,平铺作品的话
+ 看不出哪条评论属于谁。 */}
+ {groupByCreator(groups ?? []).map((creator) => (
+
))}
diff --git a/webui/src/components/monitor/NotesTable.tsx b/webui/src/components/monitor/NotesTable.tsx
index c3a0373..c4464d9 100644
--- a/webui/src/components/monitor/NotesTable.tsx
+++ b/webui/src/components/monitor/NotesTable.tsx
@@ -1,5 +1,5 @@
-import { Fragment, useState } from 'react'
-import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'
+import { Fragment, useMemo, useState } from 'react'
+import { ChevronDown, ChevronRight, ExternalLink, Users } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { useMonitorNotes } from '@/hooks/useMonitor'
@@ -39,9 +39,35 @@ const METRIC_COLUMNS: Array<{ key: keyof NoteMetrics; label: string }> = [
{ key: 'share_count', label: '分享' },
]
+// 多出来的那个空列给展开箭头;分组表头会跨掉整行。
+const COLUMN_COUNT = METRIC_COLUMNS.length + 4
+
+/**
+ * 作品表,**按博主分组**。
+ *
+ * 一个任务可以配多个博主,平铺的话根本看不出哪篇是谁的。分组键是
+ * `creator_hash` —— 爬虫刻意不落原始 user_id,所以这是唯一稳定的创作者标识;
+ * 显示名是已脱敏的昵称(张***三)。两者都认不出来时才退回"未知博主"。
+ */
export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
const { data: notes, isLoading } = useMonitorNotes(taskId, onlyNew)
- const [expanded, setExpanded] = useState(null)
+ const [expandedNote, setExpandedNote] = useState(null)
+ // 折叠状态按博主记。默认全展开 —— 藏起来的数据比多滚两屏更糟。
+ const [collapsed, setCollapsed] = useState>(new Set())
+
+ const groups = useMemo(() => {
+ const byCreator = new Map()
+ for (const note of notes ?? []) {
+ // 作品模式下每条作品都会带 creator_hash;真丢了也要有个兜底分组,
+ // 否则那些作品会凭空消失。
+ const key = note.creator_hash || '__unknown__'
+ if (!byCreator.has(key)) {
+ byCreator.set(key, { key, name: note.creator_name || '', notes: [] })
+ }
+ byCreator.get(key)!.notes.push(note)
+ }
+ return [...byCreator.values()]
+ }, [notes])
if (isLoading) {
return 加载中…
@@ -59,7 +85,13 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
)
}
- const isExpanded = (note: MonitorNote) => expanded === `${note.task_id}:${note.note_id}`
+ const toggleCreator = (key: string) =>
+ setCollapsed((prev) => {
+ const next = new Set(prev)
+ if (next.has(key)) next.delete(key)
+ else next.add(key)
+ return next
+ })
return (
@@ -78,71 +110,114 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
- {notes.map((note) => {
- const open = isExpanded(note)
+ {groups.map((group) => {
+ const isCollapsed = collapsed.has(group.key)
return (
-
+
setExpanded(open ? null : `${note.task_id}:${note.note_id}`)}
- className="border-t border-cyber-border-subtle hover:bg-cyber-bg-elevated/50 cursor-pointer"
+ onClick={() => toggleCreator(group.key)}
+ className="border-t border-cyber-border-subtle bg-cyber-bg-tertiary/50 cursor-pointer hover:bg-cyber-bg-elevated/50"
>
- |
- {open ? : }
- |
-
-
-
- {/* min-w-0 so the title can actually truncate inside the flex row */}
-
-
- {note.is_new && (
-
- NEW
-
- )}
-
- {note.title || note.note_id}
-
-
-
- {note.note_id} · {note.snapshot_count} 次快照
-
-
-
- |
- {METRIC_COLUMNS.map((column) => (
-
-
- |
- ))}
-
- {formatRelative(note.first_seen_at)}
- |
-
- {note.note_url && (
- event.stopPropagation()}
- className="text-cyber-text-muted hover:text-cyber-neon-cyan"
- >
-
-
+ |
+ {isCollapsed ? (
+
+ ) : (
+
)}
|
+
+
+
+
+ {group.name || '未知博主'}
+
+ {group.notes.length} 篇
+ {isCollapsed && (
+ (已折叠)
+ )}
+
+ |
- {open && (
-
- |
-
- |
-
- )}
+
+ {!isCollapsed &&
+ group.notes.map((note) => {
+ const rowKey = `${note.task_id}:${note.note_id}`
+ const open = expandedNote === rowKey
+ return (
+
+ setExpandedNote(open ? null : rowKey)}
+ className="border-t border-cyber-border-subtle hover:bg-cyber-bg-elevated/50 cursor-pointer"
+ >
+ |
+
+
+
+ {/* min-w-0 so the title can actually truncate inside the flex row */}
+
+
+ {note.is_new && (
+
+ NEW
+
+ )}
+
+ {note.title || note.note_id}
+
+
+
+ {note.note_id} · {note.snapshot_count} 次快照
+
+
+
+ |
+ {METRIC_COLUMNS.map((column) => (
+
+
+ |
+ ))}
+
+ {formatRelative(note.first_seen_at)}
+ |
+
+ {/* 直接跳转到该作品。stopPropagation,否则点它会连带展开趋势图。 */}
+ {note.note_url && (
+ event.stopPropagation()}
+ title="打开原作品"
+ className="text-cyber-text-muted hover:text-cyber-neon-cyan"
+ >
+
+
+ )}
+ |
+
+ {open && (
+
+ |
+
+ |
+
+ )}
+
+ )
+ })}
)
})}
diff --git a/webui/src/types/monitor.ts b/webui/src/types/monitor.ts
index d5647d9..e0353de 100644
--- a/webui/src/types/monitor.ts
+++ b/webui/src/types/monitor.ts
@@ -84,6 +84,15 @@ export interface MonitorNote {
title: string
note_url: string
cover: string
+ /**
+ * 创作者标识。
+ *
+ * `creator_hash` 是唯一稳定的分组依据 —— 爬虫刻意不落原始 user_id(见
+ * tools/user_hash.py),所以没有比它更具体的身份了。
+ * `creator_name` 是**已脱敏**的昵称(张***三 这种)。
+ */
+ creator_hash: string
+ creator_name: string
first_seen_at: number
last_seen_at: number
is_new: boolean
@@ -116,6 +125,9 @@ export interface MonitorComment {
note_title: string
note_cover: string
note_url: string
+ /** 所属作品的创作者 —— 评论流按 博主 -> 作品 -> 评论 三级展开时用。 */
+ note_creator_hash: string
+ note_creator_name: string
}
/** One work that has comments, for the filter dropdown. */
@@ -124,6 +136,8 @@ export interface CommentNoteOption {
note_title: string
note_cover: string
note_url: string
+ creator_hash: string
+ creator_name: string
comment_count: number
latest_at: number
}
@@ -134,6 +148,8 @@ export interface CommentBucket {
note_title: string
note_cover: string
note_url: string
+ creator_hash: string
+ creator_name: string
comments: MonitorComment[]
}