feat: 实现题目批量操作 — 发布/下架/删除(Phase 1c)
This commit is contained in:
parent
850a9157e5
commit
7b41df191f
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal 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 }
|
||||
@ -55,6 +55,21 @@ export async function updateQuestionStatus(
|
||||
.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 {
|
||||
imported: number
|
||||
failed: number
|
||||
|
||||
@ -4,8 +4,9 @@ import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
} 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 { Input } from "@/components/ui/input"
|
||||
import {
|
||||
@ -36,7 +37,8 @@ import {
|
||||
import { getColumns } from "@/components/question/columns"
|
||||
import { StatusTransitionDialog } from "@/components/question/StatusTransitionDialog"
|
||||
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 { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
|
||||
import type { Question, QuestionStatus, Difficulty } from "@/types/question"
|
||||
@ -67,6 +69,11 @@ export default function QuestionsPage() {
|
||||
// 批量导入对话框
|
||||
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))
|
||||
|
||||
// 加载分类列表(用于筛选和列显示)
|
||||
@ -128,18 +135,63 @@ export default function QuestionsPage() {
|
||||
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({
|
||||
categories,
|
||||
onDelete: openDelete,
|
||||
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({
|
||||
data: questions,
|
||||
columns,
|
||||
columns: tableColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
enableRowSelection: true,
|
||||
})
|
||||
|
||||
const selectedCount = table.getFilteredSelectedRowModel().rows.length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面头部 */}
|
||||
@ -234,6 +286,49 @@ export default function QuestionsPage() {
|
||||
</Select>
|
||||
</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">
|
||||
<Table>
|
||||
@ -353,6 +448,34 @@ export default function QuestionsPage() {
|
||||
onOpenChange={setImportOpen}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user