diff --git a/src/components/category/CategoryFormDialog.tsx b/src/components/category/CategoryFormDialog.tsx new file mode 100644 index 0000000..bb6107d --- /dev/null +++ b/src/components/category/CategoryFormDialog.tsx @@ -0,0 +1,194 @@ +import { useEffect } from "react" +import { useForm } from "react-hook-form" +import { z } from "zod/v4" +import { zodResolver } from "@hookform/resolvers/zod" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { CATEGORY_STATUS_LABELS } from "@/lib/constants" +import type { Category, CategoryFormData, CategoryStatus } from "@/types/category" + +const categoryFormSchema = z.object({ + name: z.string().min(1, "请输入分类名称").max(100), + slug: z + .string() + .min(1, "请输入 Slug") + .max(100) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Slug 只能包含小写字母、数字和连字符"), + sortOrder: z.number().int().min(0), + status: z.enum(["active", "inactive"]), +}) + +type FormValues = z.infer + +interface CategoryFormDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + category?: Category | null + onSubmit: (data: CategoryFormData) => Promise +} + +function generateSlug(name: string): string { + return name + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^\w\u4e00-\u9fff-]/g, "") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") +} + +export function CategoryFormDialog({ + open, + onOpenChange, + category, + onSubmit, +}: CategoryFormDialogProps) { + const isEditing = !!category + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + reset, + setValue, + watch, + } = useForm({ + resolver: zodResolver(categoryFormSchema), + defaultValues: { + name: "", + slug: "", + sortOrder: 0, + status: "active", + }, + }) + + useEffect(() => { + if (open) { + if (category) { + reset({ + name: category.name, + slug: category.slug, + sortOrder: category.sortOrder, + status: category.status, + }) + } else { + reset({ name: "", slug: "", sortOrder: 0, status: "active" }) + } + } + }, [open, category, reset]) + + async function handleFormSubmit(data: FormValues) { + await onSubmit(data) + onOpenChange(false) + } + + return ( + + + + {isEditing ? "编辑分类" : "新建分类"} + + +
+
+ + ) => { + if (!isEditing) { + setValue("slug", generateSlug(e.target.value), { + shouldValidate: true, + }) + } + }, + })} + /> + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ + + {errors.slug && ( +

{errors.slug.message}

+ )} +
+ +
+
+ + + {errors.sortOrder && ( +

+ {errors.sortOrder.message} +

+ )} +
+ +
+ + +
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/src/components/category/DeleteCategoryDialog.tsx b/src/components/category/DeleteCategoryDialog.tsx new file mode 100644 index 0000000..bb12414 --- /dev/null +++ b/src/components/category/DeleteCategoryDialog.tsx @@ -0,0 +1,50 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import type { Category } from "@/types/category" + +interface DeleteCategoryDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + category: Category | null + onConfirm: () => Promise +} + +export function DeleteCategoryDialog({ + open, + onOpenChange, + category, + onConfirm, +}: DeleteCategoryDialogProps) { + return ( + + + + 确认删除 + + 确定要删除分类「{category?.name}」吗?{category?.questionCount + ? `该分类下有 ${category.questionCount} 道题目。` + : ""} + 此操作不可撤销。 + + + + 取消 + + 删除 + + + + + ) +} diff --git a/src/components/category/columns.tsx b/src/components/category/columns.tsx new file mode 100644 index 0000000..e796351 --- /dev/null +++ b/src/components/category/columns.tsx @@ -0,0 +1,101 @@ +import type { ColumnDef } from "@tanstack/react-table" +import { MoreHorizontal } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { CATEGORY_STATUS_LABELS } from "@/lib/constants" +import type { Category } from "@/types/category" + +interface ColumnContext { + onEdit: (category: Category) => void + onDelete: (category: Category) => void +} + +export function getColumns(ctx: ColumnContext): ColumnDef[] { + return [ + { + accessorKey: "name", + header: "名称", + cell: ({ row }) => ( + {row.getValue("name")} + ), + }, + { + accessorKey: "slug", + header: "Slug", + cell: ({ row }) => ( + + {row.getValue("slug")} + + ), + }, + { + accessorKey: "status", + header: "状态", + cell: ({ row }) => { + const status = row.getValue("status") as Category["status"] + return ( + + {CATEGORY_STATUS_LABELS[status]} + + ) + }, + }, + { + accessorKey: "questionCount", + header: "题目数", + cell: ({ row }) => { + const count = row.getValue("questionCount") as number + return {count} + }, + }, + { + accessorKey: "sortOrder", + header: "排序", + }, + { + accessorKey: "createdAt", + header: "创建时间", + cell: ({ row }) => { + const date = row.getValue("createdAt") as string + return ( + + {new Date(date).toLocaleDateString("zh-CN")} + + ) + }, + }, + { + id: "actions", + header: "", + cell: ({ row }) => { + const category = row.original + return ( + + + + + + ctx.onEdit(category)}> + 编辑 + + ctx.onDelete(category)} + > + 删除 + + + + ) + }, + }, + ] +} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..7e6c036 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,194 @@ +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..6eb2a05 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90", + outline: + "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]:hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..84bdef4 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,158 @@ +"use client" + +import * as React from "react" +import { XIcon } from "lucide-react" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..ae1fcf6 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..067e9d3 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,188 @@ +import * as React from "react" +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" +import { Select as SelectPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 0000000..fcfa0a2 --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,114 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/src/lib/api/category-api.ts b/src/lib/api/category-api.ts new file mode 100644 index 0000000..94acd06 --- /dev/null +++ b/src/lib/api/category-api.ts @@ -0,0 +1,43 @@ +import { apiClient } from "@/lib/api-client" +import type { PaginatedResponse, ApiResponse } from "@/types/api" +import type { Category, CategoryFormData, CategoryStatus } from "@/types/category" + +export interface FetchCategoriesParams { + page?: number + limit?: number + search?: string + status?: CategoryStatus +} + +export async function fetchCategories( + params: FetchCategoriesParams = {} +): Promise> { + const searchParams = new URLSearchParams() + if (params.page) searchParams.set("page", String(params.page)) + if (params.limit) searchParams.set("limit", String(params.limit)) + if (params.search) searchParams.set("search", params.search) + if (params.status) searchParams.set("status", params.status) + + return apiClient + .get("categories", { searchParams }) + .json>() +} + +export async function fetchCategory(id: string): Promise> { + return apiClient.get(`categories/${id}`).json>() +} + +export async function createCategory(data: CategoryFormData): Promise> { + return apiClient.post("categories", { json: data }).json>() +} + +export async function updateCategory( + id: string, + data: Partial +): Promise> { + return apiClient.put(`categories/${id}`, { json: data }).json>() +} + +export async function deleteCategory(id: string): Promise> { + return apiClient.delete(`categories/${id}`).json>() +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 523103d..9a24075 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,3 +1,5 @@ +import type { CategoryStatus } from "@/types/category" + export const QUESTION_STATUSES = { draft: "草稿", review: "审核中", @@ -13,6 +15,11 @@ export const DIFFICULTY_LABELS: Record = { 5: "困难", } +export const CATEGORY_STATUS_LABELS: Record = { + active: "启用", + inactive: "停用", +} + export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000" export const AUTH_STORAGE_KEY = "duoqi_admin_jwt" diff --git a/src/routes/categories/index.tsx b/src/routes/categories/index.tsx index e6a0594..5edb158 100644 --- a/src/routes/categories/index.tsx +++ b/src/routes/categories/index.tsx @@ -1,10 +1,270 @@ +import { useCallback, useEffect, useState } from "react" +import { + useReactTable, + getCoreRowModel, + flexRender, +} from "@tanstack/react-table" +import { Plus, Search } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { CategoryFormDialog } from "@/components/category/CategoryFormDialog" +import { DeleteCategoryDialog } from "@/components/category/DeleteCategoryDialog" +import { getColumns } from "@/components/category/columns" +import { + fetchCategories, + createCategory, + updateCategory, + deleteCategory, +} from "@/lib/api/category-api" +import { CATEGORY_STATUS_LABELS } from "@/lib/constants" +import type { Category, CategoryFormData, CategoryStatus } from "@/types/category" + +const PAGE_SIZE = 20 + export default function CategoriesPage() { + const [categories, setCategories] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [search, setSearch] = useState("") + const [statusFilter, setStatusFilter] = useState("all") + + // 对话框状态 + const [formOpen, setFormOpen] = useState(false) + const [editingCategory, setEditingCategory] = useState(null) + const [deleteOpen, setDeleteOpen] = useState(false) + const [deletingCategory, setDeletingCategory] = useState(null) + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + + const loadCategories = useCallback(async () => { + setLoading(true) + try { + const res = await fetchCategories({ + page, + limit: PAGE_SIZE, + search: search || undefined, + status: statusFilter !== "all" ? statusFilter : undefined, + }) + setCategories(res.data) + setTotal(res.pagination.total) + } catch { + setCategories([]) + } finally { + setLoading(false) + } + }, [page, search, statusFilter]) + + useEffect(() => { + loadCategories() + }, [loadCategories]) + + // 新建 / 编辑提交 + async function handleFormSubmit(data: CategoryFormData) { + if (editingCategory) { + await updateCategory(editingCategory.id, data) + } else { + await createCategory(data) + } + await loadCategories() + } + + // 删除提交 + async function handleDelete() { + if (!deletingCategory) return + await deleteCategory(deletingCategory.id) + setDeleteOpen(false) + setDeletingCategory(null) + await loadCategories() + } + + // 打开编辑对话框 + function openEdit(category: Category) { + setEditingCategory(category) + setFormOpen(true) + } + + // 打开新建对话框 + function openCreate() { + setEditingCategory(null) + setFormOpen(true) + } + + // 打开删除对话框 + function openDelete(category: Category) { + setDeletingCategory(category) + setDeleteOpen(true) + } + + const columns = getColumns({ + onEdit: openEdit, + onDelete: openDelete, + }) + + const table = useReactTable({ + data: categories, + columns, + getCoreRowModel: getCoreRowModel(), + }) + return (
-

分类管理

-
- Phase 1b — 分类 CRUD + {/* 页面头部 */} +
+

分类管理

+
+ + {/* 筛选栏 */} +
+
+ + { + setSearch(e.target.value) + setPage(1) + }} + /> +
+ +
+ + {/* 表格 */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {loading ? ( + + + 加载中... + + + ) : categories.length === 0 ? ( + + + 暂无分类数据 + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + )} + +
+
+ + {/* 分页 */} + {totalPages > 1 && ( +
+ + 共 {total} 条,第 {page}/{totalPages} 页 + +
+ + +
+
+ )} + + {/* 对话框 */} + + +
) } diff --git a/src/types/api.ts b/src/types/api.ts index 77e0584..e6978fd 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -3,13 +3,17 @@ export interface ApiResponse { message?: string } -export interface PaginatedResponse { - data: T[] +export interface PaginationMeta { total: number page: number limit: number } +export interface PaginatedResponse { + data: T[] + pagination: PaginationMeta +} + export interface AdminUser { id: string role: "admin" diff --git a/src/types/category.ts b/src/types/category.ts index 987c6cc..7fc55a1 100644 --- a/src/types/category.ts +++ b/src/types/category.ts @@ -1,10 +1,20 @@ +export type CategoryStatus = "active" | "inactive" + export interface Category { id: string name: string slug: string parentId?: string sortOrder: number - status: "active" | "inactive" + status: CategoryStatus questionCount: number createdAt: string } + +export interface CategoryFormData { + name: string + slug: string + parentId?: string + sortOrder: number + status: CategoryStatus +}