{"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":"<commit>
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"}<commit>
This commit is contained in:
2026-05-24 19:41:50 +08:00
parent 4820370b6a
commit 10dbfa7638
5 changed files with 756 additions and 24 deletions
+76 -3
View File
@@ -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() { 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 (
<div className="flex min-h-screen">
<aside className="fixed top-0 left-0 flex h-full w-56 flex-col border-r bg-background">
<div className="flex h-12 items-center gap-2 border-b px-4">
<span className="font-heading text-base font-semibold"></span>
</div>
<nav className="flex-1 space-y-1 p-3">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={linkClass} end>
<Icon className="size-4" />
{label}
</NavLink>
))}
</nav>
<div className="flex items-center gap-2 border-t px-4 py-3">
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">{username}</span>
<Button variant="ghost" size="icon-sm" onClick={handleLogout} title="退出">
<LogOutIcon className="size-4" />
</Button>
</div>
</aside>
<main className="ml-56 flex-1 px-6 py-6">
<Outlet />
</main>
</div>
)
}
return ( return (
<div className="min-h-screen"> <div className="flex min-h-screen flex-col">
<Outlet /> <header className="sticky top-0 z-40 flex h-12 items-center justify-between border-b bg-background px-4">
<span className="font-heading text-sm font-semibold"></span>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{username}</span>
<Button variant="ghost" size="icon-sm" onClick={handleLogout}>
<LogOutIcon className="size-4" />
</Button>
</div>
</header>
<main className="flex-1 px-4 py-4">
<Outlet />
</main>
<nav className="sticky bottom-0 z-40 flex border-t bg-background">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={linkClass + " flex-1 justify-center py-3"} end>
<Icon className="size-4" />
<span className="text-xs">{label}</span>
</NavLink>
))}
</nav>
</div> </div>
) )
} }
+342
View File
@@ -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<AddDialog>(addInitial)
const [edit, setEdit] = useState<EditDialog | null>(null)
const [stock, setStock] = useState<StockDialog | null>(null)
const [del, setDel] = useState<DeleteDialog>({ open: false, book: null })
if (isLoading) return <AdminBooksSkeleton isDesktop={isDesktop} />
if (error) {
return (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error instanceof Error ? error.message : "未知错误"}
</div>
)
}
const list = books ?? []
return (
<div>
<div className="mb-4 flex items-center justify-between">
<h1 className="text-xl font-semibold"></h1>
<Button size="sm" onClick={() => setAdd({ open: true })}>
<PlusIcon />
</Button>
</div>
{list.length === 0 ? (
<p className="py-16 text-center text-muted-foreground"></p>
) : isDesktop ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((b) => (
<TableRow key={b.id}>
<TableCell>{b.id}</TableCell>
<TableCell className="font-medium">{b.name}</TableCell>
<TableCell>{b.author}</TableCell>
<TableCell>{b.stock}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button size="xs" variant="outline" onClick={() => setEdit({ open: true, book: b })}>
</Button>
<Button size="xs" variant="outline" onClick={() => setStock({ open: true, book: b })}>
</Button>
<Button size="xs" variant="destructive" onClick={() => setDel({ open: true, book: b })}>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="space-y-3">
{list.map((b) => (
<Card key={b.id}>
<CardContent className="space-y-1.5 p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">#{b.id}</span>
<span className="text-sm">{b.stock}</span>
</div>
<div className="font-medium">{b.name}</div>
<div className="text-sm text-muted-foreground">{b.author}</div>
<div className="flex gap-1.5 pt-2">
<Button size="xs" variant="outline" onClick={() => setEdit({ open: true, book: b })}>
</Button>
<Button size="xs" variant="outline" onClick={() => setStock({ open: true, book: b })}>
</Button>
<Button size="xs" variant="destructive" onClick={() => setDel({ open: true, book: b })}>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
<AddBookDialog open={add.open} onClose={() => setAdd(addInitial)} />
{edit && <EditBookDialog book={edit.book} open onClose={() => setEdit(null)} />}
{stock && <StockDialog_ book={stock.book} open onClose={() => setStock(null)} />}
{del.book && (
<DeleteBookDialog book={del.book} open onClose={() => setDel({ open: false, book: null })} />
)}
</div>
)
}
// ---- 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 (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="add-name"></Label>
<Input id="add-name" required value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="add-author"></Label>
<Input id="add-author" required value={author} onChange={(e) => setAuthor(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="add-stock"></Label>
<Input id="add-stock" type="number" min={1} required value={initStock} onChange={(e) => setInitStock(e.target.value)} />
</div>
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && <Loader2Icon className="animate-spin" />}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// ---- 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 (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name"></Label>
<Input id="edit-name" required value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="edit-author"></Label>
<Input id="edit-author" required value={author} onChange={(e) => setAuthor(e.target.value)} />
</div>
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && <Loader2Icon className="animate-spin" />}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// ---- 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 (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle> {book.name}</DialogTitle>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="stock-val"></Label>
<Input id="stock-val" type="number" min={0} required value={value} onChange={(e) => setValue(e.target.value)} />
</div>
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && <Loader2Icon className="animate-spin" />}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// ---- Delete Book Dialog ----
function DeleteBookDialog({ book, open, onClose }: { book: Book; open: boolean; onClose: () => void }) {
const mutation = useDeleteBook()
return (
<AlertDialog open={open} onOpenChange={(o) => !o && onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{book.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={mutation.isPending}
onClick={() => mutation.mutateAsync(book.id!).then(onClose)}
>
{mutation.isPending && <Loader2Icon className="animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
// ---- Skeleton ----
function AdminBooksSkeleton({ isDesktop }: { isDesktop: boolean }) {
if (!isDesktop) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-8 w-24 rounded-lg" />
</div>
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardContent className="space-y-2 p-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-20" />
<div className="flex gap-1.5 pt-1">
<Skeleton className="h-6 w-12 rounded-lg" />
<Skeleton className="h-6 w-16 rounded-lg" />
<Skeleton className="h-6 w-12 rounded-lg" />
</div>
</CardContent>
</Card>
))}
</div>
)
}
return (
<div>
<div className="mb-4 flex items-center justify-between">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-8 w-24 rounded-lg" />
</div>
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</div>
)
}
+222
View File
@@ -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<BorrowInfoVo | null>(null)
if (isLoading) return <AdminBorrowsSkeleton isDesktop={isDesktop} />
if (error) {
return (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error instanceof Error ? error.message : "未知错误"}
</div>
)
}
const list = data ?? []
return (
<div>
<h1 className="mb-4 text-xl font-semibold"></h1>
<div className="mb-4">
<Input
placeholder="搜索借阅记录(书名/用户名)..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="max-w-sm"
/>
</div>
{list.length === 0 ? (
<p className="py-16 text-center text-muted-foreground">
{isSearching ? "无匹配记录" : "暂无借阅记录"}
</p>
) : isDesktop ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[110px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((r) => (
<BorrowRow
key={r.id}
record={r}
isDesktop
returning={returnMutation.isPending}
onReturn={() => setReturnTarget(r)}
/>
))}
</TableBody>
</Table>
) : (
<div className="space-y-3">
{list.map((r) => (
<Card key={r.id}>
<CardContent className="space-y-1.5 p-4">
<BorrowRow
record={r}
isDesktop={false}
returning={returnMutation.isPending}
onReturn={() => setReturnTarget(r)}
/>
</CardContent>
</Card>
))}
</div>
)}
<AlertDialog open={!!returnTarget} onOpenChange={(o) => !o && setReturnTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{returnTarget?.userBorrowVo.username} {returnTarget?.bookBorrowVo.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={returnMutation.isPending}></AlertDialogCancel>
<AlertDialogAction
disabled={returnMutation.isPending}
onClick={() => returnMutation.mutateAsync(returnTarget!.id).then(() => setReturnTarget(null))}
>
{returnMutation.isPending && <Loader2Icon className="animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
function BorrowRow({
record,
isDesktop,
returning,
onReturn,
}: {
record: BorrowInfoVo
isDesktop: boolean
returning: boolean
onReturn: () => void
}) {
const statusBadge = (
<Badge variant={record.status === "BORROWED" ? "default" : "secondary"}>
{record.status === "BORROWED" ? "借阅中" : "已归还"}
</Badge>
)
const returnButton = record.status === "BORROWED" && (
<Button size={isDesktop ? "xs" : "sm"} variant="outline" disabled={returning} onClick={onReturn}>
{returning && <Loader2Icon className="animate-spin" />}
</Button>
)
if (isDesktop) {
return (
<TableRow>
<TableCell>{record.id}</TableCell>
<TableCell className="font-medium">{record.bookBorrowVo.name}</TableCell>
<TableCell>{record.userBorrowVo.username}</TableCell>
<TableCell>{formatDateTime(record.borrowTime)}</TableCell>
<TableCell>{record.returnTime ? formatDateTime(record.returnTime) : "-"}</TableCell>
<TableCell>{statusBadge}</TableCell>
<TableCell>{returnButton}</TableCell>
</TableRow>
)
}
return (
<>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">#{record.id}</span>
{statusBadge}
</div>
<div className="font-medium">{record.bookBorrowVo.name}</div>
<div className="text-sm text-muted-foreground">{record.userBorrowVo.username}</div>
<div className="text-sm text-muted-foreground">
{formatDateTime(record.borrowTime)}
{record.returnTime && ` | 归还:${formatDateTime(record.returnTime)}`}
</div>
{returnButton && <div className="pt-1">{returnButton}</div>}
</>
)
}
function AdminBorrowsSkeleton({ isDesktop }: { isDesktop: boolean }) {
if (!isDesktop) {
return (
<div className="space-y-3">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-8 w-full max-w-sm rounded-lg" />
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardContent className="space-y-2 p-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-5 w-36" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-40" />
</CardContent>
</Card>
))}
</div>
)
}
return (
<div>
<Skeleton className="mb-4 h-7 w-24" />
<Skeleton className="mb-4 h-8 w-64 rounded-lg" />
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</div>
)
}
+98
View File
@@ -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 : "归还失败")
},
})
}
+18 -21
View File
@@ -3,18 +3,10 @@ import { ProtectedRoute, AdminRoute } from "./guards"
import LoginPage from "@/features/auth/LoginPage" import LoginPage from "@/features/auth/LoginPage"
import BooksPage from "@/features/books/BooksPage" import BooksPage from "@/features/books/BooksPage"
import MyBorrowsPage from "@/features/borrows/MyBorrowsPage" import MyBorrowsPage from "@/features/borrows/MyBorrowsPage"
import AdminBooksPage from "@/features/admin/AdminBooksPage"
import AdminBorrowsPage from "@/features/admin/AdminBorrowsPage"
import AppLayout from "@/components/layout/AppLayout" import AppLayout from "@/components/layout/AppLayout"
import AdminLayout from "@/components/layout/AdminLayout"
// Placeholder components — real pages will replace these
function AdminBooksPage() {
return <div>Admin / Books</div>
}
function AdminBorrowsPage() {
return <div>Admin / Borrows</div>
}
function AdminIndexPage() {
return <Navigate to="/admin/books" replace />
}
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
@@ -47,16 +39,21 @@ export const router = createBrowserRouter([
element: <AdminRoute />, element: <AdminRoute />,
children: [ children: [
{ {
path: "/admin", element: <AdminLayout />,
element: <AdminIndexPage />, children: [
}, {
{ path: "/admin",
path: "/admin/books", element: <Navigate to="/admin/books" replace />,
element: <AdminBooksPage />, },
}, {
{ path: "/admin/books",
path: "/admin/borrows", element: <AdminBooksPage />,
element: <AdminBorrowsPage />, },
{
path: "/admin/borrows",
element: <AdminBorrowsPage />,
},
],
}, },
], ],
}, },