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