feat: 实现题目批量操作 — 发布/下架/删除(Phase 1c)

This commit is contained in:
Wang Zhuoxuan 2026-04-08 00:01:56 +08:00
parent 850a9157e5
commit 7b41df191f
3 changed files with 171 additions and 3 deletions

View File

@ -0,0 +1,30 @@
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -55,6 +55,21 @@ export async function updateQuestionStatus(
.json<ApiResponse<Question>>() .json<ApiResponse<Question>>()
} }
export type BatchAction = "publish" | "archive" | "delete"
export interface BatchResult {
affected: number
}
export async function batchOperateQuestions(
ids: string[],
action: BatchAction
): Promise<ApiResponse<BatchResult>> {
return apiClient
.post("questions/batch", { json: { ids, action } })
.json<ApiResponse<BatchResult>>()
}
export interface ImportResult { export interface ImportResult {
imported: number imported: number
failed: number failed: number

View File

@ -4,8 +4,9 @@ import {
useReactTable, useReactTable,
getCoreRowModel, getCoreRowModel,
flexRender, flexRender,
type ColumnDef,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { Plus, Search } from "lucide-react" import { Plus, Search, Trash2, EyeOff, Send } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import {
@ -36,7 +37,8 @@ import {
import { getColumns } from "@/components/question/columns" import { getColumns } from "@/components/question/columns"
import { StatusTransitionDialog } from "@/components/question/StatusTransitionDialog" import { StatusTransitionDialog } from "@/components/question/StatusTransitionDialog"
import { ImportQuestionsDialog } from "@/components/question/ImportQuestionsDialog" import { ImportQuestionsDialog } from "@/components/question/ImportQuestionsDialog"
import { fetchQuestions, deleteQuestion, updateQuestionStatus } from "@/lib/api/question-api" import { Checkbox } from "@/components/ui/checkbox"
import { fetchQuestions, deleteQuestion, updateQuestionStatus, batchOperateQuestions } from "@/lib/api/question-api"
import { fetchCategories } from "@/lib/api/category-api" import { fetchCategories } from "@/lib/api/category-api"
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants" import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
import type { Question, QuestionStatus, Difficulty } from "@/types/question" import type { Question, QuestionStatus, Difficulty } from "@/types/question"
@ -67,6 +69,11 @@ export default function QuestionsPage() {
// 批量导入对话框 // 批量导入对话框
const [importOpen, setImportOpen] = useState(false) const [importOpen, setImportOpen] = useState(false)
// 批量操作
const [batchConfirmOpen, setBatchConfirmOpen] = useState(false)
const [batchAction, setBatchAction] = useState<"publish" | "archive" | "delete">("delete")
const [batchSubmitting, setBatchSubmitting] = useState(false)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
// 加载分类列表(用于筛选和列显示) // 加载分类列表(用于筛选和列显示)
@ -128,18 +135,63 @@ export default function QuestionsPage() {
setDeleteOpen(true) setDeleteOpen(true)
} }
// 批量操作
async function confirmBatchAction() {
setBatchSubmitting(true)
try {
const ids = table.getFilteredSelectedRowModel().rows.map((r) => r.original.id)
await batchOperateQuestions(ids, batchAction)
setBatchConfirmOpen(false)
table.resetRowSelection()
await loadQuestions()
} finally {
setBatchSubmitting(false)
}
}
function openBatchConfirm(action: "publish" | "archive" | "delete") {
setBatchAction(action)
setBatchConfirmOpen(true)
}
const columns = getColumns({ const columns = getColumns({
categories, categories,
onDelete: openDelete, onDelete: openDelete,
onStatusChange: handleStatusChange, onStatusChange: handleStatusChange,
}) })
// 选择列 + 数据列
const tableColumns: ColumnDef<Question>[] = [
{
id: "select",
header: ({ table: t }) => (
<Checkbox
checked={t.getIsAllPageRowsSelected() || (t.getIsSomePageRowsSelected() && "indeterminate")}
onCheckedChange={(value: boolean) => t.toggleAllPageRowsSelected(!!value)}
aria-label="全选"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value: boolean) => row.toggleSelected(!!value)}
aria-label="选择"
/>
),
},
...columns,
]
const table = useReactTable({ const table = useReactTable({
data: questions, data: questions,
columns, columns: tableColumns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
enableRowSelection: true,
}) })
const selectedCount = table.getFilteredSelectedRowModel().rows.length
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 页面头部 */} {/* 页面头部 */}
@ -234,6 +286,49 @@ export default function QuestionsPage() {
</Select> </Select>
</div> </div>
{/* 批量操作栏 */}
{selectedCount > 0 && (
<div className="flex items-center gap-3 rounded-lg border bg-muted/50 px-4 py-2">
<span className="text-sm text-muted-foreground">
{selectedCount}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => openBatchConfirm("publish")}
>
<Send className="size-3.5" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => openBatchConfirm("archive")}
>
<EyeOff className="size-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => openBatchConfirm("delete")}
>
<Trash2 className="size-3.5" />
</Button>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => table.resetRowSelection()}
>
</Button>
</div>
)}
{/* 表格 */} {/* 表格 */}
<div className="rounded-lg border"> <div className="rounded-lg border">
<Table> <Table>
@ -353,6 +448,34 @@ export default function QuestionsPage() {
onOpenChange={setImportOpen} onOpenChange={setImportOpen}
onSuccess={loadQuestions} onSuccess={loadQuestions}
/> />
{/* 批量操作确认 */}
<AlertDialog open={batchConfirmOpen} onOpenChange={setBatchConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{batchAction === "delete" ? "批量删除" : batchAction === "publish" ? "批量发布" : "批量下架"}
</AlertDialogTitle>
<AlertDialogDescription>
{batchAction === "delete"
? `确定要删除选中的 ${selectedCount} 道题目吗?此操作不可撤销。`
: batchAction === "publish"
? `确定要将选中的 ${selectedCount} 道题目发布吗?`
: `确定要将选中的 ${selectedCount} 道题目下架吗?`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={batchSubmitting}></AlertDialogCancel>
<AlertDialogAction
onClick={confirmBatchAction}
disabled={batchSubmitting}
className={batchAction === "delete" ? "bg-destructive text-white hover:bg-destructive/90" : undefined}
>
{batchSubmitting ? "处理中..." : "确认"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
) )
} }