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 NewQuestionPage from "@/routes/questions/new"
|
||||||
import EditQuestionPage from "@/routes/questions/$id.edit"
|
import EditQuestionPage from "@/routes/questions/$id.edit"
|
||||||
import CategoriesPage from "@/routes/categories"
|
import CategoriesPage from "@/routes/categories"
|
||||||
|
import SkillTreePage from "@/routes/skill-tree"
|
||||||
import UsersPage from "@/routes/users"
|
import UsersPage from "@/routes/users"
|
||||||
import SettingsPage from "@/routes/settings"
|
import SettingsPage from "@/routes/settings"
|
||||||
|
|
||||||
@ -28,6 +29,7 @@ const router = createBrowserRouter([
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: "categories", Component: CategoriesPage },
|
{ path: "categories", Component: CategoriesPage },
|
||||||
|
{ path: "skill-tree", Component: SkillTreePage },
|
||||||
{ path: "users", Component: UsersPage },
|
{ path: "users", Component: UsersPage },
|
||||||
{ path: "settings", Component: SettingsPage },
|
{ path: "settings", Component: SettingsPage },
|
||||||
],
|
],
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
TreePine,
|
||||||
Users,
|
Users,
|
||||||
Settings,
|
Settings,
|
||||||
LogOut,
|
LogOut,
|
||||||
@ -14,6 +15,7 @@ const navItems = [
|
|||||||
{ to: "/", label: "数据看板", icon: LayoutDashboard, end: true },
|
{ to: "/", label: "数据看板", icon: LayoutDashboard, end: true },
|
||||||
{ to: "/questions", label: "题库管理", icon: BookOpen },
|
{ to: "/questions", label: "题库管理", icon: BookOpen },
|
||||||
{ to: "/categories", label: "分类管理", icon: FolderOpen },
|
{ to: "/categories", label: "分类管理", icon: FolderOpen },
|
||||||
|
{ to: "/skill-tree", label: "技能树", icon: TreePine },
|
||||||
{ to: "/users", label: "用户管理", icon: Users },
|
{ to: "/users", label: "用户管理", icon: Users },
|
||||||
{ to: "/settings", label: "系统设置", icon: Settings },
|
{ 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