From 850a9157e543b46158aac75e586e12173fe088b8 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Tue, 7 Apr 2026 23:52:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E6=A0=91=E7=AB=A0=E8=8A=82=E7=AE=A1=E7=90=86=20CRUD=20+=20?= =?UTF-8?q?=E9=87=8D=E6=8E=92=E5=BA=8F=EF=BC=88Phase=201c=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 2 + src/components/layout/Sidebar.tsx | 2 + .../skill-tree/DeleteChapterDialog.tsx | 47 +++ .../skill-tree/SkillTreeFormDialog.tsx | 209 +++++++++++++ src/components/skill-tree/columns.tsx | 128 ++++++++ src/lib/api/skill-tree-api.ts | 49 +++ src/routes/skill-tree/index.tsx | 293 ++++++++++++++++++ src/types/skill-tree.ts | 18 ++ 8 files changed, 748 insertions(+) create mode 100644 src/components/skill-tree/DeleteChapterDialog.tsx create mode 100644 src/components/skill-tree/SkillTreeFormDialog.tsx create mode 100644 src/components/skill-tree/columns.tsx create mode 100644 src/lib/api/skill-tree-api.ts create mode 100644 src/routes/skill-tree/index.tsx create mode 100644 src/types/skill-tree.ts diff --git a/src/App.tsx b/src/App.tsx index 5d589f3..d7b7811 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 }, ], diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index d13dd22..3b2475c 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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 }, ] diff --git a/src/components/skill-tree/DeleteChapterDialog.tsx b/src/components/skill-tree/DeleteChapterDialog.tsx new file mode 100644 index 0000000..3e4798f --- /dev/null +++ b/src/components/skill-tree/DeleteChapterDialog.tsx @@ -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 +} + +export function DeleteChapterDialog({ + open, + onOpenChange, + chapter, + onConfirm, +}: DeleteChapterDialogProps) { + return ( + + + + 确认删除 + + 确定要删除章节「{chapter?.title}」吗?此操作不可撤销。 + + + + 取消 + + 删除 + + + + + ) +} diff --git a/src/components/skill-tree/SkillTreeFormDialog.tsx b/src/components/skill-tree/SkillTreeFormDialog.tsx new file mode 100644 index 0000000..3ee4f1d --- /dev/null +++ b/src/components/skill-tree/SkillTreeFormDialog.tsx @@ -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 + +interface SkillTreeFormDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + chapter?: SkillTreeChapter | null + categories: Category[] + onSubmit: (data: SkillTreeFormData) => Promise +} + +export function SkillTreeFormDialog({ + open, + onOpenChange, + chapter, + categories, + onSubmit, +}: SkillTreeFormDialogProps) { + const isEditing = !!chapter + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + reset, + setValue, + watch, + } = useForm({ + 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 ( + + + + {isEditing ? "编辑章节" : "新建章节"} + + +
+
+ + + {errors.title && ( +

{errors.title.message}

+ )} +
+ +
+ + + {errors.categoryId && ( +

+ {errors.categoryId.message} +

+ )} +
+ +
+
+ + + {errors.questionsRequired && ( +

+ {errors.questionsRequired.message} +

+ )} +
+ +
+ + + {errors.passThreshold && ( +

+ {errors.passThreshold.message} +

+ )} +
+ +
+ + + {errors.sortOrder && ( +

+ {errors.sortOrder.message} +

+ )} +
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/src/components/skill-tree/columns.tsx b/src/components/skill-tree/columns.tsx new file mode 100644 index 0000000..21e435f --- /dev/null +++ b/src/components/skill-tree/columns.tsx @@ -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[] { + return [ + { + accessorKey: "title", + header: "章节标题", + cell: ({ row }) => ( + {row.getValue("title")} + ), + }, + { + accessorKey: "categoryId", + header: "所属分类", + cell: ({ row }) => { + const catId = row.getValue("categoryId") as string + const cat = ctx.categories.find((c) => c.id === catId) + return ( + {cat?.name ?? catId} + ) + }, + }, + { + accessorKey: "questionsRequired", + header: "每关题数", + cell: ({ row }) => ( + + {row.getValue("questionsRequired")} 题 + + ), + }, + { + accessorKey: "passThreshold", + header: "通过条件", + cell: ({ row }) => { + const threshold = row.getValue("passThreshold") as number + return ( + 答对 {threshold} 题 + ) + }, + }, + { + accessorKey: "sortOrder", + header: "排序", + cell: ({ row }) => { + const chapter = row.original + const order = row.getValue("sortOrder") as number + return ( +
+ {order} + + +
+ ) + }, + }, + { + accessorKey: "createdAt", + header: "创建时间", + cell: ({ row }) => ( + + {new Date(row.getValue("createdAt") as string).toLocaleDateString("zh-CN")} + + ), + }, + { + id: "actions", + header: "", + cell: ({ row }) => { + const chapter = row.original + return ( + + + + + + ctx.onEdit(chapter)}> + 编辑 + + ctx.onDelete(chapter)} + > + 删除 + + + + ) + }, + }, + ] +} diff --git a/src/lib/api/skill-tree-api.ts b/src/lib/api/skill-tree-api.ts new file mode 100644 index 0000000..81b193e --- /dev/null +++ b/src/lib/api/skill-tree-api.ts @@ -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> { + 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>() +} + +export async function createChapter( + data: SkillTreeFormData +): Promise> { + return apiClient.post("skill-tree", { json: data }).json>() +} + +export async function updateChapter( + id: string, + data: Partial +): Promise> { + return apiClient + .put(`skill-tree/${id}`, { json: data }) + .json>() +} + +export async function deleteChapter(id: string): Promise> { + return apiClient.delete(`skill-tree/${id}`).json>() +} + +export async function reorderChapters( + items: { id: string; sortOrder: number }[] +): Promise> { + return apiClient + .patch("skill-tree/reorder", { json: { items } }) + .json>() +} diff --git a/src/routes/skill-tree/index.tsx b/src/routes/skill-tree/index.tsx new file mode 100644 index 0000000..ff15827 --- /dev/null +++ b/src/routes/skill-tree/index.tsx @@ -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([]) + const [categories, setCategories] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [categoryFilter, setCategoryFilter] = useState("all") + + // 对话框状态 + const [formOpen, setFormOpen] = useState(false) + const [editingChapter, setEditingChapter] = useState(null) + const [deleteOpen, setDeleteOpen] = useState(false) + const [deletingChapter, setDeletingChapter] = useState(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 ( +
+ {/* 页面头部 */} +
+

技能树管理

+ +
+ + {/* 筛选栏 */} +
+ +
+ + {/* 表格 */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {loading ? ( + + + 加载中... + + + ) : chapters.length === 0 ? ( + + + 暂无章节数据 + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + )} + +
+
+ + {/* 分页 */} + {totalPages > 1 && ( +
+ + 共 {total} 条,第 {page}/{totalPages} 页 + +
+ + +
+
+ )} + + {/* 对话框 */} + + + +
+ ) +} diff --git a/src/types/skill-tree.ts b/src/types/skill-tree.ts new file mode 100644 index 0000000..e8cd525 --- /dev/null +++ b/src/types/skill-tree.ts @@ -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 +}