import { Ban, Bot, CheckCheck, MessageSquareText, RefreshCcw, Shield, ShieldOff, Trash2, XCircle, } from 'lucide-react' import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Select } from '@/components/ui/select' import { Skeleton } from '@/components/ui/skeleton' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { adminApi, ApiError } from '@/lib/api' import { formatBrowserName, formatCommentScope, formatDateTime } from '@/lib/admin-format' import type { CommentBlacklistRecord, CommentPersonaAnalysisLogRecord, CommentPersonaAnalysisResponse, CommentRecord, } from '@/lib/types' type MatcherType = 'ip' | 'email' | 'user_agent' type PersonaItem = { matcherValue: string total: number approved: number pending: number firstAt: string latestAt: string latestPostSlug: string } function moderationBadgeVariant(approved: boolean | null) { if (approved) { return 'success' as const } return 'warning' as const } function normalizeMatcherValue(type: MatcherType, value: string | null | undefined) { const normalized = (value ?? '').trim() if (!normalized) { return '' } if (type === 'email') { return normalized.toLowerCase() } return normalized } function extractMatcherValue(comment: CommentRecord, type: MatcherType) { if (type === 'ip') { return normalizeMatcherValue(type, comment.ip_address) } if (type === 'email') { return normalizeMatcherValue(type, comment.email) } return normalizeMatcherValue(type, comment.user_agent) } function matcherLabel(type: MatcherType) { if (type === 'ip') return 'IP' if (type === 'email') return '邮箱' return '浏览器 UA' } function toPersonaAnalysisFromLog( item: CommentPersonaAnalysisLogRecord, ): CommentPersonaAnalysisResponse { return { matcher_type: item.matcher_type, matcher_value: item.matcher_value, total_comments: item.total_comments, pending_comments: item.pending_comments, first_seen_at: item.from_at, latest_seen_at: item.to_at, distinct_posts: item.distinct_posts, analysis: item.analysis, samples: item.samples, } } export function CommentsPage() { const [comments, setComments] = useState([]) const [blacklist, setBlacklist] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [actingId, setActingId] = useState(null) const [actingBlacklistId, setActingBlacklistId] = useState(null) const [banLoadingKey, setBanLoadingKey] = useState(null) const [searchTerm, setSearchTerm] = useState('') const [approvalFilter, setApprovalFilter] = useState('pending') const [scopeFilter, setScopeFilter] = useState('all') const [profileType, setProfileType] = useState('ip') const [selectedProfileValue, setSelectedProfileValue] = useState('') const [manualMatcherType, setManualMatcherType] = useState('ip') const [manualMatcherValue, setManualMatcherValue] = useState('') const [manualReason, setManualReason] = useState('') const [analyzingPersona, setAnalyzingPersona] = useState(false) const [personaAnalysis, setPersonaAnalysis] = useState(null) const [personaLogs, setPersonaLogs] = useState([]) const [personaLogsLoading, setPersonaLogsLoading] = useState(false) const loadComments = useCallback(async (showToast = false) => { try { if (showToast) { setRefreshing(true) } const [nextComments, nextBlacklist] = await Promise.all([ adminApi.listComments(), adminApi.listCommentBlacklist(), ]) startTransition(() => { setComments(nextComments) setBlacklist(nextBlacklist) }) if (showToast) { toast.success('评论列表已刷新。') } } catch (error) { if (error instanceof ApiError && error.status === 401) { return } toast.error(error instanceof ApiError ? error.message : '无法加载评论列表。') } finally { setLoading(false) setRefreshing(false) } }, []) useEffect(() => { void loadComments(false) }, [loadComments]) const filteredComments = useMemo(() => { return comments.filter((comment) => { const matchesSearch = !searchTerm || [ comment.author ?? '', comment.email ?? '', comment.post_slug ?? '', comment.content ?? '', comment.paragraph_excerpt ?? '', comment.paragraph_key ?? '', comment.ip_address ?? '', comment.user_agent ?? '', comment.referer ?? '', ] .join('\n') .toLowerCase() .includes(searchTerm.toLowerCase()) const matchesApproval = approvalFilter === 'all' || (approvalFilter === 'approved' && Boolean(comment.approved)) || (approvalFilter === 'pending' && !comment.approved) const matchesScope = scopeFilter === 'all' || comment.scope === scopeFilter return matchesSearch && matchesApproval && matchesScope }) }, [approvalFilter, comments, scopeFilter, searchTerm]) const blacklistedMatcherSet = useMemo(() => { return new Set( blacklist .filter((item) => item.effective) .map( (item) => `${item.matcher_type}:${normalizeMatcherValue( item.matcher_type as MatcherType, item.matcher_value, )}`, ), ) }, [blacklist]) const pendingCount = useMemo( () => comments.filter((comment) => !comment.approved).length, [comments], ) const paragraphCount = useMemo( () => comments.filter((comment) => comment.scope === 'paragraph').length, [comments], ) const personas = useMemo(() => { const map = new Map() filteredComments.forEach((comment) => { const matcherValue = extractMatcherValue(comment, profileType) if (!matcherValue) { return } const existing = map.get(matcherValue) if (existing) { existing.total += 1 if (comment.approved) { existing.approved += 1 } else { existing.pending += 1 } if (comment.created_at < existing.firstAt) { existing.firstAt = comment.created_at } if (comment.created_at > existing.latestAt) { existing.latestAt = comment.created_at existing.latestPostSlug = comment.post_slug ?? existing.latestPostSlug } return } map.set(matcherValue, { matcherValue, total: 1, approved: comment.approved ? 1 : 0, pending: comment.approved ? 0 : 1, firstAt: comment.created_at, latestAt: comment.created_at, latestPostSlug: comment.post_slug ?? 'unknown-post', }) }) return Array.from(map.values()).sort((left, right) => { if (right.total !== left.total) { return right.total - left.total } return right.latestAt.localeCompare(left.latestAt) }) }, [filteredComments, profileType]) useEffect(() => { if (!personas.length) { setSelectedProfileValue('') return } if (!personas.some((item) => item.matcherValue === selectedProfileValue)) { setSelectedProfileValue(personas[0].matcherValue) } }, [personas, selectedProfileValue]) const selectedPersona = useMemo( () => personas.find((item) => item.matcherValue === selectedProfileValue) ?? null, [personas, selectedProfileValue], ) const selectedPersonaComments = useMemo(() => { if (!selectedProfileValue) { return [] } return filteredComments .filter( (comment) => extractMatcherValue(comment, profileType) === normalizeMatcherValue(profileType, selectedProfileValue), ) .sort((left, right) => right.created_at.localeCompare(left.created_at)) }, [filteredComments, profileType, selectedProfileValue]) useEffect(() => { setPersonaAnalysis(null) }, [profileType, selectedProfileValue]) const loadPersonaLogs = useCallback( async (type: MatcherType, matcherValue?: string) => { try { setPersonaLogsLoading(true) const items = await adminApi.listCommentPersonaAnalysisLogs({ matcher_type: type, matcher_value: matcherValue, limit: 20, }) startTransition(() => { setPersonaLogs(items) }) } catch (error) { if (error instanceof ApiError && error.status === 401) { return } toast.error(error instanceof ApiError ? error.message : '加载分析历史失败。') } finally { setPersonaLogsLoading(false) } }, [], ) useEffect(() => { void loadPersonaLogs(profileType, selectedProfileValue || undefined) }, [loadPersonaLogs, profileType, selectedProfileValue]) const createBlacklist = useCallback( async (type: MatcherType, matcherValue: string, reason?: string) => { const normalizedValue = normalizeMatcherValue(type, matcherValue) if (!normalizedValue) { return } const key = `${type}:${normalizedValue}` if (blacklistedMatcherSet.has(key)) { toast.info('该规则已在黑名单中。') return } try { setBanLoadingKey(key) await adminApi.createCommentBlacklist({ matcher_type: type, matcher_value: normalizedValue, reason: reason || '后台快速封禁', active: true, }) toast.success(`已加入黑名单:${matcherLabel(type)} ${normalizedValue}`) await loadComments(false) } catch (error) { toast.error(error instanceof ApiError ? error.message : '创建黑名单失败。') } finally { setBanLoadingKey(null) } }, [blacklistedMatcherSet, loadComments], ) const analyzeSelectedPersona = useCallback(async () => { if (!selectedProfileValue) { toast.error('请先选择一个画像来源。') return } try { setAnalyzingPersona(true) const result = await adminApi.analyzeCommentPersona({ matcher_type: profileType, matcher_value: selectedProfileValue, limit: 24, }) startTransition(() => { setPersonaAnalysis(result) }) await loadPersonaLogs(profileType, selectedProfileValue) toast.success('AI 画像分析完成。') } catch (error) { toast.error(error instanceof ApiError ? error.message : 'AI 画像分析失败。') } finally { setAnalyzingPersona(false) } }, [loadPersonaLogs, profileType, selectedProfileValue]) return (
评论

评论审核队列

在一个页面中处理全文评论与段落评论,快速完成公开讨论区的审核工作。

待审核

{pendingCount}

需要人工审核处理。

段落评论

{paragraphCount}

挂载到具体段落锚点。

总数

{comments.length}

当前系统中全部评论。

评论列表 先筛选,再直接通过、隐藏、删除,或快速把来源加入黑名单。
setSearchTerm(event.target.value)} />
{loading ? ( ) : (
评论内容 状态 上下文 操作 {filteredComments.map((comment) => (
{comment.author ?? '匿名用户'} {formatCommentScope(comment.scope)} {formatDateTime(comment.created_at)}

{comment.content ?? '暂无评论内容。'}

{comment.scope === 'paragraph' ? (

{comment.paragraph_key ?? '缺少段落键'}

{comment.paragraph_excerpt ?? '没有保存段落摘录。'}

) : null}
{comment.approved ? '已通过' : '待审核'}

{comment.post_slug ?? '未知文章'}

{comment.ip_address ? `IP ${comment.ip_address}` : 'IP 未记录'}

浏览器:{formatBrowserName(comment.user_agent)}

{comment.referer ? (

来源:{comment.referer}

) : null} {comment.email ?

邮箱:{comment.email}

: null}
{comment.ip_address ? ( ) : null} {comment.email ? ( ) : null} {comment.user_agent ? ( ) : null}
))} {!filteredComments.length ? (

当前筛选条件下没有匹配的评论。

) : null}
人物画像视图 按 IP / 邮箱 / UA 聚合,并查看同源评论轨迹。
{personas.map((persona) => { const key = `${profileType}:${normalizeMatcherValue( profileType, persona.matcherValue, )}` const blacklisted = blacklistedMatcherSet.has(key) return ( ) })} {!personas.length ? (

当前筛选条件下暂无可聚合来源。

) : null}
{selectedPersona ? (

首次出现:{formatDateTime(selectedPersona.firstAt)}

最近评论:{formatDateTime(selectedPersona.latestAt)}

最近文章:{selectedPersona.latestPostSlug}

) : null}
setManualReason(event.target.value)} placeholder="封禁原因(可选)" />

AI 风险分析

{personaAnalysis ? (

匹配评论 {personaAnalysis.total_comments} · 待审核{' '} {personaAnalysis.pending_comments} · 涉及文章{' '} {personaAnalysis.distinct_posts}

                            {personaAnalysis.analysis}
                          
) : (

选中来源后可让 AI 输出风险等级、行为特征和处置建议。

)}

最近分析记录

{personaLogsLoading ? ( ) : personaLogs.length ? (
{personaLogs.map((item) => ( ))}
) : (

暂无历史分析记录。

)}
同源评论轨迹 当前按 {matcherLabel(profileType)} 查看,共 {selectedPersonaComments.length}{' '} 条。 {selectedPersonaComments.map((comment) => (
{comment.approved ? '通过' : '待审'} {formatDateTime(comment.created_at)} {comment.post_slug ?? 'unknown-post'}

{comment.content ?? '暂无内容'}

))} {!selectedPersonaComments.length ? (

请选择来源查看轨迹。

) : null}
黑名单管理 支持手动新增、停用与删除规则。
setManualMatcherValue(event.target.value)} placeholder="输入要封禁的值" />
setManualReason(event.target.value)} placeholder="原因(可选)" />
{blacklist.map((item) => (

{item.matcher_type}:{' '} {item.matcher_type === 'user_agent' ? formatBrowserName(item.matcher_value) : item.matcher_value}

{item.effective ? '生效中' : '已停用/过期'}
{item.reason ? (

{item.reason}

) : null}
))} {!blacklist.length ? (

暂无黑名单规则。

) : null}
)}
) }