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 open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
category?: Category | null category?: Category | null
existingSlugs?: string[]
onSubmit: (data: CategoryFormData) => Promise<void> onSubmit: (data: CategoryFormData) => Promise<void>
} }
@ -50,10 +51,24 @@ function generateSlug(name: string): string {
.replace(/^-|-$/g, "") .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({ export function CategoryFormDialog({
open, open,
onOpenChange, onOpenChange,
category, category,
existingSlugs = [],
onSubmit, onSubmit,
}: CategoryFormDialogProps) { }: CategoryFormDialogProps) {
const isEditing = !!category const isEditing = !!category
@ -111,7 +126,7 @@ export function CategoryFormDialog({
{...register("name", { {...register("name", {
onChange: (e: React.ChangeEvent<HTMLInputElement>) => { onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
if (!isEditing) { if (!isEditing) {
setValue("slug", generateSlug(e.target.value), { setValue("slug", generateUniqueSlug(e.target.value, existingSlugs, category?.slug), {
shouldValidate: true, shouldValidate: true,
}) })
} }
@ -123,18 +138,6 @@ export function CategoryFormDialog({
)} )}
</div> </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="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="sortOrder"></Label> <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> <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", accessorKey: "status",
header: "状态", 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"> <header className="flex h-14 items-center justify-between border-b bg-background px-6">
<div /> <div />
<div className="flex items-center gap-3 text-sm text-muted-foreground"> <div className="flex items-center gap-3 text-sm text-muted-foreground">
<span>: {admin?.id ?? "admin"}</span> <span>: {admin?.username ?? "admin"}</span>
</div> </div>
</header> </header>
) )

View File

@ -1,5 +1,5 @@
import { useState } from "react" import { useState } from "react"
import { NavLink, useNavigate } from "react-router" import { useLocation, useNavigate } from "react-router"
import { import {
LayoutDashboard, LayoutDashboard,
BookOpen, BookOpen,
@ -18,9 +18,16 @@ import {
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useAuth } from "@/hooks/use-auth" import { useAuth } from "@/hooks/use-auth"
import { ChangePasswordDialog } from "@/components/admin/ChangePasswordDialog" import { ChangePasswordDialog } from "@/components/admin/ChangePasswordDialog"
import type { LucideIcon } from "lucide-react"
const navItems = [ interface NavItem {
{ to: "/", label: "数据看板", icon: LayoutDashboard, end: true }, to: string
label: string
icon: LucideIcon
}
const navItems: NavItem[] = [
{ to: "/", label: "数据看板", icon: LayoutDashboard },
{ to: "/questions", label: "题库管理", icon: BookOpen }, { to: "/questions", label: "题库管理", icon: BookOpen },
{ to: "/questions?source=ugc", label: "UGC 审核", icon: FileCheck }, { to: "/questions?source=ugc", label: "UGC 审核", icon: FileCheck },
{ to: "/categories", label: "分类管理", icon: FolderOpen }, { to: "/categories", label: "分类管理", icon: FolderOpen },
@ -33,9 +40,32 @@ const navItems = [
{ to: "/settings", label: "系统设置", icon: Settings }, { 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() { export function Sidebar() {
const { logout } = useAuth() const { logout } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const [changePasswordOpen, setChangePasswordOpen] = useState(false) const [changePasswordOpen, setChangePasswordOpen] = useState(false)
function handleLogout() { function handleLogout() {
@ -51,24 +81,24 @@ export function Sidebar() {
</div> </div>
<nav className="flex-1 space-y-1 p-3"> <nav className="flex-1 space-y-1 p-3">
{navItems.map(({ to, label, icon: Icon, end }) => ( {navItems.map((item) => {
<NavLink const isActive = isNavItemActive(item, location.pathname, location.search)
key={to} return (
to={to} <button
end={end} key={item.to}
className={({ isActive }) => onClick={() => navigate(item.to)}
cn( className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors", "flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive isActive
? "bg-sidebar-accent text-sidebar-accent-foreground" ? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground", : "text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
) )}
} >
> <item.icon className="h-4 w-4" />
<Icon className="h-4 w-4" /> {item.label}
{label} </button>
</NavLink> )
))} })}
</nav> </nav>
<div className="border-t p-3 space-y-1"> <div className="border-t p-3 space-y-1">

View File

@ -1,24 +1,20 @@
import { apiClient } from "@/lib/api-client" 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" import type { SkillTreeChapter, SkillTreeFormData } from "@/types/skill-tree"
export interface FetchChaptersParams { export interface FetchChaptersParams {
page?: number
limit?: number
categoryId?: string categoryId?: string
} }
export async function fetchChapters( export async function fetchChapters(
params: FetchChaptersParams = {} params: FetchChaptersParams = {}
): Promise<PaginatedResponse<SkillTreeChapter>> { ): Promise<ApiResponse<SkillTreeChapter[]>> {
const searchParams = new URLSearchParams() 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) if (params.categoryId) searchParams.set("categoryId", params.categoryId)
return apiClient return apiClient
.get("skill-tree", { searchParams }) .get("skill-tree", { searchParams })
.json<PaginatedResponse<SkillTreeChapter>>() .json<ApiResponse<SkillTreeChapter[]>>()
} }
export async function createChapter( export async function createChapter(

View File

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

View File

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

View File

@ -16,7 +16,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { import {
Table, Table,
TableBody, TableBody,
@ -51,16 +50,8 @@ import type { Category } from "@/types/category"
const PAGE_SIZE = 20 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() { export default function QuestionsPage() {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams] = useSearchParams()
const [questions, setQuestions] = useState<Question[]>([]) const [questions, setQuestions] = useState<Question[]>([])
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -73,21 +64,8 @@ export default function QuestionsPage() {
const [sortField, setSortField] = useState<"createdAt" | "difficulty" | "updatedAt">("createdAt") const [sortField, setSortField] = useState<"createdAt" | "difficulty" | "updatedAt">("createdAt")
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc") const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc")
// 从 URL 查询参数读取 source如果没有则默认为 "all" // 根据 URL 决定模式:有 source=ugc 则为 UGC 审核模式,否则为题库管理模式
const [sourceTab, setSourceTab] = useState<SourceTab>( const isUgcMode = searchParams.get("source") === "ugc"
() => (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])
// 删除对话框 // 删除对话框
const [deleteOpen, setDeleteOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false)
@ -132,7 +110,7 @@ export default function QuestionsPage() {
difficulty: difficultyFilter !== "all" difficulty: difficultyFilter !== "all"
? (Number(difficultyFilter) as Difficulty) ? (Number(difficultyFilter) as Difficulty)
: undefined, : undefined,
source: sourceTab !== "all" ? sourceTab : undefined, source: isUgcMode ? "ugc" : "system",
sortBy: sortField, sortBy: sortField,
sortOrder, sortOrder,
}) })
@ -143,7 +121,7 @@ export default function QuestionsPage() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab, sortField, sortOrder]) }, [page, search, statusFilter, categoryFilter, difficultyFilter, isUgcMode, sortField, sortOrder])
useEffect(() => { useEffect(() => {
loadQuestions() loadQuestions()
@ -250,7 +228,7 @@ export default function QuestionsPage() {
categories, categories,
onDelete: openDelete, onDelete: openDelete,
onStatusChange: handleStatusChange, 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="space-y-6">
{/* 页面头部 */} {/* 页面头部 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-2"> <h1 className="text-2xl font-bold">{isUgcMode ? "UGC 审核" : "题库管理"}</h1>
<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>
<div className="flex gap-2"> <div className="flex gap-2">
{sourceTab === "ugc" && ( {isUgcMode && (
<Button variant="outline" size="sm" onClick={() => {/* UGC 批量审核 - 后续实现 */}}> <Button variant="outline" size="sm" onClick={() => {/* UGC 批量审核 - 后续实现 */}}>
<FileCheck className="mr-1 size-4" /> <FileCheck className="mr-1 size-4" />
@ -339,17 +306,19 @@ export default function QuestionsPage() {
<Download className="mr-1 size-4" /> <Download className="mr-1 size-4" />
{exporting ? "导出中..." : "导出 CSV"} {exporting ? "导出中..." : "导出 CSV"}
</Button> </Button>
{sourceTab === "system" && ( {!isUgcMode && (
<Button variant="outline" onClick={() => setImportOpen(true)}> <Button variant="outline" onClick={() => setImportOpen(true)}>
</Button> </Button>
)} )}
<Button asChild> {!isUgcMode && (
<Link to="/questions/new"> <Button asChild>
<Plus className="size-4" /> <Link to="/questions/new">
<Plus className="size-4" />
</Link>
</Button> </Link>
</Button>
)}
</div> </div>
</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 { SkillTreeChapter, SkillTreeFormData } from "@/types/skill-tree"
import type { Category } from "@/types/category" import type { Category } from "@/types/category"
const PAGE_SIZE = 20
export default function SkillTreePage() { export default function SkillTreePage() {
const [chapters, setChapters] = useState<SkillTreeChapter[]>([]) const [chapters, setChapters] = useState<SkillTreeChapter[]>([])
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [categoryFilter, setCategoryFilter] = useState<string>("all") const [categoryFilter, setCategoryFilter] = useState<string>("all")
// 对话框状态 // 对话框状态
@ -51,8 +47,6 @@ export default function SkillTreePage() {
const [deleteOpen, setDeleteOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false)
const [deletingChapter, setDeletingChapter] = useState<SkillTreeChapter | null>(null) const [deletingChapter, setDeletingChapter] = useState<SkillTreeChapter | null>(null)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
// 加载分类列表 // 加载分类列表
useEffect(() => { useEffect(() => {
fetchCategories({}).then((res) => setCategories(res.data)) fetchCategories({}).then((res) => setCategories(res.data))
@ -62,18 +56,15 @@ export default function SkillTreePage() {
setLoading(true) setLoading(true)
try { try {
const res = await fetchChapters({ const res = await fetchChapters({
page,
limit: PAGE_SIZE,
categoryId: categoryFilter !== "all" ? categoryFilter : undefined, categoryId: categoryFilter !== "all" ? categoryFilter : undefined,
}) })
setChapters(res.data) setChapters(res.data ?? [])
setTotal(res.pagination.total)
} catch { } catch {
setChapters([]) setChapters([])
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [page, categoryFilter]) }, [categoryFilter])
useEffect(() => { useEffect(() => {
loadChapters() loadChapters()
@ -173,7 +164,6 @@ export default function SkillTreePage() {
value={categoryFilter} value={categoryFilter}
onValueChange={(val) => { onValueChange={(val) => {
setCategoryFilter(val) setCategoryFilter(val)
setPage(1)
}} }}
> >
<SelectTrigger className="w-36"> <SelectTrigger className="w-36">
@ -246,33 +236,6 @@ export default function SkillTreePage() {
</Table> </Table>
</div> </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 <SkillTreeFormDialog
open={formOpen} open={formOpen}