import { CheckCheck, MessageSquareText, RefreshCcw, 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 { formatCommentScope, formatDateTime } from '@/lib/admin-format' import type { CommentRecord } from '@/lib/types' function moderationBadgeVariant(approved: boolean | null) { if (approved) { return 'success' as const } return 'warning' as const } export function CommentsPage() { const [comments, setComments] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [actingId, setActingId] = useState(null) const [searchTerm, setSearchTerm] = useState('') const [approvalFilter, setApprovalFilter] = useState('pending') const [scopeFilter, setScopeFilter] = useState('all') const loadComments = useCallback(async (showToast = false) => { try { if (showToast) { setRefreshing(true) } const next = await adminApi.listComments() startTransition(() => { setComments(next) }) 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.post_slug ?? '', comment.content ?? '', comment.paragraph_excerpt ?? '', comment.paragraph_key ?? '', ] .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 pendingCount = useMemo( () => comments.filter((comment) => !comment.approved).length, [comments], ) const paragraphCount = useMemo( () => comments.filter((comment) => comment.scope === 'paragraph').length, [comments], ) 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.reply_to_comment_id ? (

回复评论 #{comment.reply_to_comment_id}

) : (

顶级评论

)}
))} {!filteredComments.length ? (

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

) : null}
)}
) }