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 (
{title || noteId} {url && ( )}
) } function CommentRow({ comment, showNote }: { comment: MonitorComment; showNote: boolean }) { return (
{comment.nickname || '匿名'} {comment.like_count !== null && ( {comment.like_count} )} {comment.sub_comment_count > 0 && {comment.sub_comment_count} 条回复} {formatRelative(comment.first_seen_at)}

{comment.content}

{/* 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 && (
)}
) } function CollapsibleGroup({ bucket, open, onToggle, }: { bucket: CommentBucket open: boolean onToggle: () => void }) { return (
{open && (
{bucket.comments.map((comment) => ( ))}
)}
) } /** 把作品分组按博主归并 —— 三级视图的第一级。 */ function groupByCreator(buckets: CommentBucket[]) { const byCreator = new Map() 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 onToggleNote: (noteId: string) => void }) { const [open, setOpen] = useState(true) const commentCount = buckets.reduce((sum, bucket) => sum + bucket.comments.length, 0) return (
{open && (
{buckets.map((bucket) => ( onToggleNote(bucket.note_id)} /> ))}
)}
) } export function CommentsFeed({ taskId }: CommentsFeedProps) { const [grouped, setGrouped] = useState(true) const [noteFilter, setNoteFilter] = useState(ALL_NOTES) const [openNotes, setOpenNotes] = useState | 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 (

请先选择一个监控任务

) } return (
{/* Toolbar */}
共 {total} 条
{/* Honest about what is NOT here. */}

评论接口没有时间排序,这里只有平台默认排序下抓取到的前 N 条, 沉底的新评论不会出现。可在任务里调大「每篇评论抓取条数」。

{isLoading ? (

加载中…

) : total === 0 ? (

还没有采集到评论

) : grouped ? (
{/* 三级:博主 -> 作品 -> 评论。一个任务可以配多个博主,平铺作品的话 看不出哪条评论属于谁。 */} {groupByCreator(groups ?? []).map((creator) => ( ))}
) : (
{flat?.map((comment) => ( ))}
)}
) }