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:59:18 +08:00
parent 78ebd0b62e
commit 9780083476
10 changed files with 65 additions and 17 deletions
+11 -4
View File
@@ -1,6 +1,7 @@
import axios, { AxiosError } from "axios" import axios, { AxiosError } from "axios"
import { useAuthStore } from "@/store/authStore" import { useAuthStore } from "@/store/authStore"
import type { ApiResult } from "@/types/api" import type { ApiResult } from "@/types/api"
import { extractMessageFromBody } from "@/lib/errors"
function toCamelCase(s: string): string { function toCamelCase(s: string): string {
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase()) return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
@@ -31,15 +32,20 @@ client.interceptors.request.use((config) => {
return config return config
}) })
function isLoginPage(): boolean {
return window.location.pathname === "/login" || window.location.pathname === "/"
}
client.interceptors.response.use( client.interceptors.response.use(
(response) => { (response) => {
const body = response.data as ApiResult<unknown> const body = response.data as ApiResult<unknown>
if (body.code !== 200) { if (body.code !== 200) {
if (body.code === 401) { if (body.code === 401) {
useAuthStore.getState().clearAuth() useAuthStore.getState().clearAuth()
window.location.href = "/login" if (!isLoginPage()) window.location.href = "/login"
} }
throw new Error(body.message || "Request failed") const msg = extractMessageFromBody(body) ?? "请求失败"
throw new Error(msg)
} }
response.data = transformKeys(body) as ApiResult<unknown> response.data = transformKeys(body) as ApiResult<unknown>
return response return response
@@ -52,9 +58,10 @@ client.interceptors.response.use(
if (data) { if (data) {
if (data.code === 401) { if (data.code === 401) {
useAuthStore.getState().clearAuth() useAuthStore.getState().clearAuth()
window.location.href = "/login" if (!isLoginPage()) window.location.href = "/login"
} }
throw new Error(data.message || "Request failed") const msg = extractMessageFromBody(data) ?? "请求失败"
throw new Error(msg)
} }
if (error.code === "ERR_NETWORK" || error.code === "ECONNABORTED") { if (error.code === "ERR_NETWORK" || error.code === "ECONNABORTED") {
throw new Error("网络连接失败,请检查网络") throw new Error("网络连接失败,请检查网络")
+2 -1
View File
@@ -21,6 +21,7 @@ import { useAdminBooks, useAddBook, useUpdateBook, useUpdateStock, useDeleteBook
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"
import { getErrorMessage } from "@/lib/errors"
// ---- Dialog state helpers ---- // ---- Dialog state helpers ----
interface AddDialog { open: boolean } interface AddDialog { open: boolean }
@@ -47,7 +48,7 @@ export default function AdminBooksPage() {
if (error) { if (error) {
return ( return (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"> <div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error instanceof Error ? error.message : "未知错误"} {getErrorMessage(error)}
</div> </div>
) )
} }
+2 -1
View File
@@ -20,6 +20,7 @@ import { useAdminBorrows, useSearchBorrows, useAdminReturnBook } from "./hooks"
import { usePlatform } from "@/hooks/usePlatform" import { usePlatform } from "@/hooks/usePlatform"
import { useIsMobile } from "@/hooks/useIsMobile" import { useIsMobile } from "@/hooks/useIsMobile"
import { formatDateTime } from "@/lib/formatters" import { formatDateTime } from "@/lib/formatters"
import { getErrorMessage } from "@/lib/errors"
import type { BorrowInfoVo } from "@/types/api" import type { BorrowInfoVo } from "@/types/api"
export default function AdminBorrowsPage() { export default function AdminBorrowsPage() {
@@ -47,7 +48,7 @@ export default function AdminBorrowsPage() {
if (error) { if (error) {
return ( return (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"> <div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error instanceof Error ? error.message : "未知错误"} {getErrorMessage(error)}
</div> </div>
) )
} }
+7 -6
View File
@@ -3,6 +3,7 @@ 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, borrowBook as adminBorrowBook } from "@/api/admin/borrows" import { getAllBorrows, searchBorrows, returnBook, borrowBook as adminBorrowBook } from "@/api/admin/borrows"
import { getErrorMessage } from "@/lib/errors"
export function useAdminBooks() { export function useAdminBooks() {
return useQuery({ return useQuery({
@@ -20,7 +21,7 @@ export function useAddBook() {
toast.success("添加成功") toast.success("添加成功")
}, },
onError: (err) => { onError: (err) => {
toast.error(err instanceof Error ? err.message : "添加失败") toast.error(getErrorMessage(err, "添加失败"))
}, },
}) })
} }
@@ -35,7 +36,7 @@ export function useUpdateBook() {
toast.success("修改成功") toast.success("修改成功")
}, },
onError: (err) => { onError: (err) => {
toast.error(err instanceof Error ? err.message : "修改失败") toast.error(getErrorMessage(err, "修改失败"))
}, },
}) })
} }
@@ -49,7 +50,7 @@ export function useUpdateStock() {
toast.success("库存更新成功") toast.success("库存更新成功")
}, },
onError: (err) => { onError: (err) => {
toast.error(err instanceof Error ? err.message : "库存更新失败") toast.error(getErrorMessage(err, "库存更新失败"))
}, },
}) })
} }
@@ -63,7 +64,7 @@ export function useDeleteBook() {
toast.success("删除成功") toast.success("删除成功")
}, },
onError: (err) => { onError: (err) => {
toast.error(err instanceof Error ? err.message : "删除失败") toast.error(getErrorMessage(err, "删除失败"))
}, },
}) })
} }
@@ -92,7 +93,7 @@ export function useAdminReturnBook() {
toast.success("归还成功") toast.success("归还成功")
}, },
onError: (err) => { onError: (err) => {
toast.error(err instanceof Error ? err.message : "归还失败") toast.error(getErrorMessage(err, "归还失败"))
}, },
}) })
} }
@@ -107,7 +108,7 @@ export function useAdminBorrowBook() {
toast.success("借阅成功") toast.success("借阅成功")
}, },
onError: (err) => { onError: (err) => {
toast.error(err instanceof Error ? err.message : "借阅失败") toast.error(getErrorMessage(err, "借阅失败"))
}, },
}) })
} }
+2 -1
View File
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"
import { useAuthStore, isAdminRole } from "@/store/authStore" import { useAuthStore, isAdminRole } from "@/store/authStore"
import { login } from "@/api/auth" import { login } from "@/api/auth"
import { setBaseUrl } from "@/api/client" import { setBaseUrl } from "@/api/client"
import { getErrorMessage } from "@/lib/errors"
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -51,7 +52,7 @@ export default function LoginPage() {
toast.success("登录成功") toast.success("登录成功")
navigate(isAdminRole(result.role) ? "/admin/books" : "/books", { replace: true }) navigate(isAdminRole(result.role) ? "/admin/books" : "/books", { replace: true })
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "登录失败") toast.error(getErrorMessage(err, "登录失败"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
+2 -1
View File
@@ -7,6 +7,7 @@ import { useBooks, useBorrowBook } from "./hooks"
import { useAuthStore } from "@/store/authStore" import { useAuthStore } from "@/store/authStore"
import { usePlatform } from "@/hooks/usePlatform" import { usePlatform } from "@/hooks/usePlatform"
import { useIsMobile } from "@/hooks/useIsMobile" import { useIsMobile } from "@/hooks/useIsMobile"
import { getErrorMessage } from "@/lib/errors"
export default function BooksPage() { export default function BooksPage() {
const { data: books, isLoading, error } = useBooks() const { data: books, isLoading, error } = useBooks()
@@ -22,7 +23,7 @@ export default function BooksPage() {
return ( return (
<div className="mx-auto max-w-[1200px] px-4 py-8"> <div className="mx-auto max-w-[1200px] px-4 py-8">
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"> <div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error instanceof Error ? error.message : "未知错误"} {getErrorMessage(error)}
</div> </div>
</div> </div>
) )
+2 -1
View File
@@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { getAllBooks } from "@/api/books" import { getAllBooks } from "@/api/books"
import { borrowBookForMe } from "@/api/borrows" import { borrowBookForMe } from "@/api/borrows"
import { toast } from "sonner" import { toast } from "sonner"
import { getErrorMessage } from "@/lib/errors"
export function useBooks() { export function useBooks() {
return useQuery({ return useQuery({
@@ -21,7 +22,7 @@ export function useBorrowBook() {
toast.success("借阅成功") toast.success("借阅成功")
}, },
onError: (err) => { onError: (err) => {
toast.error(err instanceof Error ? err.message : "借阅失败") toast.error(getErrorMessage(err, "借阅失败"))
}, },
}) })
} }
+2 -1
View File
@@ -8,6 +8,7 @@ import { useMyBorrows, useReturnBook } from "./hooks"
import { formatDateTime } from "@/lib/formatters" import { formatDateTime } from "@/lib/formatters"
import { usePlatform } from "@/hooks/usePlatform" import { usePlatform } from "@/hooks/usePlatform"
import { useIsMobile } from "@/hooks/useIsMobile" import { useIsMobile } from "@/hooks/useIsMobile"
import { getErrorMessage } from "@/lib/errors"
export default function MyBorrowsPage() { export default function MyBorrowsPage() {
const { data: records, isLoading, error } = useMyBorrows() const { data: records, isLoading, error } = useMyBorrows()
@@ -22,7 +23,7 @@ export default function MyBorrowsPage() {
return ( return (
<div className="mx-auto max-w-[1200px] px-4 py-8"> <div className="mx-auto max-w-[1200px] px-4 py-8">
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"> <div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error instanceof Error ? error.message : "未知错误"} {getErrorMessage(error)}
</div> </div>
</div> </div>
) )
+2 -1
View File
@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { getAllMyBorrows, returnBookForMe } from "@/api/borrows" import { getAllMyBorrows, returnBookForMe } from "@/api/borrows"
import { toast } from "sonner" import { toast } from "sonner"
import { getErrorMessage } from "@/lib/errors"
export function useMyBorrows() { export function useMyBorrows() {
return useQuery({ return useQuery({
@@ -18,7 +19,7 @@ export function useReturnBook() {
toast.success("归还成功") toast.success("归还成功")
}, },
onError: (err) => { onError: (err) => {
toast.error(err instanceof Error ? err.message : "归还失败") toast.error(getErrorMessage(err, "归还失败"))
}, },
}) })
} }
+33
View File
@@ -0,0 +1,33 @@
/**
* Extract a human-readable Chinese error message from anything caught in a try/catch.
* Prefers meaningful messages from API responses; falls back to the provided default.
*/
export function getErrorMessage(err: unknown, fallback = "操作失败"): string {
if (err instanceof Error) return err.message || fallback
if (typeof err === "string") return err
return fallback
}
/**
* Try to pull an error message from an API response body of unknown shape.
* Handles:
* - { code, message } (ApiResult / custom backend wrapper)
* - { error, message } (Spring Boot default error attributes)
* - { message } (plain message)
* - string body
*/
export function extractMessageFromBody(body: unknown): string | null {
if (!body) return null
if (typeof body === "string") return body
if (typeof body !== "object") return null
const d = body as Record<string, unknown>
// Preferred: message field (ApiResult and Spring Boot both use this)
if (typeof d.message === "string" && d.message) return d.message
// Spring Boot sometimes puts the summary in "error" and detail in "message"
if (typeof d.error === "string" && d.error) return d.error
return null
}