feat: 对接题目批量发布/归档/删除接口

替换原单一 batch 端点为 batch-publish、batch-archive、batch-delete 三个独立端点,
BatchResult 类型对齐 API 规范(total/succeeded/failed),新增 BatchResultDialog
展示批量操作结果及失败项详情。
This commit is contained in:
Wang Zhuoxuan 2026-04-11 22:36:15 +08:00
parent d1af1dbe11
commit 4cb26daa02
3 changed files with 142 additions and 14 deletions

View File

@ -0,0 +1,86 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { CheckCircle2, XCircle } from "lucide-react"
import type { BatchResult } from "@/lib/api/question-api"
interface BatchResultDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
result: BatchResult | null
action: "publish" | "archive" | "delete"
}
const ACTION_LABELS = {
publish: "发布",
archive: "归档",
delete: "删除",
} as const
export function BatchResultDialog({
open,
onOpenChange,
result,
action,
}: BatchResultDialogProps) {
if (!result) return null
const hasFailures = result.failed.length > 0
const allSucceeded = result.succeeded === result.total
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{allSucceeded ? (
<CheckCircle2 className="size-5 text-green-600" />
) : (
<XCircle className="size-5 text-yellow-600" />
)}
{ACTION_LABELS[action]}
</DialogTitle>
<DialogDescription>
{result.total} {result.succeeded}
{hasFailures ? `,失败 ${result.failed.length}` : ""}
</DialogDescription>
</DialogHeader>
{hasFailures && (
<div className="max-h-60 overflow-y-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium"> ID</th>
<th className="px-3 py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{result.failed.map((item) => (
<tr key={item.id} className="border-t">
<td className="px-3 py-1.5 font-mono text-xs">
{item.id.slice(0, 8)}...
</td>
<td className="px-3 py-1.5 text-muted-foreground">
{item.reason}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<DialogFooter>
<Button onClick={() => onOpenChange(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -61,18 +61,38 @@ export async function updateQuestionStatus(
.json<ApiResponse<Question>>()
}
export type BatchAction = "publish" | "archive" | "delete"
export interface BatchResult {
affected: number
export interface BatchFailureItem {
id: string
reason: string
}
export async function batchOperateQuestions(
ids: string[],
action: BatchAction
export interface BatchResult {
total: number
succeeded: number
failed: BatchFailureItem[]
}
export async function batchPublishQuestions(
ids: string[]
): Promise<ApiResponse<BatchResult>> {
return apiClient
.post("questions/batch", { json: { ids, action } })
.post("questions/batch-publish", { json: { ids } })
.json<ApiResponse<BatchResult>>()
}
export async function batchArchiveQuestions(
ids: string[]
): Promise<ApiResponse<BatchResult>> {
return apiClient
.post("questions/batch-archive", { json: { ids } })
.json<ApiResponse<BatchResult>>()
}
export async function batchDeleteQuestions(
ids: string[]
): Promise<ApiResponse<BatchResult>> {
return apiClient
.post("questions/batch-delete", { json: { ids } })
.json<ApiResponse<BatchResult>>()
}

View File

@ -37,10 +37,12 @@ import {
} from "@/components/ui/alert-dialog"
import { getColumns } from "@/components/question/columns"
import { StatusTransitionDialog } from "@/components/question/StatusTransitionDialog"
import { BatchResultDialog } from "@/components/question/BatchResultDialog"
import { ImportQuestionsDialog } from "@/components/question/ImportQuestionsDialog"
import { UgcReviewDialog } from "@/components/question/UgcReviewDialog"
import { Checkbox } from "@/components/ui/checkbox"
import { fetchQuestions, deleteQuestion, updateQuestionStatus, batchOperateQuestions } from "@/lib/api/question-api"
import { fetchQuestions, deleteQuestion, updateQuestionStatus, batchPublishQuestions, batchArchiveQuestions, batchDeleteQuestions } from "@/lib/api/question-api"
import type { BatchResult } from "@/lib/api/question-api"
import { fetchCategories } from "@/lib/api/category-api"
import { exportToCsv } from "@/lib/csv-export"
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
@ -107,6 +109,8 @@ export default function QuestionsPage() {
const [batchConfirmOpen, setBatchConfirmOpen] = useState(false)
const [batchAction, setBatchAction] = useState<"publish" | "archive" | "delete">("delete")
const [batchSubmitting, setBatchSubmitting] = useState(false)
const [batchResult, setBatchResult] = useState<BatchResult | null>(null)
const [batchResultOpen, setBatchResultOpen] = useState(false)
const [exporting, setExporting] = useState(false)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
@ -180,14 +184,24 @@ export default function QuestionsPage() {
// 批量操作
const BATCH_OPERATIONS = {
publish: batchPublishQuestions,
archive: batchArchiveQuestions,
delete: batchDeleteQuestions,
} as const
async function confirmBatchAction() {
setBatchSubmitting(true)
try {
const ids = table.getFilteredSelectedRowModel().rows.map((r) => r.original.id)
await batchOperateQuestions(ids, batchAction)
const res = await BATCH_OPERATIONS[batchAction](ids)
setBatchConfirmOpen(false)
table.resetRowSelection()
await loadQuestions()
if (res.data) {
setBatchResult(res.data)
setBatchResultOpen(true)
}
} finally {
setBatchSubmitting(false)
}
@ -620,14 +634,14 @@ export default function QuestionsPage() {
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{batchAction === "delete" ? "批量删除" : batchAction === "publish" ? "批量发布" : "批量下架"}
{batchAction === "delete" ? "批量删除" : batchAction === "publish" ? "批量发布" : "批量归档"}
</AlertDialogTitle>
<AlertDialogDescription>
{batchAction === "delete"
? `确定要删除选中的 ${selectedCount} 道题目吗?此操作不可撤销`
? `确定要删除选中的 ${selectedCount} 道题目吗?仅 draft/reviewing/published 状态的题目可被归档`
: batchAction === "publish"
? `确定要将选中的 ${selectedCount} 道题目发布吗?`
: `确定要将选中的 ${selectedCount} 道题目下架吗?`}
? `确定要将选中的 ${selectedCount} 道题目发布吗?仅 reviewing 状态的题目可被发布。`
: `确定要将选中的 ${selectedCount} 道题目归档吗?`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@ -642,6 +656,14 @@ export default function QuestionsPage() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 批量操作结果 */}
<BatchResultDialog
open={batchResultOpen}
onOpenChange={setBatchResultOpen}
result={batchResult}
action={batchAction}
/>
</div>
)
}