import { Fragment, useMemo, useRef, useState } from 'react' import { ChevronDown, ChevronRight, ExternalLink, Pencil, Tag, Users } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { useMonitorNotes, useSetCreatorAlias, useSetNoteAlias } from '@/hooks/useMonitor' import { formatCount, formatDate, formatDateTime, formatDelta, formatRelative, } from '@/lib/monitorFormat' import { cn } from '@/lib/utils' import { tagColorClass } from '@/lib/tagColors' import type { MonitorCreator, MonitorNote, NoteMetrics } from '@/types/monitor' import { NoteCover } from './NoteCover' import { NoteTagPicker } from './NoteTagPicker' import { NoteTrendChart } from './NoteTrendChart' interface NotesTableProps { taskId: number | null onlyNew: boolean /** 按标签筛。空数组 = 不筛。命中的是**任意一个**选中的标签。 */ tagIds?: number[] } /** 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 + 4 /** * 账号级指标那几个字段。 * * 作品的 payload 和博主的 payload 都带这一组,值也一样(同一次采集写的同一行), * 所以组件只认字段、不认来源。 */ type AccountStats = Pick< MonitorCreator, 'creator_fans' | 'creator_total_favorited' | 'creator_works' | 'creator_stats_at' > /** * 博主的**账号级**指标:粉丝 / 总获赞 / 作品数。 * * 作品列表给不了这个 —— 那几列说的是「这条作品涨了多少赞」,这里说的是「这个人整个 * 账号在涨还是在掉」。**一个都没采到时整块不画**:画成「粉丝 0」比不画糟得多,那是 * 一句假话。 */ function CreatorStats({ stats }: { stats: AccountStats }) { const parts = [ stats.creator_fans !== null && `粉丝 ${formatCount(stats.creator_fans)}`, stats.creator_total_favorited !== null && `获赞 ${formatCount(stats.creator_total_favorited)}`, stats.creator_works !== null && `${formatCount(stats.creator_works)} 作品`, ].filter(Boolean) as string[] if (parts.length === 0) return null return ( {parts.join(' · ')} ) } /** * 作品表,**按博主分组**。 * * 一个任务可以配多个博主,平铺的话根本看不出哪篇是谁的。分组键是 * `creator_hash` —— 爬虫刻意不落原始 user_id,所以这是唯一稳定的创作者标识; * 显示名是已脱敏的昵称(张***三)。两者都认不出来时才退回"未知博主"。 */ export function NotesTable({ taskId, onlyNew, tagIds = [] }: NotesTableProps) { const { data, isLoading } = useMonitorNotes(taskId, onlyNew, tagIds) const notes = data?.notes const creators = data?.creators 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 [editingNote, setEditingNote] = useState(null) const [noteAliasDraft, setNoteAliasDraft] = useState('') const noteAliasCancelled = useRef(false) const setNoteAlias = useSetNoteAlias() // 正在贴标签的那条作品。null = 对话框没开。 const [taggingNote, setTaggingNote] = useState(null) const commitAlias = (creatorHash: string) => { if (aliasCancelled.current) { aliasCancelled.current = false setEditingCreator(null) return } setEditingCreator(null) setAlias.mutate({ creatorHash, alias: aliasDraft }) } const commitNoteAlias = (noteId: string) => { if (noteAliasCancelled.current) { noteAliasCancelled.current = false setEditingNote(null) return } setEditingNote(null) setNoteAlias.mutate({ noteId, alias: noteAliasDraft }) } const groups = useMemo(() => { type Group = { key: string name: string alias: string /** 主页链接。空串就是不画那个按钮(见 MonitorCreator.creator_url)。 */ url: string /** 服务端说的这条博主名下有多少作品 —— 列表被 `onlyNew` 滤过时和 `notes.length` 不等。 */ totalNotes: number stats: MonitorCreator | null notes: MonitorNote[] } const byCreator = new Map() // **先放博主,不是先放作品。** 「一条作品都没有的博主」在作品里根本推不出来 —— // 目标加了、资料也采到了、粉丝数就躺在库里,可界面上什么都看不见。而他恰恰是最该 // 看见的一个:还在涨粉,只是最近没发东西。 for (const creator of creators ?? []) { const key = creator.creator_hash || '__unknown__' const existing = byCreator.get(key) if (existing) { // 同一个博主可能挂在多个任务下(服务端按 任务×博主 给),合并成一组。 existing.totalNotes += creator.note_count if (!existing.stats?.creator_stats_at && creator.creator_stats_at) { existing.stats = creator } existing.name ||= creator.creator_name // 备注按 (platform, creator_hash) 存,所以跨任务就是同一条,取到即可。 existing.alias ||= creator.creator_alias existing.url ||= creator.creator_url continue } byCreator.set(key, { key, name: creator.creator_name || '', alias: creator.creator_alias || '', url: creator.creator_url || '', totalNotes: creator.note_count, stats: creator, notes: [], }) } for (const note of notes ?? []) { // 作品模式下每条作品都会带 creator_hash;真丢了也要有个兜底分组, // 否则那些作品会凭空消失。 const key = note.creator_hash || '__unknown__' let group = byCreator.get(key) if (!group) { group = { key, name: note.creator_name || '', alias: note.creator_alias || '', url: '', totalNotes: 0, stats: null, notes: [], } byCreator.set(key, group) } group.notes.push(note) group.name ||= note.creator_name || '' group.alias ||= note.creator_alias || '' } return [...byCreator.values()] }, [notes, creators]) if (isLoading) { return

加载中…

} // 博主有、作品没有也是**要画**的:那正是「这个号还没被删,只是没发东西」。 if (groups.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} )} {/* 跳到他主页。链接是采集时从资料接口带回来的(哈希拼不出地址), 拿不到就整块不画 —— 一个点开 404 的按钮比没有按钮糟。 */} {group.url && ( event.stopPropagation()} title="打开博主主页" className="text-cyber-text-muted hover:text-cyber-neon-cyan flex-shrink-0" > )} {/* 被 onlyNew 滤过时,把「显示了几篇 / 一共几篇」都说出来, 否则「1 篇」会让人以为这个号只发过一条。 */} {group.notes.length} {group.totalNotes > group.notes.length && `/${group.totalNotes}`} 篇 {group.stats && } {/* 博主在、作品一条都没有 —— 说清楚,别让人以为列表挂了。 */} {group.notes.length === 0 && ( 暂无作品 )} )}
{/* min-w-0 so the title can actually truncate inside the flex row */}
{note.is_new && ( NEW )} {/* 备注是**加**在标题前面的一枚标记,不是替换 —— 标题才是这条作品本身,扫列表时两个都要看得见。 */} {note.note_alias && ( {note.note_alias} )} {note.title || note.note_id} {/* 跳转紧跟在标题后面 —— 它属于**这条作品**,挂在整行最右边 时就脱离了它修饰的东西,眼睛得横穿整张表去找。 stopPropagation,否则点它会连带展开趋势图。 */} {note.note_url && ( event.stopPropagation()} title="打开原作品" className="text-cyber-text-muted hover:text-cyber-neon-cyan flex-shrink-0" > )}
{editingNote === rowKey ? ( event.stopPropagation()} onChange={(event) => setNoteAliasDraft(event.target.value)} onKeyDown={(event) => { event.stopPropagation() if (event.key === 'Enter') commitNoteAlias(note.note_id) if (event.key === 'Escape') { noteAliasCancelled.current = true setEditingNote(null) } }} onBlur={() => commitNoteAlias(note.note_id)} className="mt-1 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" /> ) : (
{/* 标签放在这一行而不是标题行:标题行已经有 NEW、备注 和跳转,再塞几个标签就糊成一团了。标签跟着 id 和 快照数走,是同一层的元信息。 */} {note.tags.map((tag) => ( {tag.name} ))} {note.note_id} · {note.snapshot_count} 次快照
)}
{formatDate(note.published_at)} {formatRelative(note.first_seen_at)}
{/* 对话框挂在表格**外面** —— 放在 tbody 里会因为表格的布局规则出问题。 */} {taggingNote && ( { if (!open) setTaggingNote(null) }} /> )}
) }