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>>()
|
.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
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user