From 10dbfa76385d43cb4a085485dcd9b2c190500db0 Mon Sep 17 00:00:00 2001 From: msksbr515 Date: Sun, 24 May 2026 19:41:50 +0800 Subject: [PATCH] {"type":"assistant","message":{"id":"2099424a-0948-4423-a191-3c37d24e52ed","type":"message","role":"assistant","model":"deepseek-v4-flash","content":[{"type":"thinking","thinking":"The user wants me to generate a commit message based on the git diff provided. Let me analyze the changes: 1. **AdminLayout.tsx** - Major changes: - Added imports for `NavLink`, `useNavigate`, `BookOpenIcon`, `ListIcon`, `LogOutIcon`, `Button`, `useAuthStore`, `usePlatform` - Added navigation items array with book management and borrow management - Added admin layout with sidebar navigation, header with username, and logout functionality - Added responsive layout for desktop/mobile 2. **router/index.tsx** - Changes: - Removed placeholder components (`AdminBooksPage`, `AdminBorrowsPage`, `AdminIndexPage`) - Added imports from actual admin feature pages - Updated routes to use real admin layout and pages This is clearly a **feat** (new feature) commit, adding admin layout with navigation and routing to real pages. The scope would be **admin** or **ui**. The key changes are: - Adding admin sidebar navigation with book/borrow management links - Adding logout functionality with username display - Replacing placeholder admin page components with real page implementations - Integrating admin layout with the router Let me write a concise commit message following the rules.","signature":"2099424a-0948-4423-a191-3c37d24e52ed"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":25016,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"service_tier":"standard"},"context_management":null},"parent_tool_use_id":null,"session_id":"bf312622-fae8-446d-8b67-c708b9d3a022","uuid":"50eb9e22-cc58-4b45-8f03-95386c522466"}{"type":"assistant","message":{"id":"2099424a-0948-4423-a191-3c37d24e52ed","type":"message","role":"assistant","model":"deepseek-v4-flash","content":[{"type":"text","text":" feat(admin): add sidebar navigation and real page routes Implement admin layout with sidebar navigation for book and borrow management, including logout functionality and username display. Replace placeholder admin page components with actual page routes."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":25016,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"service_tier":"standard"},"context_management":null},"parent_tool_use_id":null,"session_id":"bf312622-fae8-446d-8b67-c708b9d3a022","uuid":"1ca45e82-b885-4fa0-b886-fdfced0dc2e4"} --- src/components/layout/AdminLayout.tsx | 79 +++++- src/features/admin/AdminBooksPage.tsx | 342 ++++++++++++++++++++++++ src/features/admin/AdminBorrowsPage.tsx | 222 +++++++++++++++ src/features/admin/hooks.ts | 98 +++++++ src/router/index.tsx | 39 ++- 5 files changed, 756 insertions(+), 24 deletions(-) create mode 100644 src/features/admin/AdminBooksPage.tsx create mode 100644 src/features/admin/AdminBorrowsPage.tsx create mode 100644 src/features/admin/hooks.ts diff --git a/src/components/layout/AdminLayout.tsx b/src/components/layout/AdminLayout.tsx index c9add8e..086b191 100644 --- a/src/components/layout/AdminLayout.tsx +++ b/src/components/layout/AdminLayout.tsx @@ -1,9 +1,82 @@ -import { Outlet } from "react-router-dom" +import { NavLink, Outlet, useNavigate } from "react-router-dom" +import { BookOpenIcon, ListIcon, LogOutIcon } from "lucide-react" +import { Button } from "@/components/ui/button" +import { useAuthStore } from "@/store/authStore" +import { usePlatform } from "@/hooks/usePlatform" + +const navItems = [ + { to: "/admin/books", label: "图书管理", icon: BookOpenIcon }, + { to: "/admin/borrows", label: "借阅管理", icon: ListIcon }, +] export default function AdminLayout() { + const username = useAuthStore((s) => s.username) + const clearAuth = useAuthStore((s) => s.clearAuth) + const navigate = useNavigate() + const platform = usePlatform() + const isDesktop = platform === "web" || platform === "desktop" + + function handleLogout() { + clearAuth() + navigate("/books", { replace: true }) + } + + const linkClass = ({ isActive }: { isActive: boolean }) => + `flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${ + isActive ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground" + }` + + if (isDesktop) { + return ( +
+ +
+ +
+
+ ) + } + return ( -
- +
+
+ 管理后台 +
+ {username} + +
+
+
+ +
+
) } diff --git a/src/features/admin/AdminBooksPage.tsx b/src/features/admin/AdminBooksPage.tsx new file mode 100644 index 0000000..055933a --- /dev/null +++ b/src/features/admin/AdminBooksPage.tsx @@ -0,0 +1,342 @@ +import { useState, useEffect, type FormEvent } from "react" +import { Loader2Icon, PlusIcon } from "lucide-react" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Skeleton } from "@/components/ui/skeleton" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { useAdminBooks, useAddBook, useUpdateBook, useUpdateStock, useDeleteBook } from "./hooks" +import { usePlatform } from "@/hooks/usePlatform" +import type { Book } from "@/types/api" + +// ---- Dialog state helpers ---- +interface AddDialog { open: boolean } +interface EditDialog { open: boolean; book: Book } +interface StockDialog { open: boolean; book: Book } +type DeleteDialog = { open: boolean; book: Book } | { open: false; book: null } + +const addInitial: AddDialog = { open: false } + +export default function AdminBooksPage() { + const { data: books, isLoading, error } = useAdminBooks() + const platform = usePlatform() + const isDesktop = platform === "web" || platform === "desktop" + + const [add, setAdd] = useState(addInitial) + const [edit, setEdit] = useState(null) + const [stock, setStock] = useState(null) + const [del, setDel] = useState({ open: false, book: null }) + + if (isLoading) return + if (error) { + return ( +
+ 加载失败:{error instanceof Error ? error.message : "未知错误"} +
+ ) + } + + const list = books ?? [] + + return ( +
+
+

图书管理

+ +
+ + {list.length === 0 ? ( +

暂无书目

+ ) : isDesktop ? ( + + + + ID + 书名 + 作者 + 库存 + 操作 + + + + {list.map((b) => ( + + {b.id} + {b.name} + {b.author} + {b.stock} + +
+ + + +
+
+
+ ))} +
+
+ ) : ( +
+ {list.map((b) => ( + + +
+ #{b.id} + 库存:{b.stock} +
+
{b.name}
+
{b.author}
+
+ + + +
+
+
+ ))} +
+ )} + + setAdd(addInitial)} /> + {edit && setEdit(null)} />} + {stock && setStock(null)} />} + {del.book && ( + setDel({ open: false, book: null })} /> + )} +
+ ) +} + +// ---- Add Book Dialog ---- +function AddBookDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const mutation = useAddBook() + const [name, setName] = useState("") + const [author, setAuthor] = useState("") + const [initStock, setInitStock] = useState("1") + + useEffect(() => { + if (!open) { + setName("") + setAuthor("") + setInitStock("1") + } + }, [open]) + + async function submit(e: FormEvent) { + e.preventDefault() + await mutation.mutateAsync({ name, author, stock: Number(initStock) }) + onClose() + } + + return ( + !o && onClose()}> + + + 添加图书 + +
+
+ + setName(e.target.value)} /> +
+
+ + setAuthor(e.target.value)} /> +
+
+ + setInitStock(e.target.value)} /> +
+ + + +
+
+
+ ) +} + +// ---- Edit Book Dialog ---- +function EditBookDialog({ book, open, onClose }: { book: Book; open: boolean; onClose: () => void }) { + const mutation = useUpdateBook() + const [name, setName] = useState(book.name) + const [author, setAuthor] = useState(book.author) + + useEffect(() => { + if (open) { + setName(book.name) + setAuthor(book.author) + } + }, [open, book.name, book.author]) + + async function submit(e: FormEvent) { + e.preventDefault() + await mutation.mutateAsync({ id: book.id!, data: { name, author } }) + onClose() + } + + return ( + !o && onClose()}> + + + 编辑图书 + +
+
+ + setName(e.target.value)} /> +
+
+ + setAuthor(e.target.value)} /> +
+ + + +
+
+
+ ) +} + +// ---- Update Stock Dialog ---- +function StockDialog_({ book, open, onClose }: { book: Book; open: boolean; onClose: () => void }) { + const mutation = useUpdateStock() + const [value, setValue] = useState(String(book.stock)) + + useEffect(() => { + if (open) setValue(String(book.stock)) + }, [open, book.stock]) + + async function submit(e: FormEvent) { + e.preventDefault() + await mutation.mutateAsync({ id: book.id!, stock: Number(value) }) + onClose() + } + + return ( + !o && onClose()}> + + + 修改库存 — {book.name} + +
+
+ + setValue(e.target.value)} /> +
+ + + +
+
+
+ ) +} + +// ---- Delete Book Dialog ---- +function DeleteBookDialog({ book, open, onClose }: { book: Book; open: boolean; onClose: () => void }) { + const mutation = useDeleteBook() + + return ( + !o && onClose()}> + + + 确认删除 + + 确定要删除《{book.name}》吗?此操作不可撤销。 + + + + 取消 + mutation.mutateAsync(book.id!).then(onClose)} + > + {mutation.isPending && } + 删除 + + + + + ) +} + +// ---- Skeleton ---- +function AdminBooksSkeleton({ isDesktop }: { isDesktop: boolean }) { + if (!isDesktop) { + return ( +
+
+ + +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + +
+ + + +
+
+
+ ))} +
+ ) + } + return ( +
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/src/features/admin/AdminBorrowsPage.tsx b/src/features/admin/AdminBorrowsPage.tsx new file mode 100644 index 0000000..6d28a53 --- /dev/null +++ b/src/features/admin/AdminBorrowsPage.tsx @@ -0,0 +1,222 @@ +import { useState, useEffect } from "react" +import { Loader2Icon } from "lucide-react" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { useAdminBorrows, useSearchBorrows, useAdminReturnBook } from "./hooks" +import { usePlatform } from "@/hooks/usePlatform" +import { formatDateTime } from "@/lib/formatters" +import type { BorrowInfoVo } from "@/types/api" + +export default function AdminBorrowsPage() { + const platform = usePlatform() + const isDesktop = platform === "web" || platform === "desktop" + + const [searchText, setSearchText] = useState("") + const [debounced, setDebounced] = useState("") + + useEffect(() => { + const t = setTimeout(() => setDebounced(searchText), 300) + return () => clearTimeout(t) + }, [searchText]) + + const isSearching = debounced.length > 0 + const allQuery = useAdminBorrows() + const searchQuery = useSearchBorrows(debounced) + const { data, isLoading, error } = isSearching ? searchQuery : allQuery + + const returnMutation = useAdminReturnBook() + const [returnTarget, setReturnTarget] = useState(null) + + if (isLoading) return + if (error) { + return ( +
+ 加载失败:{error instanceof Error ? error.message : "未知错误"} +
+ ) + } + + const list = data ?? [] + + return ( +
+

借阅管理

+
+ setSearchText(e.target.value)} + className="max-w-sm" + /> +
+ + {list.length === 0 ? ( +

+ {isSearching ? "无匹配记录" : "暂无借阅记录"} +

+ ) : isDesktop ? ( + + + + ID + 书名 + 借阅用户 + 借阅时间 + 归还时间 + 状态 + 操作 + + + + {list.map((r) => ( + setReturnTarget(r)} + /> + ))} + +
+ ) : ( +
+ {list.map((r) => ( + + + setReturnTarget(r)} + /> + + + ))} +
+ )} + + !o && setReturnTarget(null)}> + + + 确认归还 + + 确定要代 {returnTarget?.userBorrowVo.username} 归还《{returnTarget?.bookBorrowVo.name}》吗? + + + + 取消 + returnMutation.mutateAsync(returnTarget!.id).then(() => setReturnTarget(null))} + > + {returnMutation.isPending && } + 确认归还 + + + + +
+ ) +} + +function BorrowRow({ + record, + isDesktop, + returning, + onReturn, +}: { + record: BorrowInfoVo + isDesktop: boolean + returning: boolean + onReturn: () => void +}) { + const statusBadge = ( + + {record.status === "BORROWED" ? "借阅中" : "已归还"} + + ) + + const returnButton = record.status === "BORROWED" && ( + + ) + + if (isDesktop) { + return ( + + {record.id} + {record.bookBorrowVo.name} + {record.userBorrowVo.username} + {formatDateTime(record.borrowTime)} + {record.returnTime ? formatDateTime(record.returnTime) : "-"} + {statusBadge} + {returnButton} + + ) + } + + return ( + <> +
+ #{record.id} + {statusBadge} +
+
{record.bookBorrowVo.name}
+
借阅人:{record.userBorrowVo.username}
+
+ 借阅:{formatDateTime(record.borrowTime)} + {record.returnTime && ` | 归还:${formatDateTime(record.returnTime)}`} +
+ {returnButton &&
{returnButton}
} + + ) +} + +function AdminBorrowsSkeleton({ isDesktop }: { isDesktop: boolean }) { + if (!isDesktop) { + return ( +
+ + + {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + ))} +
+ ) + } + return ( +
+ + +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/src/features/admin/hooks.ts b/src/features/admin/hooks.ts new file mode 100644 index 0000000..2f0040a --- /dev/null +++ b/src/features/admin/hooks.ts @@ -0,0 +1,98 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" +import { getAllBooks } from "@/api/books" +import { addBook, deleteBook, updateBook, updateStock } from "@/api/admin/books" +import { getAllBorrows, searchBorrows, returnBook } from "@/api/admin/borrows" + +export function useAdminBooks() { + return useQuery({ + queryKey: ["admin", "books"], + queryFn: getAllBooks, + }) +} + +export function useAddBook() { + const qc = useQueryClient() + return useMutation({ + mutationFn: addBook, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["admin", "books"] }) + toast.success("添加成功") + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "添加失败") + }, + }) +} + +export function useUpdateBook() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, data }: { id: number; data: { name: string; author: string } }) => + updateBook(id, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["admin", "books"] }) + toast.success("修改成功") + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "修改失败") + }, + }) +} + +export function useUpdateStock() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, stock }: { id: number; stock: number }) => updateStock(id, stock), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["admin", "books"] }) + toast.success("库存更新成功") + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "库存更新失败") + }, + }) +} + +export function useDeleteBook() { + const qc = useQueryClient() + return useMutation({ + mutationFn: deleteBook, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["admin", "books"] }) + toast.success("删除成功") + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "删除失败") + }, + }) +} + +export function useAdminBorrows() { + return useQuery({ + queryKey: ["admin", "borrows"], + queryFn: getAllBorrows, + }) +} + +export function useSearchBorrows(query: string) { + return useQuery({ + queryKey: ["admin", "borrows", "search", query], + queryFn: () => searchBorrows(query), + enabled: query.length > 0, + }) +} + +export function useAdminReturnBook() { + const qc = useQueryClient() + return useMutation({ + mutationFn: returnBook, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["admin", "borrows"] }) + toast.success("归还成功") + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "归还失败") + }, + }) +} diff --git a/src/router/index.tsx b/src/router/index.tsx index 0e6076e..ad5b123 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -3,18 +3,10 @@ import { ProtectedRoute, AdminRoute } from "./guards" import LoginPage from "@/features/auth/LoginPage" import BooksPage from "@/features/books/BooksPage" import MyBorrowsPage from "@/features/borrows/MyBorrowsPage" +import AdminBooksPage from "@/features/admin/AdminBooksPage" +import AdminBorrowsPage from "@/features/admin/AdminBorrowsPage" import AppLayout from "@/components/layout/AppLayout" - -// Placeholder components — real pages will replace these -function AdminBooksPage() { - return
Admin / Books
-} -function AdminBorrowsPage() { - return
Admin / Borrows
-} -function AdminIndexPage() { - return -} +import AdminLayout from "@/components/layout/AdminLayout" export const router = createBrowserRouter([ { @@ -47,16 +39,21 @@ export const router = createBrowserRouter([ element: , children: [ { - path: "/admin", - element: , - }, - { - path: "/admin/books", - element: , - }, - { - path: "/admin/borrows", - element: , + element: , + children: [ + { + path: "/admin", + element: , + }, + { + path: "/admin/books", + element: , + }, + { + path: "/admin/borrows", + element: , + }, + ], }, ], },