Files
termi-blog/admin/src/pages/comments-page.tsx
limitcool 92a85eef20 feat: Refactor service management scripts to use a unified dev script
- Added package.json to manage development scripts.
- Updated restart-services.ps1 to call the new dev script for starting services.
- Refactored start-admin.ps1, start-backend.ps1, start-frontend.ps1, and start-mcp.ps1 to utilize the dev script for starting respective services.
- Enhanced stop-services.ps1 to improve process termination logic by matching command patterns.
2026-03-29 21:36:13 +08:00

325 lines
13 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 { 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<CommentRecord[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [actingId, setActingId] = useState<number | null>(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 (
<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="按作者、文章 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" />
) : (
<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>
{comment.reply_to_comment_id ? (
<p> #{comment.reply_to_comment_id}</p>
) : (
<p></p>
)}
</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>
)}
</CardContent>
</Card>
</div>
)
}