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