1004 lines
42 KiB
TypeScript
1004 lines
42 KiB
TypeScript
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<CommentRecord[]>([])
|
||
const [blacklist, setBlacklist] = useState<CommentBlacklistRecord[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [refreshing, setRefreshing] = useState(false)
|
||
const [actingId, setActingId] = useState<number | null>(null)
|
||
const [actingBlacklistId, setActingBlacklistId] = useState<number | null>(null)
|
||
const [banLoadingKey, setBanLoadingKey] = useState<string | null>(null)
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [approvalFilter, setApprovalFilter] = useState('pending')
|
||
const [scopeFilter, setScopeFilter] = useState('all')
|
||
const [profileType, setProfileType] = useState<MatcherType>('ip')
|
||
const [selectedProfileValue, setSelectedProfileValue] = useState('')
|
||
const [manualMatcherType, setManualMatcherType] = useState<MatcherType>('ip')
|
||
const [manualMatcherValue, setManualMatcherValue] = useState('')
|
||
const [manualReason, setManualReason] = useState('')
|
||
const [analyzingPersona, setAnalyzingPersona] = useState(false)
|
||
const [personaAnalysis, setPersonaAnalysis] =
|
||
useState<CommentPersonaAnalysisResponse | null>(null)
|
||
const [personaLogs, setPersonaLogs] = useState<CommentPersonaAnalysisLogRecord[]>([])
|
||
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<string, PersonaItem>()
|
||
|
||
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 (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||
<div className="space-y-3">
|
||
<Badge variant="secondary">评论</Badge>
|
||
<div>
|
||
<h2 className="text-3xl font-semibold tracking-tight">评论审核队列</h2>
|
||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||
在一个页面中处理全文评论与段落评论,快速完成公开讨论区的审核工作。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<Button variant="secondary" onClick={() => void loadComments(true)} disabled={refreshing}>
|
||
<RefreshCcw className="h-4 w-4" />
|
||
{refreshing ? '刷新中...' : '刷新'}
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-3">
|
||
<Card className="bg-gradient-to-br from-card via-card to-background/70">
|
||
<CardContent className="pt-6">
|
||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">待审核</p>
|
||
<div className="mt-3 text-3xl font-semibold">{pendingCount}</div>
|
||
<p className="mt-2 text-sm text-muted-foreground">需要人工审核处理。</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="bg-gradient-to-br from-card via-card to-background/70">
|
||
<CardContent className="pt-6">
|
||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||
段落评论
|
||
</p>
|
||
<div className="mt-3 text-3xl font-semibold">{paragraphCount}</div>
|
||
<p className="mt-2 text-sm text-muted-foreground">挂载到具体段落锚点。</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="bg-gradient-to-br from-card via-card to-background/70">
|
||
<CardContent className="pt-6">
|
||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">总数</p>
|
||
<div className="mt-3 text-3xl font-semibold">{comments.length}</div>
|
||
<p className="mt-2 text-sm text-muted-foreground">当前系统中全部评论。</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>评论列表</CardTitle>
|
||
<CardDescription>
|
||
先筛选,再直接通过、隐藏、删除,或快速把来源加入黑名单。
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid gap-3 lg:grid-cols-[1.2fr_0.6fr_0.6fr]">
|
||
<Input
|
||
placeholder="按作者、邮箱、IP、浏览器、文章 slug 或评论内容搜索"
|
||
value={searchTerm}
|
||
onChange={(event) => setSearchTerm(event.target.value)}
|
||
/>
|
||
<Select
|
||
value={approvalFilter}
|
||
onChange={(event) => setApprovalFilter(event.target.value)}
|
||
>
|
||
<option value="all">全部状态</option>
|
||
<option value="pending">仅看待审核</option>
|
||
<option value="approved">仅看已通过</option>
|
||
</Select>
|
||
<Select value={scopeFilter} onChange={(event) => setScopeFilter(event.target.value)}>
|
||
<option value="all">全部范围</option>
|
||
<option value="article">全文</option>
|
||
<option value="paragraph">段落</option>
|
||
</Select>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<Skeleton className="h-[680px] rounded-3xl" />
|
||
) : (
|
||
<div className="grid gap-4 2xl:grid-cols-[1.35fr_1fr]">
|
||
<div className="overflow-auto rounded-2xl border border-border/70">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>评论内容</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead>上下文</TableHead>
|
||
<TableHead>操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{filteredComments.map((comment) => (
|
||
<TableRow key={comment.id}>
|
||
<TableCell>
|
||
<div className="space-y-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="font-medium">{comment.author ?? '匿名用户'}</span>
|
||
<Badge variant="outline">{formatCommentScope(comment.scope)}</Badge>
|
||
<span className="text-xs text-muted-foreground">
|
||
{formatDateTime(comment.created_at)}
|
||
</span>
|
||
</div>
|
||
<p className="text-sm leading-6 text-muted-foreground">
|
||
{comment.content ?? '暂无评论内容。'}
|
||
</p>
|
||
{comment.scope === 'paragraph' ? (
|
||
<div className="rounded-2xl border border-border/70 bg-background/60 px-3 py-2 text-xs text-muted-foreground">
|
||
<p className="font-mono">{comment.paragraph_key ?? '缺少段落键'}</p>
|
||
<p className="mt-1">
|
||
{comment.paragraph_excerpt ?? '没有保存段落摘录。'}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant={moderationBadgeVariant(comment.approved)}>
|
||
{comment.approved ? '已通过' : '待审核'}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="space-y-1 text-sm text-muted-foreground">
|
||
<p className="font-mono text-xs">{comment.post_slug ?? '未知文章'}</p>
|
||
<p className="font-mono text-xs">
|
||
{comment.ip_address ? `IP ${comment.ip_address}` : 'IP 未记录'}
|
||
</p>
|
||
<p
|
||
className="max-w-[280px] truncate"
|
||
title={comment.user_agent ?? undefined}
|
||
>
|
||
浏览器:{formatBrowserName(comment.user_agent)}
|
||
</p>
|
||
{comment.referer ? (
|
||
<p className="max-w-[280px] truncate" title={comment.referer}>
|
||
来源:{comment.referer}
|
||
</p>
|
||
) : null}
|
||
{comment.email ? <p>邮箱:{comment.email}</p> : null}
|
||
<div className="flex flex-wrap gap-2 pt-1">
|
||
{comment.ip_address ? (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={
|
||
banLoadingKey ===
|
||
`ip:${normalizeMatcherValue('ip', comment.ip_address)}`
|
||
}
|
||
onClick={() =>
|
||
void createBlacklist(
|
||
'ip',
|
||
comment.ip_address ?? '',
|
||
`评论#${comment.id} 快速封禁`,
|
||
)
|
||
}
|
||
>
|
||
<Ban className="h-3.5 w-3.5" />
|
||
封 IP
|
||
</Button>
|
||
) : null}
|
||
{comment.email ? (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={
|
||
banLoadingKey ===
|
||
`email:${normalizeMatcherValue('email', comment.email)}`
|
||
}
|
||
onClick={() =>
|
||
void createBlacklist(
|
||
'email',
|
||
comment.email ?? '',
|
||
`评论#${comment.id} 快速封禁`,
|
||
)
|
||
}
|
||
>
|
||
<Ban className="h-3.5 w-3.5" />
|
||
封邮箱
|
||
</Button>
|
||
) : null}
|
||
{comment.user_agent ? (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={
|
||
banLoadingKey ===
|
||
`user_agent:${normalizeMatcherValue(
|
||
'user_agent',
|
||
comment.user_agent,
|
||
)}`
|
||
}
|
||
onClick={() =>
|
||
void createBlacklist(
|
||
'user_agent',
|
||
comment.user_agent ?? '',
|
||
`评论#${comment.id} 快速封禁`,
|
||
)
|
||
}
|
||
>
|
||
<Ban className="h-3.5 w-3.5" />
|
||
封 UA
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={actingId === comment.id || Boolean(comment.approved)}
|
||
onClick={async () => {
|
||
try {
|
||
setActingId(comment.id)
|
||
await adminApi.updateComment(comment.id, { approved: true })
|
||
toast.success('评论已通过。')
|
||
await loadComments(false)
|
||
} catch (error) {
|
||
toast.error(
|
||
error instanceof ApiError ? error.message : '无法通过该评论。',
|
||
)
|
||
} finally {
|
||
setActingId(null)
|
||
}
|
||
}}
|
||
>
|
||
<CheckCheck className="h-4 w-4" />
|
||
通过
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
disabled={actingId === comment.id || !comment.approved}
|
||
onClick={async () => {
|
||
try {
|
||
setActingId(comment.id)
|
||
await adminApi.updateComment(comment.id, { approved: false })
|
||
toast.success('评论已移回待审核。')
|
||
await loadComments(false)
|
||
} catch (error) {
|
||
toast.error(
|
||
error instanceof ApiError ? error.message : '无法更新评论状态。',
|
||
)
|
||
} finally {
|
||
setActingId(null)
|
||
}
|
||
}}
|
||
>
|
||
<XCircle className="h-4 w-4" />
|
||
隐藏
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="danger"
|
||
disabled={actingId === comment.id}
|
||
onClick={async () => {
|
||
if (!window.confirm('确定要永久删除这条评论吗?')) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
setActingId(comment.id)
|
||
await adminApi.deleteComment(comment.id)
|
||
toast.success('评论已删除。')
|
||
await loadComments(false)
|
||
} catch (error) {
|
||
toast.error(
|
||
error instanceof ApiError ? error.message : '无法删除评论。',
|
||
)
|
||
} finally {
|
||
setActingId(null)
|
||
}
|
||
}}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
删除
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
{!filteredComments.length ? (
|
||
<TableRow>
|
||
<TableCell colSpan={4} className="py-12 text-center">
|
||
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||
<MessageSquareText className="h-8 w-8" />
|
||
<p>当前筛选条件下没有匹配的评论。</p>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
) : null}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>人物画像视图</CardTitle>
|
||
<CardDescription>按 IP / 邮箱 / UA 聚合,并查看同源评论轨迹。</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<Select
|
||
value={profileType}
|
||
onChange={(event) => setProfileType(event.target.value as MatcherType)}
|
||
>
|
||
<option value="ip">按 IP 聚合</option>
|
||
<option value="email">按邮箱聚合</option>
|
||
<option value="user_agent">按浏览器 UA 聚合</option>
|
||
</Select>
|
||
|
||
<div className="max-h-[230px] space-y-2 overflow-auto pr-1">
|
||
{personas.map((persona) => {
|
||
const key = `${profileType}:${normalizeMatcherValue(
|
||
profileType,
|
||
persona.matcherValue,
|
||
)}`
|
||
const blacklisted = blacklistedMatcherSet.has(key)
|
||
|
||
return (
|
||
<button
|
||
key={persona.matcherValue}
|
||
type="button"
|
||
className={`w-full rounded-2xl border px-3 py-2 text-left transition ${
|
||
selectedProfileValue === persona.matcherValue
|
||
? 'border-primary/50 bg-primary/10'
|
||
: 'border-border/70 bg-background/40 hover:border-primary/30'
|
||
}`}
|
||
onClick={() => setSelectedProfileValue(persona.matcherValue)}
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<p
|
||
className="line-clamp-2 break-all text-xs font-mono text-foreground"
|
||
title={persona.matcherValue}
|
||
>
|
||
{profileType === 'user_agent'
|
||
? formatBrowserName(persona.matcherValue)
|
||
: persona.matcherValue}
|
||
</p>
|
||
{blacklisted ? <Badge variant="danger">黑名单</Badge> : null}
|
||
</div>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{persona.total} 条 · 待审核 {persona.pending} · 已通过{' '}
|
||
{persona.approved}
|
||
</p>
|
||
</button>
|
||
)
|
||
})}
|
||
{!personas.length ? (
|
||
<p className="rounded-2xl border border-border/70 px-3 py-6 text-center text-sm text-muted-foreground">
|
||
当前筛选条件下暂无可聚合来源。
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
|
||
{selectedPersona ? (
|
||
<div className="rounded-2xl border border-border/70 bg-background/40 p-3 text-xs text-muted-foreground">
|
||
<p>首次出现:{formatDateTime(selectedPersona.firstAt)}</p>
|
||
<p>最近评论:{formatDateTime(selectedPersona.latestAt)}</p>
|
||
<p className="font-mono">最近文章:{selectedPersona.latestPostSlug}</p>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="flex gap-2">
|
||
<Input
|
||
value={manualReason}
|
||
onChange={(event) => setManualReason(event.target.value)}
|
||
placeholder="封禁原因(可选)"
|
||
/>
|
||
<Button
|
||
variant="danger"
|
||
disabled={!selectedProfileValue}
|
||
onClick={() =>
|
||
void createBlacklist(profileType, selectedProfileValue, manualReason)
|
||
}
|
||
>
|
||
<Shield className="h-4 w-4" />
|
||
快速封禁
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="space-y-2 rounded-2xl border border-border/70 bg-background/40 p-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||
AI 风险分析
|
||
</p>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={!selectedProfileValue || analyzingPersona}
|
||
onClick={() => void analyzeSelectedPersona()}
|
||
>
|
||
<Bot className="h-4 w-4" />
|
||
{analyzingPersona ? '分析中...' : 'AI 分析'}
|
||
</Button>
|
||
</div>
|
||
{personaAnalysis ? (
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-muted-foreground">
|
||
匹配评论 {personaAnalysis.total_comments} · 待审核{' '}
|
||
{personaAnalysis.pending_comments} · 涉及文章{' '}
|
||
{personaAnalysis.distinct_posts}
|
||
</p>
|
||
<pre className="max-h-[220px] whitespace-pre-wrap break-words rounded-xl border border-border/70 bg-background px-3 py-2 text-xs leading-6 text-foreground">
|
||
{personaAnalysis.analysis}
|
||
</pre>
|
||
</div>
|
||
) : (
|
||
<p className="text-xs text-muted-foreground">
|
||
选中来源后可让 AI 输出风险等级、行为特征和处置建议。
|
||
</p>
|
||
)}
|
||
|
||
<div className="space-y-2 rounded-xl border border-border/70 bg-background/60 p-2.5">
|
||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||
最近分析记录
|
||
</p>
|
||
{personaLogsLoading ? (
|
||
<Skeleton className="h-16 rounded-xl" />
|
||
) : personaLogs.length ? (
|
||
<div className="max-h-[220px] space-y-2 overflow-auto pr-1">
|
||
{personaLogs.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className="w-full rounded-xl border border-border/70 bg-background px-3 py-2 text-left transition hover:border-primary/40"
|
||
onClick={() =>
|
||
startTransition(() => {
|
||
setPersonaAnalysis(toPersonaAnalysisFromLog(item))
|
||
})
|
||
}
|
||
>
|
||
<p className="text-xs text-muted-foreground">
|
||
#{item.id} · {formatDateTime(item.created_at)} · 评论 {item.total_comments}{' '}
|
||
· 待审 {item.pending_comments}
|
||
</p>
|
||
<p className="line-clamp-2 mt-1 text-xs text-foreground">
|
||
{item.analysis}
|
||
</p>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-xs text-muted-foreground">暂无历史分析记录。</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>同源评论轨迹</CardTitle>
|
||
<CardDescription>
|
||
当前按 {matcherLabel(profileType)} 查看,共 {selectedPersonaComments.length}{' '}
|
||
条。
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="max-h-[260px] space-y-2 overflow-auto pr-1">
|
||
{selectedPersonaComments.map((comment) => (
|
||
<div key={comment.id} className="rounded-2xl border border-border/70 p-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge variant={comment.approved ? 'success' : 'warning'}>
|
||
{comment.approved ? '通过' : '待审'}
|
||
</Badge>
|
||
<span className="text-xs text-muted-foreground">
|
||
{formatDateTime(comment.created_at)}
|
||
</span>
|
||
<span className="text-xs font-mono text-muted-foreground">
|
||
{comment.post_slug ?? 'unknown-post'}
|
||
</span>
|
||
</div>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
{comment.content ?? '暂无内容'}
|
||
</p>
|
||
</div>
|
||
))}
|
||
{!selectedPersonaComments.length ? (
|
||
<p className="rounded-2xl border border-border/70 px-3 py-6 text-center text-sm text-muted-foreground">
|
||
请选择来源查看轨迹。
|
||
</p>
|
||
) : null}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>黑名单管理</CardTitle>
|
||
<CardDescription>支持手动新增、停用与删除规则。</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div className="grid gap-2 md:grid-cols-[150px_1fr]">
|
||
<Select
|
||
value={manualMatcherType}
|
||
onChange={(event) =>
|
||
setManualMatcherType(event.target.value as MatcherType)
|
||
}
|
||
>
|
||
<option value="ip">IP</option>
|
||
<option value="email">邮箱</option>
|
||
<option value="user_agent">浏览器 UA</option>
|
||
</Select>
|
||
<Input
|
||
value={manualMatcherValue}
|
||
onChange={(event) => setManualMatcherValue(event.target.value)}
|
||
placeholder="输入要封禁的值"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
value={manualReason}
|
||
onChange={(event) => setManualReason(event.target.value)}
|
||
placeholder="原因(可选)"
|
||
/>
|
||
<Button
|
||
onClick={async () => {
|
||
await createBlacklist(
|
||
manualMatcherType,
|
||
manualMatcherValue,
|
||
manualReason || '后台手动封禁',
|
||
)
|
||
setManualMatcherValue('')
|
||
}}
|
||
disabled={!manualMatcherValue.trim()}
|
||
>
|
||
<Shield className="h-4 w-4" />
|
||
新增
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="max-h-[220px] space-y-2 overflow-auto pr-1">
|
||
{blacklist.map((item) => (
|
||
<div
|
||
key={item.id}
|
||
className="rounded-2xl border border-border/70 bg-background/40 p-3"
|
||
>
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<p className="text-xs font-mono">
|
||
{item.matcher_type}:{' '}
|
||
{item.matcher_type === 'user_agent'
|
||
? formatBrowserName(item.matcher_value)
|
||
: item.matcher_value}
|
||
</p>
|
||
<Badge variant={item.effective ? 'danger' : 'outline'}>
|
||
{item.effective ? '生效中' : '已停用/过期'}
|
||
</Badge>
|
||
</div>
|
||
{item.reason ? (
|
||
<p className="mt-1 text-xs text-muted-foreground">{item.reason}</p>
|
||
) : null}
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={actingBlacklistId === item.id}
|
||
onClick={async () => {
|
||
try {
|
||
setActingBlacklistId(item.id)
|
||
await adminApi.updateCommentBlacklist(item.id, {
|
||
active: !item.active,
|
||
})
|
||
toast.success(item.active ? '规则已停用。' : '规则已启用。')
|
||
await loadComments(false)
|
||
} catch (error) {
|
||
toast.error(
|
||
error instanceof ApiError
|
||
? error.message
|
||
: '更新黑名单规则失败。',
|
||
)
|
||
} finally {
|
||
setActingBlacklistId(null)
|
||
}
|
||
}}
|
||
>
|
||
{item.active ? (
|
||
<ShieldOff className="h-4 w-4" />
|
||
) : (
|
||
<Shield className="h-4 w-4" />
|
||
)}
|
||
{item.active ? '停用' : '启用'}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="danger"
|
||
disabled={actingBlacklistId === item.id}
|
||
onClick={async () => {
|
||
if (!window.confirm('确定删除这条黑名单规则吗?')) {
|
||
return
|
||
}
|
||
try {
|
||
setActingBlacklistId(item.id)
|
||
await adminApi.deleteCommentBlacklist(item.id)
|
||
toast.success('规则已删除。')
|
||
await loadComments(false)
|
||
} catch (error) {
|
||
toast.error(
|
||
error instanceof ApiError
|
||
? error.message
|
||
: '删除黑名单规则失败。',
|
||
)
|
||
} finally {
|
||
setActingBlacklistId(null)
|
||
}
|
||
}}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
删除
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{!blacklist.length ? (
|
||
<p className="rounded-2xl border border-border/70 px-3 py-6 text-center text-sm text-muted-foreground">
|
||
暂无黑名单规则。
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|