Files
termi-blog/admin/src/pages/comments-page.tsx

1004 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}