Compare commits

...

2 Commits

Author SHA1 Message Date
7383386889 fix: 修复多个页面显示与导航问题
Some checks failed
Build & Deploy Admin / deploy (push) Failing after 27s
- 分类管理:隐藏 Slug 列和输入框,自动生成去重 slug
- 题库管理/UGC 审核:拆分为独立视图,移除 tab 切换
- 侧栏导航:修复题库管理与 UGC 审核同时高亮问题
- 技能树:适配后端无分页响应,移除分页逻辑
- 知识卡:防御 questionStem 为 undefined 导致崩溃
2026-04-23 23:32:42 +08:00
dc373c769d fix: Header 显示管理员用户名而非 UUID 2026-04-23 22:27:05 +08:00
9 changed files with 90 additions and 137 deletions

View File

@ -38,6 +38,7 @@ interface CategoryFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
category?: Category | null
existingSlugs?: string[]
onSubmit: (data: CategoryFormData) => Promise<void>
}
@ -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<HTMLInputElement>) => {
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({
)}
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
placeholder="例tang-dynasty"
{...register("slug")}
/>
{errors.slug && (
<p className="text-sm text-destructive">{errors.slug.message}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sortOrder"></Label>

View File

@ -25,15 +25,6 @@ export function getColumns(ctx: ColumnContext): ColumnDef<Category>[] {
<span className="font-medium">{row.getValue("name")}</span>
),
},
{
accessorKey: "slug",
header: "Slug",
cell: ({ row }) => (
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">
{row.getValue("slug")}
</code>
),
},
{
accessorKey: "status",
header: "状态",

View File

@ -7,7 +7,7 @@ export function Header() {
<header className="flex h-14 items-center justify-between border-b bg-background px-6">
<div />
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span>: {admin?.id ?? "admin"}</span>
<span>: {admin?.username ?? "admin"}</span>
</div>
</header>
)

View File

@ -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() {
</div>
<nav className="flex-1 space-y-1 p-3">
{navItems.map(({ to, label, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
{navItems.map((item) => {
const isActive = isNavItemActive(item, location.pathname, location.search)
return (
<button
key={item.to}
onClick={() => navigate(item.to)}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
)
}
)}
>
<Icon className="h-4 w-4" />
{label}
</NavLink>
))}
<item.icon className="h-4 w-4" />
{item.label}
</button>
)
})}
</nav>
<div className="border-t p-3 space-y-1">

View File

@ -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<PaginatedResponse<SkillTreeChapter>> {
): Promise<ApiResponse<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>>()
.json<ApiResponse<SkillTreeChapter[]>>()
}
export async function createChapter(

View File

@ -209,6 +209,7 @@ export default function CategoriesPage() {
open={formOpen}
onOpenChange={setFormOpen}
category={editingCategory}
existingSlugs={categories.map((c) => c.slug)}
onSubmit={handleFormSubmit}
/>

View File

@ -124,10 +124,10 @@ export default function KnowledgeCardsPage() {
accessorKey: "questionStem",
header: "关联题目",
cell: ({ row }) => {
const stem = row.original.questionStem
const stem = row.original.questionStem ?? ""
return (
<span className="line-clamp-2 max-w-xs" title={stem}>
{stem.length > 60 ? stem.slice(0, 60) + "..." : stem}
{stem.length > 60 ? stem.slice(0, 60) + "..." : stem || "—"}
</span>
)
},

View File

@ -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<Question[]>([])
const [categories, setCategories] = useState<Category[]>([])
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<SourceTab>(
() => (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() {
<div className="space-y-6">
{/* 页面头部 */}
<div className="flex items-center justify-between">
<div className="space-y-2">
<h1 className="text-2xl font-bold"></h1>
<Tabs value={sourceTab} onValueChange={(val) => { setSourceTab(val as SourceTab); setPage(1) }}>
<TabsList>
{SOURCE_TABS.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<h1 className="text-2xl font-bold">{isUgcMode ? "UGC 审核" : "题库管理"}</h1>
<div className="flex gap-2">
{sourceTab === "ugc" && (
{isUgcMode && (
<Button variant="outline" size="sm" onClick={() => {/* UGC 批量审核 - 后续实现 */}}>
<FileCheck className="mr-1 size-4" />
@ -339,17 +306,19 @@ export default function QuestionsPage() {
<Download className="mr-1 size-4" />
{exporting ? "导出中..." : "导出 CSV"}
</Button>
{sourceTab === "system" && (
{!isUgcMode && (
<Button variant="outline" onClick={() => setImportOpen(true)}>
</Button>
)}
{!isUgcMode && (
<Button asChild>
<Link to="/questions/new">
<Plus className="size-4" />
</Link>
</Button>
)}
</div>
</div>

View File

@ -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<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")
// 对话框状态
@ -51,8 +47,6 @@ export default function SkillTreePage() {
const [deleteOpen, setDeleteOpen] = useState(false)
const [deletingChapter, setDeletingChapter] = useState<SkillTreeChapter | null>(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)
}}
>
<SelectTrigger className="w-36">
@ -246,33 +236,6 @@ export default function SkillTreePage() {
</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}