feat(comments): add moderation functionality and batch operations
All checks were successful
Admin CI / build-and-push-web (push) Successful in 1m35s
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:
@@ -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 }),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user