feat(monitor): 作品栏按博主分组折叠,评论栏改为 博主/作品/评论 三级
先回答现状:跳转作品的按钮本来就有(作品栏每行末尾的外链图标); 评论栏原本是**两级**(作品 -> 评论),第一级是作品不是博主,所以并不是三级。 - 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:
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user