feat: 实现技能树章节管理 CRUD + 重排序(Phase 1c)
This commit is contained in:
parent
4bbdc590f4
commit
850a9157e5
@ -6,6 +6,7 @@ import QuestionsPage from "@/routes/questions"
|
||||
import NewQuestionPage from "@/routes/questions/new"
|
||||
import EditQuestionPage from "@/routes/questions/$id.edit"
|
||||
import CategoriesPage from "@/routes/categories"
|
||||
import SkillTreePage from "@/routes/skill-tree"
|
||||
import UsersPage from "@/routes/users"
|
||||
import SettingsPage from "@/routes/settings"
|
||||
|
||||
@ -28,6 +29,7 @@ const router = createBrowserRouter([
|
||||
],
|
||||
},
|
||||
{ path: "categories", Component: CategoriesPage },
|
||||
{ path: "skill-tree", Component: SkillTreePage },
|
||||
{ path: "users", Component: UsersPage },
|
||||
{ path: "settings", Component: SettingsPage },
|
||||
],
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
LayoutDashboard,
|
||||
BookOpen,
|
||||
FolderOpen,
|
||||
TreePine,
|
||||
Users,
|
||||
Settings,
|
||||
LogOut,
|
||||
@ -14,6 +15,7 @@ const navItems = [
|
||||
{ to: "/", label: "数据看板", icon: LayoutDashboard, end: true },
|
||||
{ to: "/questions", label: "题库管理", icon: BookOpen },
|
||||
{ to: "/categories", label: "分类管理", icon: FolderOpen },
|
||||
{ to: "/skill-tree", label: "技能树", icon: TreePine },
|
||||
{ to: "/users", label: "用户管理", icon: Users },
|
||||
{ to: "/settings", label: "系统设置", icon: Settings },
|
||||
]
|
||||
|
||||
47
src/components/skill-tree/DeleteChapterDialog.tsx
Normal file
47
src/components/skill-tree/DeleteChapterDialog.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import type { SkillTreeChapter } from "@/types/skill-tree"
|
||||
|
||||
interface DeleteChapterDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
chapter: SkillTreeChapter | null
|
||||
onConfirm: () => Promise<void>
|
||||
}
|
||||
|
||||
export function DeleteChapterDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
chapter,
|
||||
onConfirm,
|
||||
}: DeleteChapterDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除章节「{chapter?.title}」吗?此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
209
src/components/skill-tree/SkillTreeFormDialog.tsx
Normal file
209
src/components/skill-tree/SkillTreeFormDialog.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
import { useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod/v4"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import type { SkillTreeChapter, SkillTreeFormData } from "@/types/skill-tree"
|
||||
import type { Category } from "@/types/category"
|
||||
|
||||
const chapterFormSchema = z
|
||||
.object({
|
||||
title: z.string().min(1, "请输入章节标题").max(100),
|
||||
categoryId: z.string().min(1, "请选择分类"),
|
||||
sortOrder: z.number().int().min(0),
|
||||
questionsRequired: z.number().int().min(1, "至少 1 题").max(20),
|
||||
passThreshold: z.number().int().min(1, "至少 1 题"),
|
||||
})
|
||||
.refine((data) => data.passThreshold <= data.questionsRequired, {
|
||||
message: "通过条件不能大于题目数",
|
||||
path: ["passThreshold"],
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof chapterFormSchema>
|
||||
|
||||
interface SkillTreeFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
chapter?: SkillTreeChapter | null
|
||||
categories: Category[]
|
||||
onSubmit: (data: SkillTreeFormData) => Promise<void>
|
||||
}
|
||||
|
||||
export function SkillTreeFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
chapter,
|
||||
categories,
|
||||
onSubmit,
|
||||
}: SkillTreeFormDialogProps) {
|
||||
const isEditing = !!chapter
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(chapterFormSchema),
|
||||
defaultValues: {
|
||||
title: "",
|
||||
categoryId: "",
|
||||
sortOrder: 0,
|
||||
questionsRequired: 4,
|
||||
passThreshold: 2,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (chapter) {
|
||||
reset({
|
||||
title: chapter.title,
|
||||
categoryId: chapter.categoryId,
|
||||
sortOrder: chapter.sortOrder,
|
||||
questionsRequired: chapter.questionsRequired,
|
||||
passThreshold: chapter.passThreshold,
|
||||
})
|
||||
} else {
|
||||
reset({
|
||||
title: "",
|
||||
categoryId: "",
|
||||
sortOrder: 0,
|
||||
questionsRequired: 4,
|
||||
passThreshold: 2,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [open, chapter, reset])
|
||||
|
||||
async function handleFormSubmit(data: FormValues) {
|
||||
await onSubmit(data)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "编辑章节" : "新建章节"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">章节标题</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="例:盛唐气象"
|
||||
{...register("title")}
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-sm text-destructive">{errors.title.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>所属分类</Label>
|
||||
<Select
|
||||
value={watch("categoryId")}
|
||||
onValueChange={(val) => setValue("categoryId", val)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.categoryId && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.categoryId.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="questionsRequired">每关题数</Label>
|
||||
<Input
|
||||
id="questionsRequired"
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
{...register("questionsRequired", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.questionsRequired && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.questionsRequired.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="passThreshold">答对数</Label>
|
||||
<Input
|
||||
id="passThreshold"
|
||||
type="number"
|
||||
min={1}
|
||||
{...register("passThreshold", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.passThreshold && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.passThreshold.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sortOrder">排序</Label>
|
||||
<Input
|
||||
id="sortOrder"
|
||||
type="number"
|
||||
min={0}
|
||||
{...register("sortOrder", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.sortOrder && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.sortOrder.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "保存中..." : isEditing ? "更新" : "创建"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
128
src/components/skill-tree/columns.tsx
Normal file
128
src/components/skill-tree/columns.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { ArrowUp, ArrowDown, MoreHorizontal } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import type { SkillTreeChapter } from "@/types/skill-tree"
|
||||
import type { Category } from "@/types/category"
|
||||
|
||||
interface ColumnContext {
|
||||
categories: Category[]
|
||||
onEdit: (chapter: SkillTreeChapter) => void
|
||||
onDelete: (chapter: SkillTreeChapter) => void
|
||||
onMoveUp: (chapter: SkillTreeChapter) => void
|
||||
onMoveDown: (chapter: SkillTreeChapter) => void
|
||||
isFirst: (chapter: SkillTreeChapter) => boolean
|
||||
isLast: (chapter: SkillTreeChapter) => boolean
|
||||
}
|
||||
|
||||
export function getColumns(ctx: ColumnContext): ColumnDef<SkillTreeChapter>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "章节标题",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.getValue("title")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "categoryId",
|
||||
header: "所属分类",
|
||||
cell: ({ row }) => {
|
||||
const catId = row.getValue("categoryId") as string
|
||||
const cat = ctx.categories.find((c) => c.id === catId)
|
||||
return (
|
||||
<span className="text-muted-foreground">{cat?.name ?? catId}</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "questionsRequired",
|
||||
header: "每关题数",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">
|
||||
{row.getValue("questionsRequired")} 题
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "passThreshold",
|
||||
header: "通过条件",
|
||||
cell: ({ row }) => {
|
||||
const threshold = row.getValue("passThreshold") as number
|
||||
return (
|
||||
<span className="text-muted-foreground">答对 {threshold} 题</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "sortOrder",
|
||||
header: "排序",
|
||||
cell: ({ row }) => {
|
||||
const chapter = row.original
|
||||
const order = row.getValue("sortOrder") as number
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground text-xs w-6">{order}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={ctx.isFirst(chapter)}
|
||||
onClick={() => ctx.onMoveUp(chapter)}
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={ctx.isLast(chapter)}
|
||||
onClick={() => ctx.onMoveDown(chapter)}
|
||||
>
|
||||
<ArrowDown className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "创建时间",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{new Date(row.getValue("createdAt") as string).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => {
|
||||
const chapter = row.original
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => ctx.onEdit(chapter)}>
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => ctx.onDelete(chapter)}
|
||||
>
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
49
src/lib/api/skill-tree-api.ts
Normal file
49
src/lib/api/skill-tree-api.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
import type { PaginatedResponse, ApiResponse } from "@/types/api"
|
||||
import type { SkillTreeChapter, SkillTreeFormData } from "@/types/skill-tree"
|
||||
|
||||
export interface FetchChaptersParams {
|
||||
page?: number
|
||||
limit?: number
|
||||
categoryId?: string
|
||||
}
|
||||
|
||||
export async function fetchChapters(
|
||||
params: FetchChaptersParams = {}
|
||||
): Promise<PaginatedResponse<SkillTreeChapter>> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params.page) searchParams.set("page", String(params.page))
|
||||
if (params.limit) searchParams.set("limit", String(params.limit))
|
||||
if (params.categoryId) searchParams.set("categoryId", params.categoryId)
|
||||
|
||||
return apiClient
|
||||
.get("skill-tree", { searchParams })
|
||||
.json<PaginatedResponse<SkillTreeChapter>>()
|
||||
}
|
||||
|
||||
export async function createChapter(
|
||||
data: SkillTreeFormData
|
||||
): Promise<ApiResponse<SkillTreeChapter>> {
|
||||
return apiClient.post("skill-tree", { json: data }).json<ApiResponse<SkillTreeChapter>>()
|
||||
}
|
||||
|
||||
export async function updateChapter(
|
||||
id: string,
|
||||
data: Partial<SkillTreeFormData>
|
||||
): Promise<ApiResponse<SkillTreeChapter>> {
|
||||
return apiClient
|
||||
.put(`skill-tree/${id}`, { json: data })
|
||||
.json<ApiResponse<SkillTreeChapter>>()
|
||||
}
|
||||
|
||||
export async function deleteChapter(id: string): Promise<ApiResponse<{ id: string }>> {
|
||||
return apiClient.delete(`skill-tree/${id}`).json<ApiResponse<{ id: string }>>()
|
||||
}
|
||||
|
||||
export async function reorderChapters(
|
||||
items: { id: string; sortOrder: number }[]
|
||||
): Promise<ApiResponse<void>> {
|
||||
return apiClient
|
||||
.patch("skill-tree/reorder", { json: { items } })
|
||||
.json<ApiResponse<void>>()
|
||||
}
|
||||
293
src/routes/skill-tree/index.tsx
Normal file
293
src/routes/skill-tree/index.tsx
Normal file
@ -0,0 +1,293 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
} from "@tanstack/react-table"
|
||||
import { Plus } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { SkillTreeFormDialog } from "@/components/skill-tree/SkillTreeFormDialog"
|
||||
import { DeleteChapterDialog } from "@/components/skill-tree/DeleteChapterDialog"
|
||||
import { getColumns } from "@/components/skill-tree/columns"
|
||||
import {
|
||||
fetchChapters,
|
||||
createChapter,
|
||||
updateChapter,
|
||||
deleteChapter,
|
||||
reorderChapters,
|
||||
} from "@/lib/api/skill-tree-api"
|
||||
import { fetchCategories } from "@/lib/api/category-api"
|
||||
import type { SkillTreeChapter, SkillTreeFormData } from "@/types/skill-tree"
|
||||
import type { Category } from "@/types/category"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export default function SkillTreePage() {
|
||||
const [chapters, setChapters] = useState<SkillTreeChapter[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("all")
|
||||
|
||||
// 对话框状态
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [editingChapter, setEditingChapter] = useState<SkillTreeChapter | null>(null)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deletingChapter, setDeletingChapter] = useState<SkillTreeChapter | null>(null)
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
// 加载分类列表
|
||||
useEffect(() => {
|
||||
fetchCategories({ limit: 100 }).then((res) => setCategories(res.data))
|
||||
}, [])
|
||||
|
||||
const loadChapters = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetchChapters({
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
categoryId: categoryFilter !== "all" ? categoryFilter : undefined,
|
||||
})
|
||||
setChapters(res.data)
|
||||
setTotal(res.pagination.total)
|
||||
} catch {
|
||||
setChapters([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, categoryFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadChapters()
|
||||
}, [loadChapters])
|
||||
|
||||
// 新建 / 编辑提交
|
||||
async function handleFormSubmit(data: SkillTreeFormData) {
|
||||
if (editingChapter) {
|
||||
await updateChapter(editingChapter.id, data)
|
||||
} else {
|
||||
await createChapter(data)
|
||||
}
|
||||
await loadChapters()
|
||||
}
|
||||
|
||||
// 删除提交
|
||||
async function handleDelete() {
|
||||
if (!deletingChapter) return
|
||||
await deleteChapter(deletingChapter.id)
|
||||
setDeleteOpen(false)
|
||||
setDeletingChapter(null)
|
||||
await loadChapters()
|
||||
}
|
||||
|
||||
// 重排序:上移
|
||||
async function handleMoveUp(chapter: SkillTreeChapter) {
|
||||
const index = chapters.findIndex((c) => c.id === chapter.id)
|
||||
if (index <= 0) return
|
||||
const prev = chapters[index - 1]
|
||||
await reorderChapters([
|
||||
{ id: chapter.id, sortOrder: prev.sortOrder },
|
||||
{ id: prev.id, sortOrder: chapter.sortOrder },
|
||||
])
|
||||
await loadChapters()
|
||||
}
|
||||
|
||||
// 重排序:下移
|
||||
async function handleMoveDown(chapter: SkillTreeChapter) {
|
||||
const index = chapters.findIndex((c) => c.id === chapter.id)
|
||||
if (index < 0 || index >= chapters.length - 1) return
|
||||
const next = chapters[index + 1]
|
||||
await reorderChapters([
|
||||
{ id: chapter.id, sortOrder: next.sortOrder },
|
||||
{ id: next.id, sortOrder: chapter.sortOrder },
|
||||
])
|
||||
await loadChapters()
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
function openEdit(chapter: SkillTreeChapter) {
|
||||
setEditingChapter(chapter)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
// 打开新建对话框
|
||||
function openCreate() {
|
||||
setEditingChapter(null)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
// 打开删除对话框
|
||||
function openDelete(chapter: SkillTreeChapter) {
|
||||
setDeletingChapter(chapter)
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
|
||||
const columns = getColumns({
|
||||
categories,
|
||||
onEdit: openEdit,
|
||||
onDelete: openDelete,
|
||||
onMoveUp: handleMoveUp,
|
||||
onMoveDown: handleMoveDown,
|
||||
isFirst: (ch) => chapters[0]?.id === ch.id,
|
||||
isLast: (ch) => chapters[chapters.length - 1]?.id === ch.id,
|
||||
})
|
||||
|
||||
const table = useReactTable({
|
||||
data: chapters,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">技能树管理</h1>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
新建章节
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
value={categoryFilter}
|
||||
onValueChange={(val) => {
|
||||
setCategoryFilter(val)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder="全部分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部分类</SelectItem>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : chapters.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
暂无章节数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
共 {total} 条,第 {page}/{totalPages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 对话框 */}
|
||||
<SkillTreeFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
chapter={editingChapter}
|
||||
categories={categories}
|
||||
onSubmit={handleFormSubmit}
|
||||
/>
|
||||
|
||||
<DeleteChapterDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
chapter={deletingChapter}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
src/types/skill-tree.ts
Normal file
18
src/types/skill-tree.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export interface SkillTreeChapter {
|
||||
id: string
|
||||
categoryId: string
|
||||
title: string
|
||||
parentId?: string
|
||||
sortOrder: number
|
||||
questionsRequired: number
|
||||
passThreshold: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface SkillTreeFormData {
|
||||
title: string
|
||||
categoryId: string
|
||||
sortOrder: number
|
||||
questionsRequired: number
|
||||
passThreshold: number
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user