feat: 实现技能树章节管理 CRUD + 重排序(Phase 1c)

This commit is contained in:
Wang Zhuoxuan 2026-04-07 23:52:11 +08:00
parent 4bbdc590f4
commit 850a9157e5
8 changed files with 748 additions and 0 deletions

View File

@ -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 },
], ],

View File

@ -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 },
] ]

View 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>
)
}

View 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>
)
}

View 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>
)
},
},
]
}

View 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>>()
}

View 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
View 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
}