- 分类管理:隐藏 Slug 列和输入框,自动生成去重 slug - 题库管理/UGC 审核:拆分为独立视图,移除 tab 切换 - 侧栏导航:修复题库管理与 UGC 审核同时高亮问题 - 技能树:适配后端无分页响应,移除分页逻辑 - 知识卡:防御 questionStem 为 undefined 导致崩溃
This commit is contained in:
parent
dc373c769d
commit
7383386889
@ -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>
|
||||
|
||||
@ -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: "状态",
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -209,6 +209,7 @@ export default function CategoriesPage() {
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
category={editingCategory}
|
||||
existingSlugs={categories.map((c) => c.slug)}
|
||||
onSubmit={handleFormSubmit}
|
||||
/>
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user