From 7383386889c4cb5d5d499ed68ded3115abf6ece3 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Thu, 23 Apr 2026 23:32:42 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E6=98=BE=E7=A4=BA=E4=B8=8E=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 分类管理:隐藏 Slug 列和输入框,自动生成去重 slug - 题库管理/UGC 审核:拆分为独立视图,移除 tab 切换 - 侧栏导航:修复题库管理与 UGC 审核同时高亮问题 - 技能树:适配后端无分页响应,移除分页逻辑 - 知识卡:防御 questionStem 为 undefined 导致崩溃 --- .../category/CategoryFormDialog.tsx | 29 ++++---- src/components/category/columns.tsx | 9 --- src/components/layout/Sidebar.tsx | 66 ++++++++++++++----- src/lib/api/skill-tree-api.ts | 10 +-- src/routes/categories/index.tsx | 1 + src/routes/knowledge-cards/index.tsx | 4 +- src/routes/questions/index.tsx | 65 +++++------------- src/routes/skill-tree/index.tsx | 41 +----------- 8 files changed, 89 insertions(+), 136 deletions(-) diff --git a/src/components/category/CategoryFormDialog.tsx b/src/components/category/CategoryFormDialog.tsx index bb6107d..48c4891 100644 --- a/src/components/category/CategoryFormDialog.tsx +++ b/src/components/category/CategoryFormDialog.tsx @@ -38,6 +38,7 @@ interface CategoryFormDialogProps { open: boolean onOpenChange: (open: boolean) => void category?: Category | null + existingSlugs?: string[] onSubmit: (data: CategoryFormData) => Promise } @@ -50,10 +51,24 @@ function generateSlug(name: string): string { .replace(/^-|-$/g, "") } +function generateUniqueSlug(name: string, existingSlugs: string[], currentSlug?: string): string { + const base = generateSlug(name) + if (!base) return base + + // Exclude the current category's slug when editing + const taken = existingSlugs.filter((s) => s !== currentSlug) + if (!taken.includes(base)) return base + + let i = 1 + while (taken.includes(`${base}-${i}`)) i++ + return `${base}-${i}` +} + export function CategoryFormDialog({ open, onOpenChange, category, + existingSlugs = [], onSubmit, }: CategoryFormDialogProps) { const isEditing = !!category @@ -111,7 +126,7 @@ export function CategoryFormDialog({ {...register("name", { onChange: (e: React.ChangeEvent) => { if (!isEditing) { - setValue("slug", generateSlug(e.target.value), { + setValue("slug", generateUniqueSlug(e.target.value, existingSlugs, category?.slug), { shouldValidate: true, }) } @@ -123,18 +138,6 @@ export function CategoryFormDialog({ )} -
- - - {errors.slug && ( -

{errors.slug.message}

- )} -
-
diff --git a/src/components/category/columns.tsx b/src/components/category/columns.tsx index e796351..9612647 100644 --- a/src/components/category/columns.tsx +++ b/src/components/category/columns.tsx @@ -25,15 +25,6 @@ export function getColumns(ctx: ColumnContext): ColumnDef[] { {row.getValue("name")} ), }, - { - accessorKey: "slug", - header: "Slug", - cell: ({ row }) => ( - - {row.getValue("slug")} - - ), - }, { accessorKey: "status", header: "状态", diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 6b9dd50..36ee082 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,5 +1,5 @@ import { useState } from "react" -import { NavLink, useNavigate } from "react-router" +import { useLocation, useNavigate } from "react-router" import { LayoutDashboard, BookOpen, @@ -18,9 +18,16 @@ import { import { cn } from "@/lib/utils" import { useAuth } from "@/hooks/use-auth" import { ChangePasswordDialog } from "@/components/admin/ChangePasswordDialog" +import type { LucideIcon } from "lucide-react" -const navItems = [ - { to: "/", label: "数据看板", icon: LayoutDashboard, end: true }, +interface NavItem { + to: string + label: string + icon: LucideIcon +} + +const navItems: NavItem[] = [ + { to: "/", label: "数据看板", icon: LayoutDashboard }, { to: "/questions", label: "题库管理", icon: BookOpen }, { to: "/questions?source=ugc", label: "UGC 审核", icon: FileCheck }, { to: "/categories", label: "分类管理", icon: FolderOpen }, @@ -33,9 +40,32 @@ const navItems = [ { to: "/settings", label: "系统设置", icon: Settings }, ] +function isNavItemActive(item: NavItem, pathname: string, search: string): boolean { + const itemUrl = new URL(item.to, "http://dummy") + const itemPath = itemUrl.pathname + const itemSearch = itemUrl.search + + if (itemPath === "/") { + return pathname === "/" && search === "" + } + + if (!pathname.startsWith(itemPath)) { + return false + } + + // Pathname matches — check search params + if (itemSearch) { + return search === itemSearch + } + + // No search params on nav item — only active if current URL also has no source=ugc + return !search.includes("source=ugc") +} + export function Sidebar() { const { logout } = useAuth() const navigate = useNavigate() + const location = useLocation() const [changePasswordOpen, setChangePasswordOpen] = useState(false) function handleLogout() { @@ -51,24 +81,24 @@ export function Sidebar() {
diff --git a/src/lib/api/skill-tree-api.ts b/src/lib/api/skill-tree-api.ts index 81b193e..30f69bf 100644 --- a/src/lib/api/skill-tree-api.ts +++ b/src/lib/api/skill-tree-api.ts @@ -1,24 +1,20 @@ import { apiClient } from "@/lib/api-client" -import type { PaginatedResponse, ApiResponse } from "@/types/api" +import type { 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> { +): 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>() + .json>() } export async function createChapter( diff --git a/src/routes/categories/index.tsx b/src/routes/categories/index.tsx index cafbfdd..207a890 100644 --- a/src/routes/categories/index.tsx +++ b/src/routes/categories/index.tsx @@ -209,6 +209,7 @@ export default function CategoriesPage() { open={formOpen} onOpenChange={setFormOpen} category={editingCategory} + existingSlugs={categories.map((c) => c.slug)} onSubmit={handleFormSubmit} /> diff --git a/src/routes/knowledge-cards/index.tsx b/src/routes/knowledge-cards/index.tsx index 38bf431..c9063ed 100644 --- a/src/routes/knowledge-cards/index.tsx +++ b/src/routes/knowledge-cards/index.tsx @@ -124,10 +124,10 @@ export default function KnowledgeCardsPage() { accessorKey: "questionStem", header: "关联题目", cell: ({ row }) => { - const stem = row.original.questionStem + const stem = row.original.questionStem ?? "" return ( - {stem.length > 60 ? stem.slice(0, 60) + "..." : stem} + {stem.length > 60 ? stem.slice(0, 60) + "..." : stem || "—"} ) }, diff --git a/src/routes/questions/index.tsx b/src/routes/questions/index.tsx index 7e0727f..5dbc176 100644 --- a/src/routes/questions/index.tsx +++ b/src/routes/questions/index.tsx @@ -16,7 +16,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Table, TableBody, @@ -51,16 +50,8 @@ import type { Category } from "@/types/category" const PAGE_SIZE = 20 -type SourceTab = "all" | "system" | "ugc" - -const SOURCE_TABS = [ - { value: "all" as const, label: "全部题目" }, - { value: "system" as const, label: "官方题库" }, - { value: "ugc" as const, label: "用户投稿" }, -] as const - export default function QuestionsPage() { - const [searchParams, setSearchParams] = useSearchParams() + const [searchParams] = useSearchParams() const [questions, setQuestions] = useState([]) const [categories, setCategories] = useState([]) const [loading, setLoading] = useState(true) @@ -73,21 +64,8 @@ export default function QuestionsPage() { const [sortField, setSortField] = useState<"createdAt" | "difficulty" | "updatedAt">("createdAt") const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc") - // 从 URL 查询参数读取 source,如果没有则默认为 "all" - const [sourceTab, setSourceTab] = useState( - () => (searchParams.get("source") as SourceTab) || "all" - ) - - // 当 sourceTab 改变时,更新 URL - useEffect(() => { - const newParams = new URLSearchParams(searchParams) - if (sourceTab === "all") { - newParams.delete("source") - } else { - newParams.set("source", sourceTab) - } - setSearchParams(newParams, { replace: true }) - }, [sourceTab, searchParams, setSearchParams]) + // 根据 URL 决定模式:有 source=ugc 则为 UGC 审核模式,否则为题库管理模式 + const isUgcMode = searchParams.get("source") === "ugc" // 删除对话框 const [deleteOpen, setDeleteOpen] = useState(false) @@ -132,7 +110,7 @@ export default function QuestionsPage() { difficulty: difficultyFilter !== "all" ? (Number(difficultyFilter) as Difficulty) : undefined, - source: sourceTab !== "all" ? sourceTab : undefined, + source: isUgcMode ? "ugc" : "system", sortBy: sortField, sortOrder, }) @@ -143,7 +121,7 @@ export default function QuestionsPage() { } finally { setLoading(false) } - }, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab, sortField, sortOrder]) + }, [page, search, statusFilter, categoryFilter, difficultyFilter, isUgcMode, sortField, sortOrder]) useEffect(() => { loadQuestions() @@ -250,7 +228,7 @@ export default function QuestionsPage() { categories, onDelete: openDelete, onStatusChange: handleStatusChange, - onReview: sourceTab === "ugc" ? openUgcReview : undefined, + onReview: isUgcMode ? openUgcReview : undefined, }) // 选择列 + 数据列 @@ -316,20 +294,9 @@ export default function QuestionsPage() {
{/* 页面头部 */}
-
-

题库管理

- { setSourceTab(val as SourceTab); setPage(1) }}> - - {SOURCE_TABS.map((tab) => ( - - {tab.label} - - ))} - - -
+

{isUgcMode ? "UGC 审核" : "题库管理"}

- {sourceTab === "ugc" && ( + {isUgcMode && ( - {sourceTab === "system" && ( + {!isUgcMode && ( )} - + {!isUgcMode && ( + + )}
diff --git a/src/routes/skill-tree/index.tsx b/src/routes/skill-tree/index.tsx index 7424f6a..c776103 100644 --- a/src/routes/skill-tree/index.tsx +++ b/src/routes/skill-tree/index.tsx @@ -35,14 +35,10 @@ 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") // 对话框状态 @@ -51,8 +47,6 @@ export default function SkillTreePage() { const [deleteOpen, setDeleteOpen] = useState(false) const [deletingChapter, setDeletingChapter] = useState(null) - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) - // 加载分类列表 useEffect(() => { fetchCategories({}).then((res) => setCategories(res.data)) @@ -62,18 +56,15 @@ export default function SkillTreePage() { setLoading(true) try { const res = await fetchChapters({ - page, - limit: PAGE_SIZE, categoryId: categoryFilter !== "all" ? categoryFilter : undefined, }) - setChapters(res.data) - setTotal(res.pagination.total) + setChapters(res.data ?? []) } catch { setChapters([]) } finally { setLoading(false) } - }, [page, categoryFilter]) + }, [categoryFilter]) useEffect(() => { loadChapters() @@ -173,7 +164,6 @@ export default function SkillTreePage() { value={categoryFilter} onValueChange={(val) => { setCategoryFilter(val) - setPage(1) }} > @@ -246,33 +236,6 @@ export default function SkillTreePage() {
- {/* 分页 */} - {totalPages > 1 && ( -
- - 共 {total} 条,第 {page}/{totalPages} 页 - -
- - -
-
- )} - {/* 对话框 */}