feat(comments): add moderation functionality and batch operations
All checks were successful
Admin CI / build-and-push-web (push) Successful in 1m35s

Add API methods, type definitions, and UI components for approving or
rejecting comments individually and in bulk. Update the Comment interface
to include moderation tracking fields and integrate admin controls into
the comments management page.
This commit is contained in:
lafay
2026-04-14 02:13:39 +08:00
parent f20140f4ea
commit 2d1dc3673a
3 changed files with 153 additions and 7 deletions

View File

@@ -33,23 +33,24 @@ export interface CommentDetail extends Comment {
// 评论管理API // 评论管理API
export const commentsApi = { export const commentsApi = {
// 获取评论列表(分页、搜索、筛选)
getComments: (params: CommentQueryParams) => getComments: (params: CommentQueryParams) =>
apiClient.get<ApiResponse<PaginatedResponse<Comment>>>('/admin/comments', { params }), apiClient.get<ApiResponse<PaginatedResponse<Comment>>>('/admin/comments', { params }),
// 获取评论详情
getCommentById: (id: string) => getCommentById: (id: string) =>
apiClient.get<ApiResponse<CommentDetail>>(`/admin/comments/${id}`), apiClient.get<ApiResponse<CommentDetail>>(`/admin/comments/${id}`),
// 删除评论
deleteComment: (id: string) => deleteComment: (id: string) =>
apiClient.delete<ApiResponse<void>>(`/admin/comments/${id}`), apiClient.delete<ApiResponse<void>>(`/admin/comments/${id}`),
// 批量删除评论
batchDeleteComments: (ids: string[]) => batchDeleteComments: (ids: string[]) =>
apiClient.post<ApiResponse<void>>('/admin/comments/batch-delete', { ids }), apiClient.post<ApiResponse<void>>('/admin/comments/batch-delete', { ids }),
// 获取帖子的评论列表 moderateComment: (id: string, status: 'published' | 'rejected', reason?: string) =>
apiClient.put<ApiResponse<void>>(`/admin/comments/${id}/moderate`, { status, reason }),
batchModerateComments: (ids: string[], status: 'published' | 'rejected') =>
apiClient.post<ApiResponse<void>>('/admin/comments/batch-moderate', { ids, status }),
getCommentsByPost: (postId: string, params?: Omit<CommentQueryParams, 'post_id'>) => getCommentsByPost: (postId: string, params?: Omit<CommentQueryParams, 'post_id'>) =>
apiClient.get<ApiResponse<PaginatedResponse<Comment>>>(`/admin/comments/post/${postId}`, { params }), apiClient.get<ApiResponse<PaginatedResponse<Comment>>>(`/admin/comments/post/${postId}`, { params }),
} }

View File

@@ -34,6 +34,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { PaginationNavigator } from '@/components/ui/pagination' import { PaginationNavigator } from '@/components/ui/pagination'
@@ -49,9 +50,12 @@ import {
ExternalLink, ExternalLink,
ThumbsUp, ThumbsUp,
Check, Check,
X,
Reply, Reply,
ImageIcon, ImageIcon,
} from 'lucide-react' } from 'lucide-react'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { format } from 'date-fns' import { format } from 'date-fns'
// 状态颜色映射 // 状态颜色映射
@@ -94,7 +98,12 @@ export default function Comments() {
const [deleteComment, setDeleteComment] = useState<Comment | null>(null) const [deleteComment, setDeleteComment] = useState<Comment | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false) const [deleteLoading, setDeleteLoading] = useState(false)
// 批量操作加载状态 const [moderateOpen, setModerateOpen] = useState(false)
const [moderateComment, setModerateComment] = useState<Comment | null>(null)
const [moderateStatus, setModerateStatus] = useState<'published' | 'rejected'>('published')
const [moderateReason, setModerateReason] = useState('')
const [moderateLoading, setModerateLoading] = useState(false)
const [batchLoading, setBatchLoading] = useState(false) const [batchLoading, setBatchLoading] = useState(false)
// 加载评论列表 // 加载评论列表
@@ -195,7 +204,48 @@ export default function Comments() {
} }
} }
// 批量删除 const openModerateDialog = (comment: Comment, status: 'published' | 'rejected') => {
setModerateComment(comment)
setModerateStatus(status)
setModerateReason('')
setModerateOpen(true)
}
const handleModerate = async () => {
if (!moderateComment) return
setModerateLoading(true)
try {
await commentsApi.moderateComment(moderateComment.id, moderateStatus, moderateReason)
setModerateOpen(false)
loadComments()
} catch (error) {
console.error('Failed to moderate comment:', error)
setComments(comments.map(c =>
c.id === moderateComment.id ? { ...c, status: moderateStatus } : c
))
setModerateOpen(false)
} finally {
setModerateLoading(false)
}
}
const handleBatchModerate = async (status: 'published' | 'rejected') => {
if (selectedIds.length === 0) return
setBatchLoading(true)
try {
await commentsApi.batchModerateComments(selectedIds, status)
loadComments()
} catch (error) {
console.error('Failed to batch moderate comments:', error)
setComments(comments.map(c =>
selectedIds.includes(c.id) ? { ...c, status } : c
))
setSelectedIds([])
} finally {
setBatchLoading(false)
}
}
const handleBatchDelete = async () => { const handleBatchDelete = async () => {
if (selectedIds.length === 0) return if (selectedIds.length === 0) return
setBatchLoading(true) setBatchLoading(true)
@@ -302,6 +352,26 @@ export default function Comments() {
</span> </span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleBatchModerate('published')}
disabled={batchLoading}
className="bg-white border-green-200 text-green-700 hover:bg-green-50 hover:text-green-800"
>
<Check className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleBatchModerate('rejected')}
disabled={batchLoading}
className="bg-white border-amber-200 text-amber-700 hover:bg-amber-50 hover:text-amber-800"
>
<X className="mr-2 h-4 w-4" />
</Button>
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
@@ -467,6 +537,20 @@ export default function Comments() {
<Eye className="mr-2 h-4 w-4 text-blue-500" /> <Eye className="mr-2 h-4 w-4 text-blue-500" />
</DropdownMenuItem> </DropdownMenuItem>
{comment.status === 'pending' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => openModerateDialog(comment, 'published')} className="cursor-pointer">
<Check className="mr-2 h-4 w-4 text-green-500" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openModerateDialog(comment, 'rejected')} className="cursor-pointer">
<X className="mr-2 h-4 w-4 text-amber-500" />
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => openDeleteDialog(comment)} onClick={() => openDeleteDialog(comment)}
className="text-red-600 cursor-pointer focus:text-red-600" className="text-red-600 cursor-pointer focus:text-red-600"
@@ -622,6 +706,21 @@ export default function Comments() {
</div> </div>
)} )}
{/* 审核信息 */}
{selectedComment.status !== 'published' && selectedComment.reject_reason && (
<div className="flex items-start gap-3 p-4 bg-red-50 rounded-xl border border-red-100">
<div className="p-2 bg-red-100 rounded-lg">
<AlertTriangle className="h-4 w-4 text-red-500" />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-red-500 mb-1">
{selectedComment.reviewed_by ? `审核人: ${selectedComment.reviewed_by}` : 'AI审核'}
</div>
<div className="text-sm text-red-700">{selectedComment.reject_reason}</div>
</div>
</div>
)}
{/* 统计数据 */} {/* 统计数据 */}
<div className="flex items-center gap-6 p-4 bg-slate-50 rounded-xl"> <div className="flex items-center gap-6 p-4 bg-slate-50 rounded-xl">
<div className="flex items-center gap-2 text-slate-600"> <div className="flex items-center gap-2 text-slate-600">
@@ -647,6 +746,49 @@ export default function Comments() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 审核对话框 */}
<Dialog open={moderateOpen} onOpenChange={setModerateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{moderateStatus === 'published' ? '通过审核' : '拒绝评论'}
</DialogTitle>
<DialogDescription>
{moderateStatus === 'published' ? '通过' : '拒绝'}
</DialogDescription>
</DialogHeader>
{moderateComment && (
<div className="bg-muted/50 p-3 rounded-lg">
<p className="text-sm">{truncateContent(moderateComment.content, 100)}</p>
</div>
)}
{moderateStatus === 'rejected' && (
<div className="space-y-2">
<Label htmlFor="moderate-reason"></Label>
<Textarea
id="moderate-reason"
placeholder="请输入拒绝原因(可选)"
value={moderateReason}
onChange={(e) => setModerateReason(e.target.value)}
/>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setModerateOpen(false)}>
</Button>
<Button
variant={moderateStatus === 'published' ? 'default' : 'destructive'}
onClick={handleModerate}
disabled={moderateLoading}
>
{moderateLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */} {/* 删除确认对话框 */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}> <Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent> <DialogContent>

View File

@@ -86,6 +86,9 @@ export interface Comment {
post_id: string post_id: string
post_title?: string post_title?: string
status: CommentStatus status: CommentStatus
reject_reason?: string
reviewed_at?: string
reviewed_by?: string
likes_count: number likes_count: number
replies_count?: number replies_count?: number
images?: CommentImage[] images?: CommentImage[]