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[] }