和「作品备注」刻意并存,不是一回事:备注是一句话的自由文字,标签是**从一份固定
词表里选的分类**。分类必须封闭,否则「重点 / 重要 / 优先」各写各的,筛就没法用了。
* `monitor_tag`:词表,全局一套(不按平台分)—— 「重点」是给人自己用的心智,不该在
小红书和抖音各定义一遍。颜色存的是**调色板里的名字**,不是色值:Tailwind 的类名是
静态提取的,拼出来的 `text-${color}` 它看不见,线上会静默变无色。
* `monitor_note_tag`:作品↔标签,**多对多**。一条作品可以既是重点又是竞品;只能贴一个
的话人就会跑去备注里写自由文字,这份词表就白建了。全部替换式提交,不是逐个增删。
* 删除标签时**显式删关联**,不靠外键级联 —— 测试跑 SQLite,它默认不开外键约束。
* 组头上的筛选做成平铺开关片,多选是「或」。
顺带:/monitor/notes 现在支持按 tag_id 筛。
565 lines
25 KiB
TypeScript
565 lines
25 KiB
TypeScript
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 (
|
|
<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 + 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 (
|
|
<span
|
|
className="text-cyber-text-muted flex-shrink-0"
|
|
title={`账号指标采集于 ${formatDateTime(stats.creator_stats_at)}`}
|
|
>
|
|
{parts.join(' · ')}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 作品表,**按博主分组**。
|
|
*
|
|
* 一个任务可以配多个博主,平铺的话根本看不出哪篇是谁的。分组键是
|
|
* `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<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 [editingNote, setEditingNote] = useState<string | null>(null)
|
|
const [noteAliasDraft, setNoteAliasDraft] = useState('')
|
|
const noteAliasCancelled = useRef(false)
|
|
const setNoteAlias = useSetNoteAlias()
|
|
// 正在贴标签的那条作品。null = 对话框没开。
|
|
const [taggingNote, setTaggingNote] = useState<MonitorNote | null>(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<string, Group>()
|
|
|
|
// **先放博主,不是先放作品。** 「一条作品都没有的博主」在作品里根本推不出来 ——
|
|
// 目标加了、资料也采到了、粉丝数就躺在库里,可界面上什么都看不见。而他恰恰是最该
|
|
// 看见的一个:还在涨粉,只是最近没发东西。
|
|
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 <p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">加载中…</p>
|
|
}
|
|
|
|
// 博主有、作品没有也是**要画**的:那正是「这个号还没被删,只是没发东西」。
|
|
if (groups.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>
|
|
</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>
|
|
)}
|
|
{/* 跳到他主页。链接是采集时从资料接口带回来的(哈希拼不出地址),
|
|
拿不到就整块不画 —— 一个点开 404 的按钮比没有按钮糟。 */}
|
|
{group.url && (
|
|
<a
|
|
href={group.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(event) => event.stopPropagation()}
|
|
title="打开博主主页"
|
|
className="text-cyber-text-muted hover:text-cyber-neon-cyan flex-shrink-0"
|
|
>
|
|
<ExternalLink className="w-3 h-3" />
|
|
</a>
|
|
)}
|
|
<span className="text-cyber-text-muted flex-shrink-0">
|
|
{/* 被 onlyNew 滤过时,把「显示了几篇 / 一共几篇」都说出来,
|
|
否则「1 篇」会让人以为这个号只发过一条。 */}
|
|
{group.notes.length}
|
|
{group.totalNotes > group.notes.length && `/${group.totalNotes}`} 篇
|
|
</span>
|
|
{group.stats && <CreatorStats stats={group.stats} />}
|
|
{/* 博主在、作品一条都没有 —— 说清楚,别让人以为列表挂了。 */}
|
|
{group.notes.length === 0 && (
|
|
<span className="text-cyber-text-muted/70 flex-shrink-0">
|
|
暂无作品
|
|
</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>
|
|
)}
|
|
{/* 备注是**加**在标题前面的一枚标记,不是替换 ——
|
|
标题才是这条作品本身,扫列表时两个都要看得见。 */}
|
|
{note.note_alias && (
|
|
<Badge
|
|
variant="default"
|
|
className="text-[9px] px-1 py-0 flex-shrink-0"
|
|
>
|
|
{note.note_alias}
|
|
</Badge>
|
|
)}
|
|
<span
|
|
className="truncate text-cyber-text-primary"
|
|
title={note.title}
|
|
>
|
|
{note.title || note.note_id}
|
|
</span>
|
|
{/* 跳转紧跟在标题后面 —— 它属于**这条作品**,挂在整行最右边
|
|
时就脱离了它修饰的东西,眼睛得横穿整张表去找。
|
|
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 flex-shrink-0"
|
|
>
|
|
<ExternalLink className="w-3 h-3" />
|
|
</a>
|
|
)}
|
|
</div>
|
|
{editingNote === rowKey ? (
|
|
<input
|
|
autoFocus
|
|
value={noteAliasDraft}
|
|
placeholder="备注,比如「重点跟拍」"
|
|
onClick={(event) => 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"
|
|
/>
|
|
) : (
|
|
<div className="flex items-center gap-1.5 text-[9px] text-cyber-text-muted">
|
|
{/* 标签放在这一行而不是标题行:标题行已经有 NEW、备注
|
|
和跳转,再塞几个标签就糊成一团了。标签跟着 id 和
|
|
快照数走,是同一层的元信息。 */}
|
|
{note.tags.map((tag) => (
|
|
<span
|
|
key={tag.id}
|
|
className={cn(
|
|
'rounded border px-1 py-0 text-[9px] font-mono flex-shrink-0',
|
|
tagColorClass(tag.color),
|
|
)}
|
|
>
|
|
{tag.name}
|
|
</span>
|
|
))}
|
|
<span className="truncate">
|
|
{note.note_id} · {note.snapshot_count} 次快照
|
|
</span>
|
|
<button
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
setNoteAliasDraft(note.note_alias)
|
|
setEditingNote(rowKey)
|
|
}}
|
|
title="给这条作品起个备注"
|
|
className="flex items-center gap-0.5 flex-shrink-0 text-cyber-text-muted hover:text-cyber-neon-cyan"
|
|
>
|
|
<Pencil className="w-2.5 h-2.5" />
|
|
{note.note_alias ? '改备注' : '备注'}
|
|
</button>
|
|
<button
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
setTaggingNote(note)
|
|
}}
|
|
title="给这条作品贴标签"
|
|
className="flex items-center gap-0.5 flex-shrink-0 text-cyber-text-muted hover:text-cyber-neon-cyan"
|
|
>
|
|
<Tag className="w-2.5 h-2.5" />
|
|
标签
|
|
</button>
|
|
</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>
|
|
</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>
|
|
|
|
{/* 对话框挂在表格**外面** —— 放在 tbody 里会因为表格的布局规则出问题。 */}
|
|
{taggingNote && (
|
|
<NoteTagPicker
|
|
noteId={taggingNote.note_id}
|
|
noteTitle={taggingNote.title}
|
|
current={taggingNote.tags}
|
|
open={taggingNote !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setTaggingNote(null)
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|