import { Fragment, useMemo, useRef, useState } from 'react' import { ChevronDown, ChevronRight, ExternalLink, Pencil, Users } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { useMonitorNotes, useSetCreatorAlias } from '@/hooks/useMonitor' import { formatCount, formatDate, formatDateTime, formatDelta, formatRelative, } from '@/lib/monitorFormat' import type { MonitorNote, NoteMetrics } from '@/types/monitor' import { NoteCover } from './NoteCover' import { NoteTrendChart } from './NoteTrendChart' interface NotesTableProps { taskId: number | null onlyNew: boolean } /** A metric with its change vs the previous run underneath. */ function MetricCell({ value, delta }: { value: number | null; delta: number | null }) { const deltaText = formatDelta(delta) return (
{formatCount(value)}
{deltaText && (
0 ? 'text-cyber-neon-green' : 'text-cyber-neon-pink' }`} > {deltaText}
)}
) } const METRIC_COLUMNS: Array<{ key: keyof NoteMetrics; label: string }> = [ { key: 'liked_count', label: '点赞' }, { key: 'comment_count', label: '评论' }, { key: 'collected_count', label: '收藏' }, { key: 'share_count', label: '分享' }, ] /** * 一个博主名下所有作品的指标合计。 * * **全为 null 时结果是 null 而不是 0** —— 与项目一贯的口径一致:「0」是真实值, * 「null」是不知道。合计成 0 会让"还没采到"看起来像"互动为零"。 */ function sumMetrics(notes: MonitorNote[]) { const values: Record = {} const deltas: Record = {} for (const column of METRIC_COLUMNS) { let sum = 0 let sawValue = false let deltaSum = 0 let sawDelta = false for (const note of notes) { const value = note.metrics[column.key] if (value !== null && value !== undefined) { sum += value sawValue = true } const delta = note.deltas[column.key] if (delta !== null && delta !== undefined) { deltaSum += delta sawDelta = true } } values[column.key] = sawValue ? sum : null deltas[column.key] = sawDelta ? deltaSum : null } return { values, deltas } } // 多出来的那几列:展开箭头、作品、发布日期、首次发现、跳转链接;分组表头会跨掉整行。 const COLUMN_COUNT = METRIC_COLUMNS.length + 5 /** * 作品表,**按博主分组**。 * * 一个任务可以配多个博主,平铺的话根本看不出哪篇是谁的。分组键是 * `creator_hash` —— 爬虫刻意不落原始 user_id,所以这是唯一稳定的创作者标识; * 显示名是已脱敏的昵称(张***三)。两者都认不出来时才退回"未知博主"。 */ export function NotesTable({ taskId, onlyNew }: NotesTableProps) { const { data: notes, isLoading } = useMonitorNotes(taskId, onlyNew) const [expandedNote, setExpandedNote] = useState(null) // 折叠状态按博主记。默认全展开 —— 藏起来的数据比多滚两屏更糟。 const [collapsed, setCollapsed] = useState>(new Set()) // 正在改备注的那个博主(creator_hash);null = 没在改。 const [editingCreator, setEditingCreator] = useState(null) const [aliasDraft, setAliasDraft] = useState('') // 按 Escape 是「取消」,而失焦是「保存」—— 取消时得让紧接着那次失焦闭嘴, // 否则它会把你刚放弃的内容存进去。 const aliasCancelled = useRef(false) const setAlias = useSetCreatorAlias() const commitAlias = (creatorHash: string) => { if (aliasCancelled.current) { aliasCancelled.current = false setEditingCreator(null) return } setEditingCreator(null) setAlias.mutate({ creatorHash, alias: aliasDraft }) } const groups = useMemo(() => { const byCreator = new Map< string, { key: string; name: string; alias: 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 || '', alias: note.creator_alias || '', notes: [], }) } byCreator.get(key)!.notes.push(note) } return [...byCreator.values()] }, [notes]) if (isLoading) { return

加载中…

} if (!notes || notes.length === 0) { return (

{taskId === null ? '请先选择一个监控任务' : onlyNew ? '最近一轮没有新增作品' : '还没有采集到作品,运行一次任务后即可看到'}

) } 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 (
{METRIC_COLUMNS.map((column) => ( ))} {groups.map((group) => { const isCollapsed = collapsed.has(group.key) const totals = sumMetrics(group.notes) return ( {/* 组头与数据行**列对齐**:每个指标列给出该博主的合计,下面再带本轮增量。 折叠之后如果什么都看不到,那折叠就只是把信息藏起来了。 */} toggleCreator(group.key)} className="border-t border-cyber-border-subtle bg-cyber-bg-tertiary/50 cursor-pointer hover:bg-cyber-bg-elevated/50" > {METRIC_COLUMNS.map((column) => ( ))} {!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" > {METRIC_COLUMNS.map((column) => ( ))} {/* 发布日期和「首次发现」是两回事:把早就发过的作品加进监控时, 前者是作者发的那天,后者是我们第一次看到它的那天。 */} {open && ( )} ) })} ) })}
作品 {column.label} 发布日期 首次发现
{isCollapsed ? ( ) : ( )} {editingCreator === group.key ? ( event.stopPropagation()} onChange={(event) => setAliasDraft(event.target.value)} onKeyDown={(event) => { event.stopPropagation() if (event.key === 'Enter') commitAlias(group.key) if (event.key === 'Escape') { aliasCancelled.current = true setEditingCreator(null) } }} onBlur={() => commitAlias(group.key)} className="w-full rounded border border-cyber-neon-cyan/40 bg-cyber-bg-tertiary px-1.5 py-0.5 text-[11px] font-mono text-cyber-text-primary outline-none" /> ) : ( {group.alias || group.name || '未知博主'} {/* 起了备注之后,平台昵称降成副标题 —— 它仍是有用的对照。 */} {group.alias && group.name && ( {group.name} )} {group.notes.length} 篇 )}
{/* 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} 次快照
{formatDate(note.published_at)} {formatRelative(note.first_seen_at)} {/* 直接跳转到该作品。stopPropagation,否则点它会连带展开趋势图。 */} {note.note_url && ( event.stopPropagation()} title="打开原作品" className="text-cyber-text-muted hover:text-cyber-neon-cyan" > )}
) }