feat(admin): add book borrowing dialog and root redirect

- Add admin book borrowing dialog with user selection
- Add useAdminBorrowBook mutation with success/error toasts
- Fix sidebar z-index layering on admin layout
- Add RootRedirect component that routes based on admin role
This commit is contained in:
2026-05-24 20:40:07 +08:00
parent f28dc98f5e
commit 78ebd0b62e
4 changed files with 73 additions and 4 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ export default function AdminLayout() {
if (!isMobile) {
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">
<aside className="fixed top-0 left-0 z-50 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>
+49 -1
View File
@@ -17,7 +17,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useAdminBooks, useAddBook, useUpdateBook, useUpdateStock, useDeleteBook } from "./hooks"
import { useAdminBooks, useAddBook, useUpdateBook, useUpdateStock, useDeleteBook, useAdminBorrowBook } from "./hooks"
import { usePlatform } from "@/hooks/usePlatform"
import { useIsMobile } from "@/hooks/useIsMobile"
import type { Book } from "@/types/api"
@@ -27,6 +27,7 @@ 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 }
interface BorrowDialog { open: boolean; book: Book }
const addInitial: AddDialog = { open: false }
@@ -40,6 +41,7 @@ export default function AdminBooksPage() {
const [edit, setEdit] = useState<EditDialog | null>(null)
const [stock, setStock] = useState<StockDialog | null>(null)
const [del, setDel] = useState<DeleteDialog>({ open: false, book: null })
const [borrow, setBorrow] = useState<BorrowDialog | null>(null)
if (isLoading) return <AdminBooksSkeleton isMobile={isMobile} />
if (error) {
@@ -93,6 +95,9 @@ export default function AdminBooksPage() {
<Button size="xs" variant="destructive" onClick={() => setDel({ open: true, book: b })}>
</Button>
<Button size="xs" variant="outline" disabled={b.stock <= 0} onClick={() => setBorrow({ open: true, book: b })}>
</Button>
</div>
</TableCell>
</TableRow>
@@ -120,6 +125,9 @@ export default function AdminBooksPage() {
<Button size="xs" variant="destructive" onClick={() => setDel({ open: true, book: b })}>
</Button>
<Button size="xs" variant="outline" disabled={b.stock <= 0} onClick={() => setBorrow({ open: true, book: b })}>
</Button>
</div>
</CardContent>
</Card>
@@ -133,6 +141,7 @@ export default function AdminBooksPage() {
{del.book && (
<DeleteBookDialog book={del.book} open onClose={() => setDel({ open: false, book: null })} />
)}
{borrow && <BorrowDialog_ book={borrow.book} open onClose={() => setBorrow(null)} />}
</div>
)
}
@@ -302,6 +311,45 @@ function DeleteBookDialog({ book, open, onClose }: { book: Book; open: boolean;
)
}
// ---- Manual Borrow Dialog ----
function BorrowDialog_({ book, open, onClose }: { book: Book; open: boolean; onClose: () => void }) {
const mutation = useAdminBorrowBook()
const [userId, setUserId] = useState("")
useEffect(() => {
if (!open) setUserId("")
}, [open])
async function submit(e: FormEvent) {
e.preventDefault()
if (!userId.trim()) return
await mutation.mutateAsync({ bookId: book.id!, userId: Number(userId) })
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="borrow-userid"> ID</Label>
<Input id="borrow-userid" type="number" min={1} required value={userId} onChange={(e) => setUserId(e.target.value)} placeholder="输入用户 ID" />
</div>
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && <Loader2Icon className="animate-spin" />}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// ---- Skeleton ----
function AdminBooksSkeleton({ isMobile }: { isMobile: boolean }) {
if (isMobile) {
+16 -1
View File
@@ -2,7 +2,7 @@ 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"
import { getAllBorrows, searchBorrows, returnBook, borrowBook as adminBorrowBook } from "@/api/admin/borrows"
export function useAdminBooks() {
return useQuery({
@@ -96,3 +96,18 @@ export function useAdminReturnBook() {
},
})
}
export function useAdminBorrowBook() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ bookId, userId }: { bookId: number; userId: number }) =>
adminBorrowBook(bookId, userId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", "borrows"] })
toast.success("借阅成功")
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "借阅失败")
},
})
}
+7 -1
View File
@@ -1,5 +1,6 @@
import { createBrowserRouter, Navigate } from "react-router-dom"
import { ProtectedRoute, AdminRoute } from "./guards"
import { useAuthStore } from "@/store/authStore"
import LoginPage from "@/features/auth/LoginPage"
import BooksPage from "@/features/books/BooksPage"
import MyBorrowsPage from "@/features/borrows/MyBorrowsPage"
@@ -8,6 +9,11 @@ import AdminBorrowsPage from "@/features/admin/AdminBorrowsPage"
import AppLayout from "@/components/layout/AppLayout"
import AdminLayout from "@/components/layout/AdminLayout"
function RootRedirect() {
const isAdmin = useAuthStore((s) => s.isAdmin())
return <Navigate to={isAdmin ? "/admin/books" : "/books"} replace />
}
export const router = createBrowserRouter([
{
path: "/login",
@@ -18,7 +24,7 @@ export const router = createBrowserRouter([
children: [
{
path: "/",
element: <Navigate to="/books" replace />,
element: <RootRedirect />,
},
{
path: "/books",