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.published_at && (
{formatDate(bucket.published_at)}
)}
{bucket.comments.length} 条
{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 (
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 ? (
) : (
)}
{name || '未知博主'}
{buckets.length} 篇作品
{commentCount} 条评论
{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 */}
{
setGrouped(true)
// Re-derive the default expansion for the new view.
setOpenNotes(null)
}}
>
按作品分组
setGrouped(false)}
>
平铺
全部作品
{noteOptions?.map((note) => (
{`${note.note_title || note.note_id} (${note.comment_count})`}
))}
共 {total} 条
window.open(
monitorApi.getExportUrl({
kind: 'comments',
taskId: taskId ?? undefined,
noteId: activeNote ?? undefined,
}),
'_blank',
)
}
>
导出 CSV
{/* Honest about what is NOT here. */}
评论接口没有时间排序,这里只有平台默认排序下抓取到的前 N 条,
沉底的新评论不会出现 。可在任务里调大「每篇评论抓取条数」。
{isLoading ? (
加载中…
) : total === 0 ? (
还没有采集到评论
) : grouped ? (
{/* 三级:博主 -> 作品 -> 评论。一个任务可以配多个博主,平铺作品的话
看不出哪条评论属于谁。 */}
{groupByCreator(groups ?? []).map((creator) => (
))}
) : (
{flat?.map((comment) => (
))}
)}
)
}