需求:抖音和小红书都要能看到作品的发布日期。 `MonitorNote.published_at` 其实**一直在库里**(ingest 早就按平台取:小红书 time、 抖音 create_time),只是从来没往 API 和界面上透 —— 后端序列化没这个键,前端类型里 也没有,所以界面上只有「首次发现」。 * 后端:list_notes 的序列化补上 published_at;_note_meta_map 也带上,于是 list_comments 多一个 note_published_at,分组接口的桶多一个 published_at。 * 前端:NotesTable 新增「发布日期」列(要让分组表头的 colspan 从 +4 变 +5); 评论栏作品那一层在标题旁显示日期 —— 同名作品不少,日期能帮着认。 * 新增 formatDate:发布日期问的是「哪一天发的」,绝对日期比「3天前」好认,也不会 每天看都在变。具体到分钟的版本放在 title 里,悬停可见。 刻意和「首次发现」分开:前者是作者发布的那天,后者是我们第一次看到它的那天。把一个 早就存在的作品加进监控时,两者能差好几个月 —— 测试里就用不同的值把这两者钉住。 顺带修正一处过时注释:前端类型里还写着 creator_name 是「已脱敏的昵称」, 脱敏已经在上一个提交里关掉了(config.MASK_NICKNAME)。 测试 +2:桶要带发布日期;作品列表接口要带,且它不等于 first_seen_at。
378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import {
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Download,
|
|
ExternalLink,
|
|
LayoutList,
|
|
Rows3,
|
|
ThumbsUp,
|
|
Users,
|
|
} from 'lucide-react'
|
|
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
useCommentNoteOptions,
|
|
useMonitorComments,
|
|
useMonitorCommentsGrouped,
|
|
} from '@/hooks/useMonitor'
|
|
import { monitorApi } from '@/lib/api'
|
|
import { formatDate, formatRelative } from '@/lib/monitorFormat'
|
|
import type { CommentBucket, MonitorComment } from '@/types/monitor'
|
|
|
|
import { NoteCover } from './NoteCover'
|
|
|
|
interface CommentsFeedProps {
|
|
taskId: number | null
|
|
}
|
|
|
|
const ALL_NOTES = '__all__'
|
|
|
|
/** Cover + truncated title + link, identifying the work a comment sits under. */
|
|
function NoteBadge({
|
|
title,
|
|
cover,
|
|
url,
|
|
noteId,
|
|
compact = false,
|
|
}: {
|
|
title: string
|
|
cover: string
|
|
url: string
|
|
noteId: string
|
|
compact?: boolean
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<NoteCover src={cover} size={compact ? 'sm' : 'md'} />
|
|
<span className="truncate text-[10px] font-mono text-cyber-text-secondary" title={title || noteId}>
|
|
{title || noteId}
|
|
</span>
|
|
{url && (
|
|
<a
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-cyber-text-muted hover:text-cyber-neon-cyan flex-shrink-0"
|
|
title="打开原文"
|
|
>
|
|
<ExternalLink className="w-3 h-3" />
|
|
</a>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CommentRow({ comment, showNote }: { comment: MonitorComment; showNote: boolean }) {
|
|
return (
|
|
<div className="rounded-md border border-cyber-border-subtle bg-cyber-bg-tertiary/40 px-3 py-2">
|
|
<div className="flex items-center justify-between gap-2 text-[10px] font-mono">
|
|
<span className="text-cyber-text-secondary truncate">{comment.nickname || '匿名'}</span>
|
|
<span className="flex items-center gap-2 flex-shrink-0 text-cyber-text-muted">
|
|
{comment.like_count !== null && (
|
|
<span className="flex items-center gap-1">
|
|
<ThumbsUp className="w-3 h-3" />
|
|
{comment.like_count}
|
|
</span>
|
|
)}
|
|
{comment.sub_comment_count > 0 && <span>{comment.sub_comment_count} 条回复</span>}
|
|
<span>{formatRelative(comment.first_seen_at)}</span>
|
|
</span>
|
|
</div>
|
|
|
|
<p className="mt-1 text-[11px] font-mono text-cyber-text-primary break-words">
|
|
{comment.content}
|
|
</p>
|
|
|
|
{/* Only in the flat view: in a grouped view the header already says which
|
|
work these belong to, so repeating it on every row is noise. */}
|
|
{showNote && (
|
|
<div className="mt-1.5 pt-1.5 border-t border-cyber-border-subtle">
|
|
<NoteBadge
|
|
title={comment.note_title}
|
|
cover={comment.note_cover}
|
|
url={comment.note_url}
|
|
noteId={comment.note_id}
|
|
compact
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CollapsibleGroup({
|
|
bucket,
|
|
open,
|
|
onToggle,
|
|
}: {
|
|
bucket: CommentBucket
|
|
open: boolean
|
|
onToggle: () => void
|
|
}) {
|
|
return (
|
|
<div className="rounded-lg border border-cyber-border-subtle overflow-hidden">
|
|
<button
|
|
onClick={onToggle}
|
|
className="w-full flex items-center gap-2 px-3 py-2 bg-cyber-bg-tertiary/60 hover:bg-cyber-bg-elevated/60 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" />
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<NoteBadge
|
|
title={bucket.note_title}
|
|
cover={bucket.note_cover}
|
|
url={bucket.note_url}
|
|
noteId={bucket.note_id}
|
|
/>
|
|
</div>
|
|
{/* 作品那一层光有标题不够 —— 同名的作品不少,发布日期能帮着认。 */}
|
|
{bucket.published_at && (
|
|
<span
|
|
className="text-[10px] font-mono text-cyber-text-muted flex-shrink-0"
|
|
title="作品发布时间"
|
|
>
|
|
{formatDate(bucket.published_at)}
|
|
</span>
|
|
)}
|
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 flex-shrink-0">
|
|
{bucket.comments.length} 条
|
|
</Badge>
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="p-2 space-y-1.5">
|
|
{bucket.comments.map((comment) => (
|
|
<CommentRow
|
|
key={comment.comment_id}
|
|
comment={comment}
|
|
showNote={false}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 把作品分组按博主归并 —— 三级视图的第一级。 */
|
|
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) {
|
|
const [grouped, setGrouped] = useState(true)
|
|
const [noteFilter, setNoteFilter] = useState<string>(ALL_NOTES)
|
|
const [openNotes, setOpenNotes] = useState<Set<string> | null>(null)
|
|
|
|
const activeNote = noteFilter === ALL_NOTES ? null : noteFilter
|
|
const { data: noteOptions } = useCommentNoteOptions(taskId)
|
|
// Both hooks always run; react-query caches, so the inactive one costs one
|
|
// request and no rerender churn when toggling views.
|
|
const { data: groups, isLoading: loadingGroups } = useMonitorCommentsGrouped(taskId, activeNote)
|
|
const { data: flat, isLoading: loadingFlat } = useMonitorComments(taskId, activeNote)
|
|
|
|
// Expand the newest group only, so the panel does not open as a wall of text.
|
|
const expanded = useMemo(() => {
|
|
if (openNotes !== null) return openNotes
|
|
return new Set(groups && groups.length ? [groups[0].note_id] : [])
|
|
}, [openNotes, groups])
|
|
|
|
const toggleGroup = (noteId: string) => {
|
|
const next = new Set(expanded)
|
|
if (next.has(noteId)) next.delete(noteId)
|
|
else next.add(noteId)
|
|
setOpenNotes(next)
|
|
}
|
|
|
|
const total = grouped ? (groups ?? []).reduce((sum, g) => sum + g.comments.length, 0) : (flat ?? []).length
|
|
const isLoading = grouped ? loadingGroups : loadingFlat
|
|
|
|
if (taskId === null) {
|
|
return (
|
|
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">
|
|
请先选择一个监控任务
|
|
</p>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{/* Toolbar */}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Button
|
|
variant={grouped ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => {
|
|
setGrouped(true)
|
|
// Re-derive the default expansion for the new view.
|
|
setOpenNotes(null)
|
|
}}
|
|
>
|
|
<Rows3 className="w-3 h-3 mr-1" />
|
|
按作品分组
|
|
</Button>
|
|
<Button
|
|
variant={grouped ? 'outline' : 'default'}
|
|
size="sm"
|
|
onClick={() => setGrouped(false)}
|
|
>
|
|
<LayoutList className="w-3 h-3 mr-1" />
|
|
平铺
|
|
</Button>
|
|
|
|
<Select value={noteFilter} onValueChange={setNoteFilter}>
|
|
<SelectTrigger className="h-8 text-[11px] w-[240px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={ALL_NOTES}>全部作品</SelectItem>
|
|
{noteOptions?.map((note) => (
|
|
<SelectItem key={note.note_id} value={note.note_id}>
|
|
{`${note.note_title || note.note_id} (${note.comment_count})`}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<span className="text-[10px] font-mono text-cyber-text-muted">共 {total} 条</span>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="ml-auto"
|
|
onClick={() =>
|
|
window.open(
|
|
monitorApi.getExportUrl({
|
|
kind: 'comments',
|
|
taskId: taskId ?? undefined,
|
|
noteId: activeNote ?? undefined,
|
|
}),
|
|
'_blank',
|
|
)
|
|
}
|
|
>
|
|
<Download className="w-3 h-3 mr-1" />
|
|
导出 CSV
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Honest about what is NOT here. */}
|
|
<p className="text-[10px] font-mono text-cyber-neon-orange">
|
|
评论接口没有时间排序,这里只有平台默认排序下抓取到的前 N 条,
|
|
<span className="font-bold">沉底的新评论不会出现</span>。可在任务里调大「每篇评论抓取条数」。
|
|
</p>
|
|
|
|
{isLoading ? (
|
|
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">加载中…</p>
|
|
) : total === 0 ? (
|
|
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">
|
|
还没有采集到评论
|
|
</p>
|
|
) : grouped ? (
|
|
<div className="space-y-2">
|
|
{/* 三级:博主 -> 作品 -> 评论。一个任务可以配多个博主,平铺作品的话
|
|
看不出哪条评论属于谁。 */}
|
|
{groupByCreator(groups ?? []).map((creator) => (
|
|
<CreatorGroup
|
|
key={creator.key}
|
|
name={creator.name}
|
|
buckets={creator.buckets}
|
|
expandedNotes={expanded}
|
|
onToggleNote={toggleGroup}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{flat?.map((comment) => (
|
|
<CommentRow
|
|
key={`${comment.note_id}:${comment.comment_id}`}
|
|
comment={comment}
|
|
showNote
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|