feat(monitor): 作品栏按博主分组折叠,评论栏改为 博主/作品/评论 三级
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

先回答现状:跳转作品的按钮本来就有(作品栏每行末尾的外链图标);
评论栏原本是**两级**(作品 -> 评论),第一级是作品不是博主,所以并不是三级。

- MonitorNote 新增 creator_name(爬虫产出的 jsonl 里本来就有昵称,只是没存)。
  存的是**已脱敏**的值(张***三),与项目一贯的匿名化姿态一致 —— 爬虫刻意不落原始
  user_id(tools/user_hash.py),所以 creator_hash 是唯一稳定的分组依据。
  实测该哈希是无盐 sha256,能用任务目标的 external_id 反算配对。
- 入库时刷新 creator_name:作者改昵称是常事,只在首次写一次会一直显示旧的
- 作品栏:按 creator_hash 分组,组头可折叠(默认展开 —— 折叠的默认值不该藏数据),
  行内跳转按钮加了 title 说明
- 评论栏:一级博主、二级作品、三级评论。_note_meta_map 补上博主维度,
  评论流和作品分组都带上它
- 认不出博主的作品归到「未知博主」,不丢

顺带修一个语法错误:JSX 注释放在三元表达式分支里是非法的(那是子节点语法不是表达式),
移进 div 内。
This commit is contained in:
2026-10-08 09:43:19 +08:00
parent b11bbf771a
commit 0eb6ba31c5
6 changed files with 256 additions and 69 deletions
+82 -6
View File
@@ -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<string, { key: string; name: string; buckets: CommentBucket[] }>()
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<string>
onToggleNote: (noteId: string) => void
}) {
const [open, setOpen] = useState(true)
const commentCount = buckets.reduce((sum, bucket) => sum + bucket.comments.length, 0)
return (
<div className="rounded-lg border border-cyber-border-DEFAULT overflow-hidden">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2 px-3 py-2 bg-cyber-bg-elevated/50 hover:bg-cyber-bg-elevated/80 transition-colors text-left"
>
{open ? (
<ChevronDown className="w-3.5 h-3.5 text-cyber-text-muted flex-shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-cyber-text-muted flex-shrink-0" />
)}
<Users className="w-3.5 h-3.5 text-cyber-neon-cyan flex-shrink-0" />
<span className="font-mono text-xs text-cyber-text-primary truncate">
{name || '未知博主'}
</span>
<span className="text-[10px] font-mono text-cyber-text-muted flex-shrink-0">
{buckets.length} 篇作品
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 flex-shrink-0 ml-auto">
{commentCount} 条评论
</Badge>
</button>
{open && (
<div className="p-2 space-y-2 bg-cyber-bg-secondary/30">
{buckets.map((bucket) => (
<CollapsibleGroup
key={bucket.note_id}
bucket={bucket}
open={expandedNotes.has(bucket.note_id)}
onToggle={() => onToggleNote(bucket.note_id)}
/>
))}
</div>
)}
</div>
)
}
export function CommentsFeed({ taskId }: CommentsFeedProps) {
const [grouped, setGrouped] = useState(true)
const [noteFilter, setNoteFilter] = useState<string>(ALL_NOTES)
@@ -267,12 +340,15 @@ export function CommentsFeed({ taskId }: CommentsFeedProps) {
</p>
) : grouped ? (
<div className="space-y-2">
{groups?.map((bucket) => (
<CollapsibleGroup
key={bucket.note_id}
bucket={bucket}
open={expanded.has(bucket.note_id)}
onToggle={() => toggleGroup(bucket.note_id)}
{/* 三级:博主 -> 作品 -> 评论。一个任务可以配多个博主,平铺作品的话
看不出哪条评论属于谁。 */}
{groupByCreator(groups ?? []).map((creator) => (
<CreatorGroup
key={creator.key}
name={creator.name}
buckets={creator.buckets}
expandedNotes={expanded}
onToggleNote={toggleGroup}
/>
))}
</div>
+138 -63
View File
@@ -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<string | null>(null)
const [expandedNote, setExpandedNote] = useState<string | null>(null)
// 折叠状态按博主记。默认全展开 —— 藏起来的数据比多滚两屏更糟。
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const groups = useMemo(() => {
const byCreator = new Map<string, { key: string; name: string; notes: MonitorNote[] }>()
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 <p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">加载中…</p>
@@ -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 (
<div className="overflow-x-auto terminal-scroll">
@@ -78,71 +110,114 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
</tr>
</thead>
<tbody>
{notes.map((note) => {
const open = isExpanded(note)
{groups.map((group) => {
const isCollapsed = collapsed.has(group.key)
return (
<Fragment key={`${note.task_id}:${note.note_id}`}>
<Fragment key={group.key}>
<tr
onClick={() => 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"
>
<td className="py-2 px-1 text-cyber-text-muted">
{open ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</td>
<td className="py-2 px-2 max-w-[320px]">
<div className="flex items-center gap-2">
<NoteCover src={note.cover} size="md" />
{/* min-w-0 so the title can actually truncate inside the flex row */}
<div className="min-w-0">
<div className="flex items-center gap-1.5">
{note.is_new && (
<Badge variant="success" className="text-[9px] px-1 py-0 flex-shrink-0">
NEW
</Badge>
)}
<span className="truncate text-cyber-text-primary" title={note.title}>
{note.title || note.note_id}
</span>
</div>
<div className="text-[9px] text-cyber-text-muted truncate">
{note.note_id} · {note.snapshot_count} 次快照
</div>
</div>
</div>
</td>
{METRIC_COLUMNS.map((column) => (
<td key={column.key} className="py-2 px-2">
<MetricCell value={note.metrics[column.key]} delta={note.deltas[column.key]} />
</td>
))}
<td className="py-2 px-2 text-right text-[10px] text-cyber-text-muted">
{formatRelative(note.first_seen_at)}
</td>
<td className="py-2 px-1">
{note.note_url && (
<a
href={note.note_url}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
className="text-cyber-text-muted hover:text-cyber-neon-cyan"
>
<ExternalLink className="w-3 h-3" />
</a>
<td className="py-1.5 px-1 text-cyber-text-muted">
{isCollapsed ? (
<ChevronRight className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
)}
</td>
<td colSpan={COLUMN_COUNT - 1} className="py-1.5 px-2">
<span className="flex items-center gap-1.5">
<Users className="w-3 h-3 text-cyber-neon-cyan" />
<span className="text-cyber-text-primary">
{group.name || '未知博主'}
</span>
<span className="text-cyber-text-muted">{group.notes.length} 篇</span>
{isCollapsed && (
<span className="text-cyber-text-muted">(已折叠)</span>
)}
</span>
</td>
</tr>
{open && (
<tr className="bg-cyber-bg-secondary/40">
<td colSpan={METRIC_COLUMNS.length + 4} className="px-4 py-3">
<NoteTrendChart
noteId={note.note_id}
taskId={note.task_id}
noteTitle={note.title}
/>
</td>
</tr>
)}
{!isCollapsed &&
group.notes.map((note) => {
const rowKey = `${note.task_id}:${note.note_id}`
const open = expandedNote === rowKey
return (
<Fragment key={rowKey}>
<tr
onClick={() => setExpandedNote(open ? null : rowKey)}
className="border-t border-cyber-border-subtle hover:bg-cyber-bg-elevated/50 cursor-pointer"
>
<td />
<td className="py-2 px-2 max-w-[320px]">
<div className="flex items-center gap-2">
<NoteCover src={note.cover} size="md" />
{/* min-w-0 so the title can actually truncate inside the flex row */}
<div className="min-w-0">
<div className="flex items-center gap-1.5">
{note.is_new && (
<Badge
variant="success"
className="text-[9px] px-1 py-0 flex-shrink-0"
>
NEW
</Badge>
)}
<span
className="truncate text-cyber-text-primary"
title={note.title}
>
{note.title || note.note_id}
</span>
</div>
<div className="text-[9px] text-cyber-text-muted truncate">
{note.note_id} · {note.snapshot_count} 次快照
</div>
</div>
</div>
</td>
{METRIC_COLUMNS.map((column) => (
<td key={column.key} className="py-2 px-2">
<MetricCell
value={note.metrics[column.key]}
delta={note.deltas[column.key]}
/>
</td>
))}
<td className="py-2 px-2 text-right text-[10px] text-cyber-text-muted">
{formatRelative(note.first_seen_at)}
</td>
<td className="py-2 px-1">
{/* 直接跳转到该作品。stopPropagation,否则点它会连带展开趋势图。 */}
{note.note_url && (
<a
href={note.note_url}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
title="打开原作品"
className="text-cyber-text-muted hover:text-cyber-neon-cyan"
>
<ExternalLink className="w-3 h-3" />
</a>
)}
</td>
</tr>
{open && (
<tr className="bg-cyber-bg-secondary/40">
<td colSpan={COLUMN_COUNT} className="px-4 py-3">
<NoteTrendChart
noteId={note.note_id}
taskId={note.task_id}
noteTitle={note.title}
/>
</td>
</tr>
)}
</Fragment>
)
})}
</Fragment>
)
})}
+16
View File
@@ -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[]
}