{"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:
@@ -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 (
|
||||
<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 (
|
||||
<div className="min-h-screen">
|
||||
<Outlet />
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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 <div>Admin / Books</div>
|
||||
}
|
||||
function AdminBorrowsPage() {
|
||||
return <div>Admin / Borrows</div>
|
||||
}
|
||||
function AdminIndexPage() {
|
||||
return <Navigate to="/admin/books" replace />
|
||||
}
|
||||
import AdminLayout from "@/components/layout/AdminLayout"
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -47,16 +39,21 @@ export const router = createBrowserRouter([
|
||||
element: <AdminRoute />,
|
||||
children: [
|
||||
{
|
||||
path: "/admin",
|
||||
element: <AdminIndexPage />,
|
||||
},
|
||||
{
|
||||
path: "/admin/books",
|
||||
element: <AdminBooksPage />,
|
||||
},
|
||||
{
|
||||
path: "/admin/borrows",
|
||||
element: <AdminBorrowsPage />,
|
||||
element: <AdminLayout />,
|
||||
children: [
|
||||
{
|
||||
path: "/admin",
|
||||
element: <Navigate to="/admin/books" replace />,
|
||||
},
|
||||
{
|
||||
path: "/admin/books",
|
||||
element: <AdminBooksPage />,
|
||||
},
|
||||
{
|
||||
path: "/admin/borrows",
|
||||
element: <AdminBorrowsPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user