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: , + }, + ], }, ], },