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
+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>
)
})}