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:
@@ -31,7 +31,7 @@ export default function AdminLayout() {
|
|||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<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">
|
<div className="flex h-12 items-center gap-2 border-b px-4">
|
||||||
<span className="font-heading text-base font-semibold">管理后台</span>
|
<span className="font-heading text-base font-semibold">管理后台</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} 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 { usePlatform } from "@/hooks/usePlatform"
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile"
|
import { useIsMobile } from "@/hooks/useIsMobile"
|
||||||
import type { Book } from "@/types/api"
|
import type { Book } from "@/types/api"
|
||||||
@@ -27,6 +27,7 @@ interface AddDialog { open: boolean }
|
|||||||
interface EditDialog { open: boolean; book: Book }
|
interface EditDialog { open: boolean; book: Book }
|
||||||
interface StockDialog { open: boolean; book: Book }
|
interface StockDialog { open: boolean; book: Book }
|
||||||
type DeleteDialog = { open: boolean; book: Book } | { open: false; book: null }
|
type DeleteDialog = { open: boolean; book: Book } | { open: false; book: null }
|
||||||
|
interface BorrowDialog { open: boolean; book: Book }
|
||||||
|
|
||||||
const addInitial: AddDialog = { open: false }
|
const addInitial: AddDialog = { open: false }
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export default function AdminBooksPage() {
|
|||||||
const [edit, setEdit] = useState<EditDialog | null>(null)
|
const [edit, setEdit] = useState<EditDialog | null>(null)
|
||||||
const [stock, setStock] = useState<StockDialog | null>(null)
|
const [stock, setStock] = useState<StockDialog | null>(null)
|
||||||
const [del, setDel] = useState<DeleteDialog>({ open: false, book: null })
|
const [del, setDel] = useState<DeleteDialog>({ open: false, book: null })
|
||||||
|
const [borrow, setBorrow] = useState<BorrowDialog | null>(null)
|
||||||
|
|
||||||
if (isLoading) return <AdminBooksSkeleton isMobile={isMobile} />
|
if (isLoading) return <AdminBooksSkeleton isMobile={isMobile} />
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -93,6 +95,9 @@ export default function AdminBooksPage() {
|
|||||||
<Button size="xs" variant="destructive" onClick={() => setDel({ open: true, book: b })}>
|
<Button size="xs" variant="destructive" onClick={() => setDel({ open: true, book: b })}>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="xs" variant="outline" disabled={b.stock <= 0} onClick={() => setBorrow({ open: true, book: b })}>
|
||||||
|
手动借阅
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -120,6 +125,9 @@ export default function AdminBooksPage() {
|
|||||||
<Button size="xs" variant="destructive" onClick={() => setDel({ open: true, book: b })}>
|
<Button size="xs" variant="destructive" onClick={() => setDel({ open: true, book: b })}>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="xs" variant="outline" disabled={b.stock <= 0} onClick={() => setBorrow({ open: true, book: b })}>
|
||||||
|
手动借阅
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -133,6 +141,7 @@ export default function AdminBooksPage() {
|
|||||||
{del.book && (
|
{del.book && (
|
||||||
<DeleteBookDialog book={del.book} open onClose={() => setDel({ open: false, book: null })} />
|
<DeleteBookDialog book={del.book} open onClose={() => setDel({ open: false, book: null })} />
|
||||||
)}
|
)}
|
||||||
|
{borrow && <BorrowDialog_ book={borrow.book} open onClose={() => setBorrow(null)} />}
|
||||||
</div>
|
</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 ----
|
// ---- Skeleton ----
|
||||||
function AdminBooksSkeleton({ isMobile }: { isMobile: boolean }) {
|
function AdminBooksSkeleton({ isMobile }: { isMobile: boolean }) {
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { getAllBooks } from "@/api/books"
|
import { getAllBooks } from "@/api/books"
|
||||||
import { addBook, deleteBook, updateBook, updateStock } from "@/api/admin/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() {
|
export function useAdminBooks() {
|
||||||
return useQuery({
|
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 : "借阅失败")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createBrowserRouter, Navigate } from "react-router-dom"
|
import { createBrowserRouter, Navigate } from "react-router-dom"
|
||||||
import { ProtectedRoute, AdminRoute } from "./guards"
|
import { ProtectedRoute, AdminRoute } from "./guards"
|
||||||
|
import { useAuthStore } from "@/store/authStore"
|
||||||
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"
|
||||||
@@ -8,6 +9,11 @@ import AdminBorrowsPage from "@/features/admin/AdminBorrowsPage"
|
|||||||
import AppLayout from "@/components/layout/AppLayout"
|
import AppLayout from "@/components/layout/AppLayout"
|
||||||
import AdminLayout from "@/components/layout/AdminLayout"
|
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([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: "/login",
|
path: "/login",
|
||||||
@@ -18,7 +24,7 @@ export const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
element: <Navigate to="/books" replace />,
|
element: <RootRedirect />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/books",
|
path: "/books",
|
||||||
|
|||||||
Reference in New Issue
Block a user