Compare commits

..

No commits in common. "7383386889c4cb5d5d499ed68ded3115abf6ece3" and "c25e474cb54448b93e635be46ec63684f9ff2723" have entirely different histories.

9 changed files with 137 additions and 90 deletions

View File

@ -38,7 +38,6 @@ 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>
} }
@ -51,24 +50,10 @@ 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
@ -126,7 +111,7 @@ export function CategoryFormDialog({
{...register("name", { {...register("name", {
onChange: (e: React.ChangeEvent<HTMLInputElement>) => { onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
if (!isEditing) { if (!isEditing) {
setValue("slug", generateUniqueSlug(e.target.value, existingSlugs, category?.slug), { setValue("slug", generateSlug(e.target.value), {
shouldValidate: true, shouldValidate: true,
}) })
} }
@ -138,6 +123,18 @@ 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,6 +25,15 @@ 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?.username ?? "admin"}</span> <span>: {admin?.id ?? "admin"}</span>
</div> </div>
</header> </header>
) )

View File

@ -1,5 +1,5 @@
import { useState } from "react" import { useState } from "react"
import { useLocation, useNavigate } from "react-router" import { NavLink, useNavigate } from "react-router"
import { import {
LayoutDashboard, LayoutDashboard,
BookOpen, BookOpen,
@ -18,16 +18,9 @@ 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"
interface NavItem { const navItems = [
to: string { to: "/", label: "数据看板", icon: LayoutDashboard, end: true },
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 },
@ -40,32 +33,9 @@ const navItems: NavItem[] = [
{ 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() {
@ -81,24 +51,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((item) => { {navItems.map(({ to, label, icon: Icon, end }) => (
const isActive = isNavItemActive(item, location.pathname, location.search) <NavLink
return ( key={to}
<button to={to}
key={item.to} end={end}
onClick={() => navigate(item.to)} className={({ isActive }) =>
className={cn( cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors", "flex 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" /> >
{item.label} <Icon className="h-4 w-4" />
</button> {label}
) </NavLink>
})} ))}
</nav> </nav>
<div className="border-t p-3 space-y-1"> <div className="border-t p-3 space-y-1">

View File

@ -1,20 +1,24 @@
import { apiClient } from "@/lib/api-client" import { apiClient } from "@/lib/api-client"
import type { ApiResponse } from "@/types/api" import type { PaginatedResponse, 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<ApiResponse<SkillTreeChapter[]>> { ): Promise<PaginatedResponse<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<ApiResponse<SkillTreeChapter[]>>() .json<PaginatedResponse<SkillTreeChapter>>()
} }
export async function createChapter( export async function createChapter(

View File

@ -209,7 +209,6 @@ 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,6 +16,7 @@ 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,
@ -50,8 +51,16 @@ 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] = useSearchParams() const [searchParams, setSearchParams] = 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)
@ -64,8 +73,21 @@ 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=ugc 则为 UGC 审核模式,否则为题库管理模式 // 从 URL 查询参数读取 source如果没有则默认为 "all"
const isUgcMode = searchParams.get("source") === "ugc" 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])
// 删除对话框 // 删除对话框
const [deleteOpen, setDeleteOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false)
@ -110,7 +132,7 @@ export default function QuestionsPage() {
difficulty: difficultyFilter !== "all" difficulty: difficultyFilter !== "all"
? (Number(difficultyFilter) as Difficulty) ? (Number(difficultyFilter) as Difficulty)
: undefined, : undefined,
source: isUgcMode ? "ugc" : "system", source: sourceTab !== "all" ? sourceTab : undefined,
sortBy: sortField, sortBy: sortField,
sortOrder, sortOrder,
}) })
@ -121,7 +143,7 @@ export default function QuestionsPage() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [page, search, statusFilter, categoryFilter, difficultyFilter, isUgcMode, sortField, sortOrder]) }, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab, sortField, sortOrder])
useEffect(() => { useEffect(() => {
loadQuestions() loadQuestions()
@ -228,7 +250,7 @@ export default function QuestionsPage() {
categories, categories,
onDelete: openDelete, onDelete: openDelete,
onStatusChange: handleStatusChange, onStatusChange: handleStatusChange,
onReview: isUgcMode ? openUgcReview : undefined, onReview: sourceTab === "ugc" ? openUgcReview : undefined,
}) })
// 选择列 + 数据列 // 选择列 + 数据列
@ -294,9 +316,20 @@ 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">
<h1 className="text-2xl font-bold">{isUgcMode ? "UGC 审核" : "题库管理"}</h1> <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>
<div className="flex gap-2"> <div className="flex gap-2">
{isUgcMode && ( {sourceTab === "ugc" && (
<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" />
@ -306,19 +339,17 @@ export default function QuestionsPage() {
<Download className="mr-1 size-4" /> <Download className="mr-1 size-4" />
{exporting ? "导出中..." : "导出 CSV"} {exporting ? "导出中..." : "导出 CSV"}
</Button> </Button>
{!isUgcMode && ( {sourceTab === "system" && (
<Button variant="outline" onClick={() => setImportOpen(true)}> <Button variant="outline" onClick={() => setImportOpen(true)}>
</Button> </Button>
)} )}
{!isUgcMode && ( <Button asChild>
<Button asChild> <Link to="/questions/new">
<Link to="/questions/new"> <Plus className="size-4" />
<Plus className="size-4" />
</Link>
</Link> </Button>
</Button>
)}
</div> </div>
</div> </div>

View File

@ -35,10 +35,14 @@ 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")
// 对话框状态 // 对话框状态
@ -47,6 +51,8 @@ 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))
@ -56,15 +62,18 @@ 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)
} }
}, [categoryFilter]) }, [page, categoryFilter])
useEffect(() => { useEffect(() => {
loadChapters() loadChapters()
@ -164,6 +173,7 @@ 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">
@ -236,6 +246,33 @@ 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}