Files
MediaCrawler/webui/src/components/monitor/NotesTable.tsx
T
butubb 0a88474c92
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(monitor): 给博主起备注 —— 作品栏里才认得出「这是谁」
作品栏按 creator_hash 把作品归到博主的组里,可那是个哈希;creator_name 是平台昵称,
粉丝少的号常常认不出。两样都没法把账号对上人。

* 新表 monitor_creator_alias,键取 **(platform, creator_hash)** 而不是按任务:
  哈希对同一个 uid 是稳定的,所以同一个博主出现在多个任务里时,备注只填一次。
* list_notes 带上 creator_alias(整体查一次再在内存里取,不是每条作品查一次)。
* `PUT /monitor/creators/{creator_hash}`,空串即清掉那条备注。
* 作品栏的博主组头:**优先显示备注**,起过备注之后平台昵称降成副标题(它仍是有用的
  对照);组头上一个铅笔按钮就地编辑,回车保存、失焦保存、Esc 取消。

Esc 那条要单独处理:取消之后紧接着的失焦会把刚放弃的内容存进去,所以用一个标记让那次
失焦闭嘴。

测试 +6:没起过时是空串、起了会跟着作品返回、**跨任务共用一条**、**不串到别的平台**、
空串清掉、前后空格会被去掉。
2026-10-10 18:00:13 +08:00

351 lines
15 KiB
TypeScript

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 (
<div className="text-right">
<div className="font-mono text-xs text-cyber-text-primary">{formatCount(value)}</div>
{deltaText && (
<div
className={`font-mono text-[9px] ${
(delta ?? 0) > 0 ? 'text-cyber-neon-green' : 'text-cyber-neon-pink'
}`}
>
{deltaText}
</div>
)}
</div>
)
}
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<string, number | null> = {}
const deltas: Record<string, number | null> = {}
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<string | null>(null)
// 折叠状态按博主记。默认全展开 —— 藏起来的数据比多滚两屏更糟。
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
// 正在改备注的那个博主(creator_hash);null = 没在改。
const [editingCreator, setEditingCreator] = useState<string | null>(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 <p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">加载中…</p>
}
if (!notes || notes.length === 0) {
return (
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">
{taskId === null
? '请先选择一个监控任务'
: onlyNew
? '最近一轮没有新增作品'
: '还没有采集到作品,运行一次任务后即可看到'}
</p>
)
}
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">
<table className="w-full text-xs font-mono">
<thead className="sticky top-0 bg-cyber-bg-tertiary">
<tr className="text-cyber-text-secondary text-[10px]">
<th className="w-6" />
<th className="text-left font-normal py-2 px-2">作品</th>
{METRIC_COLUMNS.map((column) => (
<th key={column.key} className="text-right font-normal py-2 px-2">
{column.label}
</th>
))}
<th className="text-right font-normal py-2 px-2">发布日期</th>
<th className="text-right font-normal py-2 px-2">首次发现</th>
<th className="w-8" />
</tr>
</thead>
<tbody>
{groups.map((group) => {
const isCollapsed = collapsed.has(group.key)
const totals = sumMetrics(group.notes)
return (
<Fragment key={group.key}>
{/* 组头与数据行**列对齐**:每个指标列给出该博主的合计,下面再带本轮增量。
折叠之后如果什么都看不到,那折叠就只是把信息藏起来了。 */}
<tr
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-1.5 px-1 text-cyber-text-muted">
{isCollapsed ? (
<ChevronRight className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
)}
</td>
<td className="py-1.5 px-2">
{editingCreator === group.key ? (
<input
autoFocus
value={aliasDraft}
placeholder="备注,比如「竞品A」"
onClick={(event) => 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"
/>
) : (
<span className="flex items-center gap-1.5">
<Users className="w-3 h-3 text-cyber-neon-cyan flex-shrink-0" />
<span className="text-cyber-text-primary truncate">
{group.alias || group.name || '未知博主'}
</span>
{/* 起了备注之后,平台昵称降成副标题 —— 它仍是有用的对照。 */}
{group.alias && group.name && (
<span className="text-cyber-text-muted truncate">{group.name}</span>
)}
<span className="text-cyber-text-muted flex-shrink-0">
{group.notes.length} 篇
</span>
<button
onClick={(event) => {
event.stopPropagation()
setAliasDraft(group.alias)
setEditingCreator(group.key)
}}
title="给这个博主起个备注"
className="text-cyber-text-muted hover:text-cyber-neon-cyan flex-shrink-0"
>
<Pencil className="w-3 h-3" />
</button>
</span>
)}
</td>
{METRIC_COLUMNS.map((column) => (
<td key={column.key} className="py-1.5 px-2">
<MetricCell
value={totals.values[column.key]}
delta={totals.deltas[column.key]}
/>
</td>
))}
<td />
<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 whitespace-nowrap"
title={formatDateTime(note.published_at)}
>
{formatDate(note.published_at)}
</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>
)
})}
</tbody>
</table>
</div>
)
}