Compare commits

...

2 Commits

Author SHA1 Message Date
lafay
2d1dc3673a 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.
2026-04-14 02:13:39 +08:00
lafay
f20140f4ea refactor(posts): use PostImage type for images in PostDetail 2026-04-10 13:20:00 +08:00
4 changed files with 155 additions and 9 deletions

View File

@@ -33,23 +33,24 @@ export interface CommentDetail extends Comment {
// 评论管理API
export const commentsApi = {
// 获取评论列表(分页、搜索、筛选)
getComments: (params: CommentQueryParams) =>
apiClient.get<ApiResponse<PaginatedResponse<Comment>>>('/admin/comments', { params }),
// 获取评论详情
getCommentById: (id: string) =>
apiClient.get<ApiResponse<CommentDetail>>(`/admin/comments/${id}`),
// 删除评论
deleteComment: (id: string) =>
apiClient.delete<ApiResponse<void>>(`/admin/comments/${id}`),
// 批量删除评论
batchDeleteComments: (ids: string[]) =>
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'>) =>
apiClient.get<ApiResponse<PaginatedResponse<Comment>>>(`/admin/comments/post/${postId}`, { params }),
}

View File

@@ -1,5 +1,5 @@
import apiClient from './client'
import type { Post, ApiResponse, PaginatedResponse } from '@/types'
import type { Post, PostImage, ApiResponse, PaginatedResponse } from '@/types'
// 帖子查询参数
export interface PostQueryParams {
@@ -16,7 +16,7 @@ export interface PostQueryParams {
export interface PostDetail extends Post {
group_id?: string
group_name?: string
images?: string[]
images?: PostImage[]
tags?: string[]
is_pinned: boolean
is_featured: boolean

View File

@@ -34,6 +34,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { PaginationNavigator } from '@/components/ui/pagination'
@@ -49,9 +50,12 @@ import {
ExternalLink,
ThumbsUp,
Check,
X,
Reply,
ImageIcon,
} from 'lucide-react'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { format } from 'date-fns'
// 状态颜色映射
@@ -94,7 +98,12 @@ export default function Comments() {
const [deleteComment, setDeleteComment] = useState<Comment | null>(null)
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)
// 加载评论列表
@@ -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 () => {
if (selectedIds.length === 0) return
setBatchLoading(true)
@@ -302,6 +352,26 @@ export default function Comments() {
</span>
</div>
<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
variant="destructive"
size="sm"
@@ -467,6 +537,20 @@ export default function Comments() {
<Eye className="mr-2 h-4 w-4 text-blue-500" />
</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
onClick={() => openDeleteDialog(comment)}
className="text-red-600 cursor-pointer focus:text-red-600"
@@ -622,6 +706,21 @@ export default function Comments() {
</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-2 text-slate-600">
@@ -647,6 +746,49 @@ export default function Comments() {
</DialogContent>
</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}>
<DialogContent>

View File

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