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:
@@ -300,6 +300,7 @@ async def _ingest_notes(
|
|||||||
note_url=record.get("note_url") or "",
|
note_url=record.get("note_url") or "",
|
||||||
cover=cover,
|
cover=cover,
|
||||||
creator_hash=record.get("creator_hash") or "",
|
creator_hash=record.get("creator_hash") or "",
|
||||||
|
creator_name=record.get("nickname") or "",
|
||||||
source_kind=record.get("type") or "",
|
source_kind=record.get("type") or "",
|
||||||
published_at=_as_int(record.get("time")),
|
published_at=_as_int(record.get("time")),
|
||||||
first_seen_run_id=run.id,
|
first_seen_run_id=run.id,
|
||||||
@@ -328,6 +329,9 @@ async def _ingest_notes(
|
|||||||
# 重跑也修不回来。落盘那份由 covers.cache_pending 负责(网络操作不在本模块)。
|
# 重跑也修不回来。落盘那份由 covers.cache_pending 负责(网络操作不在本模块)。
|
||||||
if cover:
|
if cover:
|
||||||
note.cover = cover
|
note.cover = cover
|
||||||
|
# 昵称也要刷:作者改昵称是常事,只在首次入库写一次会一直显示旧的。
|
||||||
|
if record.get("nickname"):
|
||||||
|
note.creator_name = record["nickname"]
|
||||||
note.last_seen_run_id = run.id
|
note.last_seen_run_id = run.id
|
||||||
note.last_seen_at = now
|
note.last_seen_at = now
|
||||||
|
|
||||||
|
|||||||
@@ -220,7 +220,12 @@ class MonitorNote(MonitorBase):
|
|||||||
title: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
title: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
note_url: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
note_url: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
cover: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
cover: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
# 创作者匿名哈希。爬虫刻意不落原始 user_id(见 tools/user_hash.py),
|
||||||
|
# 所以这是唯一稳定的创作者标识 —— 按博主分组就靠它。
|
||||||
creator_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
creator_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
|
# 创作者昵称,**已由爬虫脱敏**(张***三 这种)。存的是脱敏后的值,与项目一贯的
|
||||||
|
# 匿名化姿态一致;不存的话分组只能显示一串哈希,根本认不出是谁。
|
||||||
|
creator_name: Mapped[str] = mapped_column(String(200), nullable=False, default="")
|
||||||
source_kind: Mapped[str] = mapped_column(String(16), nullable=False, default="")
|
source_kind: Mapped[str] = mapped_column(String(16), nullable=False, default="")
|
||||||
published_at: Mapped[Optional[int]] = mapped_column(BigInteger)
|
published_at: Mapped[Optional[int]] = mapped_column(BigInteger)
|
||||||
|
|
||||||
|
|||||||
@@ -397,6 +397,10 @@ async def list_notes(
|
|||||||
"note_id": note.note_id,
|
"note_id": note.note_id,
|
||||||
"title": note.title,
|
"title": note.title,
|
||||||
"note_url": note.note_url,
|
"note_url": note.note_url,
|
||||||
|
# 按博主分组用。creator_hash 是唯一稳定的创作者标识(原始 user_id
|
||||||
|
# 被爬虫刻意匿名化了),creator_name 是已脱敏的昵称。
|
||||||
|
"creator_hash": note.creator_hash,
|
||||||
|
"creator_name": note.creator_name,
|
||||||
# 优先给本地缓存地址:远程地址带签名、会过期(实测隔天即 403),
|
# 优先给本地缓存地址:远程地址带签名、会过期(实测隔天即 403),
|
||||||
# 本地那份不会。没有缓存时才退回远程,至少让图先显示出来。
|
# 本地那份不会。没有缓存时才退回远程,至少让图先显示出来。
|
||||||
"cover": covers.cover_url(note.note_id, note.cover),
|
"cover": covers.cover_url(note.note_id, note.cover),
|
||||||
@@ -478,6 +482,9 @@ async def _note_meta_map(
|
|||||||
"note_cover": covers.cover_url(row.note_id, row.cover),
|
"note_cover": covers.cover_url(row.note_id, row.cover),
|
||||||
"note_url": row.note_url,
|
"note_url": row.note_url,
|
||||||
"task_id": row.task_id,
|
"task_id": row.task_id,
|
||||||
|
# 博主维度也带上,评论流才能按 博主 -> 作品 -> 评论 三级展开。
|
||||||
|
"creator_hash": row.creator_hash,
|
||||||
|
"creator_name": row.creator_name,
|
||||||
}
|
}
|
||||||
for row in rows
|
for row in rows
|
||||||
}
|
}
|
||||||
@@ -523,6 +530,8 @@ async def list_comments(
|
|||||||
"note_title": meta.get(row.note_id, {}).get("note_title", ""),
|
"note_title": meta.get(row.note_id, {}).get("note_title", ""),
|
||||||
"note_cover": meta.get(row.note_id, {}).get("note_cover", ""),
|
"note_cover": meta.get(row.note_id, {}).get("note_cover", ""),
|
||||||
"note_url": meta.get(row.note_id, {}).get("note_url", ""),
|
"note_url": meta.get(row.note_id, {}).get("note_url", ""),
|
||||||
|
"note_creator_hash": meta.get(row.note_id, {}).get("creator_hash", ""),
|
||||||
|
"note_creator_name": meta.get(row.note_id, {}).get("creator_name", ""),
|
||||||
}
|
}
|
||||||
for row in comments
|
for row in comments
|
||||||
]
|
]
|
||||||
@@ -573,6 +582,8 @@ async def comment_note_groups(
|
|||||||
"note_title": meta.get(note_id, {}).get("note_title", ""),
|
"note_title": meta.get(note_id, {}).get("note_title", ""),
|
||||||
"note_cover": meta.get(note_id, {}).get("note_cover", ""),
|
"note_cover": meta.get(note_id, {}).get("note_cover", ""),
|
||||||
"note_url": meta.get(note_id, {}).get("note_url", ""),
|
"note_url": meta.get(note_id, {}).get("note_url", ""),
|
||||||
|
"creator_hash": meta.get(note_id, {}).get("creator_hash", ""),
|
||||||
|
"creator_name": meta.get(note_id, {}).get("creator_name", ""),
|
||||||
"comment_count": count,
|
"comment_count": count,
|
||||||
"latest_at": latest.get(note_id, 0),
|
"latest_at": latest.get(note_id, 0),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
LayoutList,
|
LayoutList,
|
||||||
Rows3,
|
Rows3,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
|
Users,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -156,6 +157,78 @@ function CollapsibleGroup({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 把作品分组按博主归并 —— 三级视图的第一级。 */
|
||||||
|
function groupByCreator(buckets: CommentBucket[]) {
|
||||||
|
const byCreator = new Map<string, { key: string; name: string; buckets: CommentBucket[] }>()
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
const key = bucket.creator_hash || '__unknown__'
|
||||||
|
if (!byCreator.has(key)) {
|
||||||
|
byCreator.set(key, { key, name: bucket.creator_name || '', buckets: [] })
|
||||||
|
}
|
||||||
|
byCreator.get(key)!.buckets.push(bucket)
|
||||||
|
}
|
||||||
|
return [...byCreator.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一级:博主。二级作品、三级评论都收在它下面。
|
||||||
|
*
|
||||||
|
* 分组键是 `creator_hash`(爬虫刻意不落原始 user_id,这是唯一稳定的创作者标识),
|
||||||
|
* 显示名用已脱敏的昵称。默认展开 —— 折叠的默认值不该把数据藏起来。
|
||||||
|
*/
|
||||||
|
function CreatorGroup({
|
||||||
|
name,
|
||||||
|
buckets,
|
||||||
|
expandedNotes,
|
||||||
|
onToggleNote,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
buckets: CommentBucket[]
|
||||||
|
expandedNotes: Set<string>
|
||||||
|
onToggleNote: (noteId: string) => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(true)
|
||||||
|
const commentCount = buckets.reduce((sum, bucket) => sum + bucket.comments.length, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-cyber-border-DEFAULT overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 bg-cyber-bg-elevated/50 hover:bg-cyber-bg-elevated/80 transition-colors text-left"
|
||||||
|
>
|
||||||
|
{open ? (
|
||||||
|
<ChevronDown className="w-3.5 h-3.5 text-cyber-text-muted flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3.5 h-3.5 text-cyber-text-muted flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<Users className="w-3.5 h-3.5 text-cyber-neon-cyan flex-shrink-0" />
|
||||||
|
<span className="font-mono text-xs text-cyber-text-primary truncate">
|
||||||
|
{name || '未知博主'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-mono text-cyber-text-muted flex-shrink-0">
|
||||||
|
{buckets.length} 篇作品
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 flex-shrink-0 ml-auto">
|
||||||
|
{commentCount} 条评论
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="p-2 space-y-2 bg-cyber-bg-secondary/30">
|
||||||
|
{buckets.map((bucket) => (
|
||||||
|
<CollapsibleGroup
|
||||||
|
key={bucket.note_id}
|
||||||
|
bucket={bucket}
|
||||||
|
open={expandedNotes.has(bucket.note_id)}
|
||||||
|
onToggle={() => onToggleNote(bucket.note_id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function CommentsFeed({ taskId }: CommentsFeedProps) {
|
export function CommentsFeed({ taskId }: CommentsFeedProps) {
|
||||||
const [grouped, setGrouped] = useState(true)
|
const [grouped, setGrouped] = useState(true)
|
||||||
const [noteFilter, setNoteFilter] = useState<string>(ALL_NOTES)
|
const [noteFilter, setNoteFilter] = useState<string>(ALL_NOTES)
|
||||||
@@ -267,12 +340,15 @@ export function CommentsFeed({ taskId }: CommentsFeedProps) {
|
|||||||
</p>
|
</p>
|
||||||
) : grouped ? (
|
) : grouped ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{groups?.map((bucket) => (
|
{/* 三级:博主 -> 作品 -> 评论。一个任务可以配多个博主,平铺作品的话
|
||||||
<CollapsibleGroup
|
看不出哪条评论属于谁。 */}
|
||||||
key={bucket.note_id}
|
{groupByCreator(groups ?? []).map((creator) => (
|
||||||
bucket={bucket}
|
<CreatorGroup
|
||||||
open={expanded.has(bucket.note_id)}
|
key={creator.key}
|
||||||
onToggle={() => toggleGroup(bucket.note_id)}
|
name={creator.name}
|
||||||
|
buckets={creator.buckets}
|
||||||
|
expandedNotes={expanded}
|
||||||
|
onToggleNote={toggleGroup}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Fragment, useState } from 'react'
|
import { Fragment, useMemo, useState } from 'react'
|
||||||
import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ExternalLink, Users } from 'lucide-react'
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { useMonitorNotes } from '@/hooks/useMonitor'
|
import { useMonitorNotes } from '@/hooks/useMonitor'
|
||||||
@@ -39,9 +39,35 @@ const METRIC_COLUMNS: Array<{ key: keyof NoteMetrics; label: string }> = [
|
|||||||
{ key: 'share_count', label: '分享' },
|
{ key: 'share_count', label: '分享' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 多出来的那个空列给展开箭头;分组表头会跨掉整行。
|
||||||
|
const COLUMN_COUNT = METRIC_COLUMNS.length + 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作品表,**按博主分组**。
|
||||||
|
*
|
||||||
|
* 一个任务可以配多个博主,平铺的话根本看不出哪篇是谁的。分组键是
|
||||||
|
* `creator_hash` —— 爬虫刻意不落原始 user_id,所以这是唯一稳定的创作者标识;
|
||||||
|
* 显示名是已脱敏的昵称(张***三)。两者都认不出来时才退回"未知博主"。
|
||||||
|
*/
|
||||||
export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
|
export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
|
||||||
const { data: notes, isLoading } = useMonitorNotes(taskId, onlyNew)
|
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) {
|
if (isLoading) {
|
||||||
return <p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">加载中…</p>
|
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 (
|
return (
|
||||||
<div className="overflow-x-auto terminal-scroll">
|
<div className="overflow-x-auto terminal-scroll">
|
||||||
@@ -78,71 +110,114 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{notes.map((note) => {
|
{groups.map((group) => {
|
||||||
const open = isExpanded(note)
|
const isCollapsed = collapsed.has(group.key)
|
||||||
return (
|
return (
|
||||||
<Fragment key={`${note.task_id}:${note.note_id}`}>
|
<Fragment key={group.key}>
|
||||||
<tr
|
<tr
|
||||||
onClick={() => setExpanded(open ? null : `${note.task_id}:${note.note_id}`)}
|
onClick={() => toggleCreator(group.key)}
|
||||||
className="border-t border-cyber-border-subtle hover:bg-cyber-bg-elevated/50 cursor-pointer"
|
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">
|
<td className="py-1.5 px-1 text-cyber-text-muted">
|
||||||
{open ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
{isCollapsed ? (
|
||||||
</td>
|
<ChevronRight className="w-3 h-3" />
|
||||||
<td className="py-2 px-2 max-w-[320px]">
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<ChevronDown className="w-3 h-3" />
|
||||||
<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>
|
</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>
|
</tr>
|
||||||
{open && (
|
|
||||||
<tr className="bg-cyber-bg-secondary/40">
|
{!isCollapsed &&
|
||||||
<td colSpan={METRIC_COLUMNS.length + 4} className="px-4 py-3">
|
group.notes.map((note) => {
|
||||||
<NoteTrendChart
|
const rowKey = `${note.task_id}:${note.note_id}`
|
||||||
noteId={note.note_id}
|
const open = expandedNote === rowKey
|
||||||
taskId={note.task_id}
|
return (
|
||||||
noteTitle={note.title}
|
<Fragment key={rowKey}>
|
||||||
/>
|
<tr
|
||||||
</td>
|
onClick={() => setExpandedNote(open ? null : rowKey)}
|
||||||
</tr>
|
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>
|
</Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ export interface MonitorNote {
|
|||||||
title: string
|
title: string
|
||||||
note_url: string
|
note_url: string
|
||||||
cover: string
|
cover: string
|
||||||
|
/**
|
||||||
|
* 创作者标识。
|
||||||
|
*
|
||||||
|
* `creator_hash` 是唯一稳定的分组依据 —— 爬虫刻意不落原始 user_id(见
|
||||||
|
* tools/user_hash.py),所以没有比它更具体的身份了。
|
||||||
|
* `creator_name` 是**已脱敏**的昵称(张***三 这种)。
|
||||||
|
*/
|
||||||
|
creator_hash: string
|
||||||
|
creator_name: string
|
||||||
first_seen_at: number
|
first_seen_at: number
|
||||||
last_seen_at: number
|
last_seen_at: number
|
||||||
is_new: boolean
|
is_new: boolean
|
||||||
@@ -116,6 +125,9 @@ export interface MonitorComment {
|
|||||||
note_title: string
|
note_title: string
|
||||||
note_cover: string
|
note_cover: string
|
||||||
note_url: string
|
note_url: string
|
||||||
|
/** 所属作品的创作者 —— 评论流按 博主 -> 作品 -> 评论 三级展开时用。 */
|
||||||
|
note_creator_hash: string
|
||||||
|
note_creator_name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One work that has comments, for the filter dropdown. */
|
/** One work that has comments, for the filter dropdown. */
|
||||||
@@ -124,6 +136,8 @@ export interface CommentNoteOption {
|
|||||||
note_title: string
|
note_title: string
|
||||||
note_cover: string
|
note_cover: string
|
||||||
note_url: string
|
note_url: string
|
||||||
|
creator_hash: string
|
||||||
|
creator_name: string
|
||||||
comment_count: number
|
comment_count: number
|
||||||
latest_at: number
|
latest_at: number
|
||||||
}
|
}
|
||||||
@@ -134,6 +148,8 @@ export interface CommentBucket {
|
|||||||
note_title: string
|
note_title: string
|
||||||
note_cover: string
|
note_cover: string
|
||||||
note_url: string
|
note_url: string
|
||||||
|
creator_hash: string
|
||||||
|
creator_name: string
|
||||||
comments: MonitorComment[]
|
comments: MonitorComment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user