{"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:
@@ -2,6 +2,22 @@ import axios, { AxiosError } from "axios"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import type { ApiResult } from "@/types/api"
|
||||
|
||||
function toCamelCase(s: string): string {
|
||||
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function transformKeys(obj: unknown): unknown {
|
||||
if (Array.isArray(obj)) return obj.map(transformKeys)
|
||||
if (obj !== null && typeof obj === "object") {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
result[toCamelCase(k)] = transformKeys(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: localStorage.getItem("api_base_url") || "",
|
||||
timeout: 15000,
|
||||
@@ -25,9 +41,13 @@ client.interceptors.response.use(
|
||||
}
|
||||
throw new Error(body.message || "Request failed")
|
||||
}
|
||||
response.data = transformKeys(body) as ApiResult<unknown>
|
||||
return response
|
||||
},
|
||||
(error: AxiosError<ApiResult<unknown>>) => {
|
||||
if (!navigator.onLine) {
|
||||
throw new Error("网络连接失败,请检查网络")
|
||||
}
|
||||
const data = error.response?.data
|
||||
if (data) {
|
||||
if (data.code === 401) {
|
||||
@@ -36,6 +56,9 @@ client.interceptors.response.use(
|
||||
}
|
||||
throw new Error(data.message || "Request failed")
|
||||
}
|
||||
if (error.code === "ERR_NETWORK" || error.code === "ECONNABORTED") {
|
||||
throw new Error("网络连接失败,请检查网络")
|
||||
}
|
||||
throw error
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BookOpenIcon, ListIcon, LogOutIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { usePlatform } from "@/hooks/usePlatform"
|
||||
import { useIsMobile } from "@/hooks/useIsMobile"
|
||||
|
||||
const navItems = [
|
||||
{ to: "/admin/books", label: "图书管理", icon: BookOpenIcon },
|
||||
@@ -14,7 +15,8 @@ export default function AdminLayout() {
|
||||
const clearAuth = useAuthStore((s) => s.clearAuth)
|
||||
const navigate = useNavigate()
|
||||
const platform = usePlatform()
|
||||
const isDesktop = platform === "web" || platform === "desktop"
|
||||
const isMobile = useIsMobile()
|
||||
const hideTitle = platform === "mobile"
|
||||
|
||||
function handleLogout() {
|
||||
clearAuth()
|
||||
@@ -26,7 +28,7 @@ export default function AdminLayout() {
|
||||
isActive ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`
|
||||
|
||||
if (isDesktop) {
|
||||
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">
|
||||
@@ -43,7 +45,7 @@ export default function AdminLayout() {
|
||||
</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="退出">
|
||||
<Button variant="ghost" size="icon-sm" onClick={handleLogout} {...(!hideTitle && { title: "退出" })}>
|
||||
<LogOutIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { useAdminBooks, useAddBook, useUpdateBook, useUpdateStock, useDeleteBook } from "./hooks"
|
||||
import { usePlatform } from "@/hooks/usePlatform"
|
||||
import { useIsMobile } from "@/hooks/useIsMobile"
|
||||
import type { Book } from "@/types/api"
|
||||
|
||||
// ---- Dialog state helpers ----
|
||||
@@ -32,14 +33,15 @@ 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 isMobile = useIsMobile()
|
||||
const sel = platform === "web" ? ({ "data-selectable": true } as const) : ({} as const)
|
||||
|
||||
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 (isLoading) return <AdminBooksSkeleton isMobile={isMobile} />
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
@@ -62,7 +64,7 @@ export default function AdminBooksPage() {
|
||||
|
||||
{list.length === 0 ? (
|
||||
<p className="py-16 text-center text-muted-foreground">暂无书目</p>
|
||||
) : isDesktop ? (
|
||||
) : !isMobile ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -77,8 +79,8 @@ export default function AdminBooksPage() {
|
||||
{list.map((b) => (
|
||||
<TableRow key={b.id}>
|
||||
<TableCell>{b.id}</TableCell>
|
||||
<TableCell className="font-medium">{b.name}</TableCell>
|
||||
<TableCell>{b.author}</TableCell>
|
||||
<TableCell className="font-medium" {...sel}>{b.name}</TableCell>
|
||||
<TableCell {...sel}>{b.author}</TableCell>
|
||||
<TableCell>{b.stock}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
@@ -106,8 +108,8 @@ export default function AdminBooksPage() {
|
||||
<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="font-medium" {...sel}>{b.name}</div>
|
||||
<div className="text-sm text-muted-foreground" {...sel}>{b.author}</div>
|
||||
<div className="flex gap-1.5 pt-2">
|
||||
<Button size="xs" variant="outline" onClick={() => setEdit({ open: true, book: b })}>
|
||||
编辑
|
||||
@@ -301,8 +303,8 @@ function DeleteBookDialog({ book, open, onClose }: { book: Book; open: boolean;
|
||||
}
|
||||
|
||||
// ---- Skeleton ----
|
||||
function AdminBooksSkeleton({ isDesktop }: { isDesktop: boolean }) {
|
||||
if (!isDesktop) {
|
||||
function AdminBooksSkeleton({ isMobile }: { isMobile: boolean }) {
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -18,12 +18,14 @@ import {
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { useAdminBorrows, useSearchBorrows, useAdminReturnBook } from "./hooks"
|
||||
import { usePlatform } from "@/hooks/usePlatform"
|
||||
import { useIsMobile } from "@/hooks/useIsMobile"
|
||||
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 isMobile = useIsMobile()
|
||||
const sel = platform === "web" ? ({ "data-selectable": true } as const) : ({} as const)
|
||||
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [debounced, setDebounced] = useState("")
|
||||
@@ -41,7 +43,7 @@ export default function AdminBorrowsPage() {
|
||||
const returnMutation = useAdminReturnBook()
|
||||
const [returnTarget, setReturnTarget] = useState<BorrowInfoVo | null>(null)
|
||||
|
||||
if (isLoading) return <AdminBorrowsSkeleton isDesktop={isDesktop} />
|
||||
if (isLoading) return <AdminBorrowsSkeleton isMobile={isMobile} />
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
@@ -68,7 +70,7 @@ export default function AdminBorrowsPage() {
|
||||
<p className="py-16 text-center text-muted-foreground">
|
||||
{isSearching ? "无匹配记录" : "暂无借阅记录"}
|
||||
</p>
|
||||
) : isDesktop ? (
|
||||
) : !isMobile ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -86,9 +88,10 @@ export default function AdminBorrowsPage() {
|
||||
<BorrowRow
|
||||
key={r.id}
|
||||
record={r}
|
||||
isDesktop
|
||||
isMobile={false}
|
||||
returning={returnMutation.isPending}
|
||||
onReturn={() => setReturnTarget(r)}
|
||||
sel={sel}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -100,9 +103,10 @@ export default function AdminBorrowsPage() {
|
||||
<CardContent className="space-y-1.5 p-4">
|
||||
<BorrowRow
|
||||
record={r}
|
||||
isDesktop={false}
|
||||
isMobile
|
||||
returning={returnMutation.isPending}
|
||||
onReturn={() => setReturnTarget(r)}
|
||||
sel={sel}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -136,14 +140,16 @@ export default function AdminBorrowsPage() {
|
||||
|
||||
function BorrowRow({
|
||||
record,
|
||||
isDesktop,
|
||||
isMobile,
|
||||
returning,
|
||||
onReturn,
|
||||
sel,
|
||||
}: {
|
||||
record: BorrowInfoVo
|
||||
isDesktop: boolean
|
||||
isMobile: boolean
|
||||
returning: boolean
|
||||
onReturn: () => void
|
||||
sel: Record<string, unknown>
|
||||
}) {
|
||||
const statusBadge = (
|
||||
<Badge variant={record.status === "BORROWED" ? "default" : "secondary"}>
|
||||
@@ -152,17 +158,17 @@ function BorrowRow({
|
||||
)
|
||||
|
||||
const returnButton = record.status === "BORROWED" && (
|
||||
<Button size={isDesktop ? "xs" : "sm"} variant="outline" disabled={returning} onClick={onReturn}>
|
||||
<Button size={isMobile ? "sm" : "xs"} variant="outline" disabled={returning} onClick={onReturn}>
|
||||
{returning && <Loader2Icon className="animate-spin" />}
|
||||
代为归还
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (isDesktop) {
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>{record.id}</TableCell>
|
||||
<TableCell className="font-medium">{record.bookBorrowVo.name}</TableCell>
|
||||
<TableCell className="font-medium" {...sel}>{record.bookBorrowVo?.name ?? "-"}</TableCell>
|
||||
<TableCell>{record.userBorrowVo.username}</TableCell>
|
||||
<TableCell>{formatDateTime(record.borrowTime)}</TableCell>
|
||||
<TableCell>{record.returnTime ? formatDateTime(record.returnTime) : "-"}</TableCell>
|
||||
@@ -178,7 +184,7 @@ function BorrowRow({
|
||||
<span className="text-xs text-muted-foreground">#{record.id}</span>
|
||||
{statusBadge}
|
||||
</div>
|
||||
<div className="font-medium">{record.bookBorrowVo.name}</div>
|
||||
<div className="font-medium" {...sel}>{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)}
|
||||
@@ -189,8 +195,8 @@ function BorrowRow({
|
||||
)
|
||||
}
|
||||
|
||||
function AdminBorrowsSkeleton({ isDesktop }: { isDesktop: boolean }) {
|
||||
if (!isDesktop) {
|
||||
function AdminBorrowsSkeleton({ isMobile }: { isMobile: boolean }) {
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-7 w-24" />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/componen
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { useAuthStore, isAdminRole } from "@/store/authStore"
|
||||
import { login } from "@/api/auth"
|
||||
import { setBaseUrl } from "@/api/client"
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function LoginPage() {
|
||||
}, [])
|
||||
|
||||
if (token) {
|
||||
return <Navigate to={role === "ADMIN" ? "/admin/books" : "/books"} replace />
|
||||
return <Navigate to={isAdminRole(role) ? "/admin/books" : "/books"} replace />
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
@@ -49,7 +49,7 @@ export default function LoginPage() {
|
||||
const result = await login({ username: username.trim(), password })
|
||||
setAuth(result)
|
||||
toast.success("登录成功")
|
||||
navigate(result.role === "ADMIN" ? "/admin/books" : "/books", { replace: true })
|
||||
navigate(isAdminRole(result.role) ? "/admin/books" : "/books", { replace: true })
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "登录失败")
|
||||
} finally {
|
||||
@@ -59,7 +59,7 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<Card className="w-full max-w-md mx-4 sm:mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-xl">图书管理系统</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@@ -6,15 +6,17 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useBooks, useBorrowBook } from "./hooks"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { usePlatform } from "@/hooks/usePlatform"
|
||||
import { useIsMobile } from "@/hooks/useIsMobile"
|
||||
|
||||
export default function BooksPage() {
|
||||
const { data: books, isLoading, error } = useBooks()
|
||||
const borrow = useBorrowBook()
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const platform = usePlatform()
|
||||
const isDesktop = platform === "web" || platform === "desktop"
|
||||
const isMobile = useIsMobile()
|
||||
const sel = platform === "web" ? ({ "data-selectable": true } as const) : ({} as const)
|
||||
|
||||
if (isLoading) return <BooksSkeleton isDesktop={isDesktop} />
|
||||
if (isLoading) return <BooksSkeleton isMobile={isMobile} />
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -36,7 +38,7 @@ export default function BooksPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (isDesktop) {
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1200px] px-4 py-8">
|
||||
<h1 className="mb-4 text-xl font-semibold">书目列表</h1>
|
||||
@@ -52,8 +54,8 @@ export default function BooksPage() {
|
||||
<TableBody>
|
||||
{list.map((book) => (
|
||||
<TableRow key={book.id}>
|
||||
<TableCell className="font-medium">{book.name}</TableCell>
|
||||
<TableCell>{book.author}</TableCell>
|
||||
<TableCell className="font-medium" {...sel}>{book.name}</TableCell>
|
||||
<TableCell {...sel}>{book.author}</TableCell>
|
||||
<TableCell>{book.stock}</TableCell>
|
||||
{token && (
|
||||
<TableCell>
|
||||
@@ -83,8 +85,8 @@ export default function BooksPage() {
|
||||
<Card key={book.id}>
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium">{book.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{book.author}</div>
|
||||
<div className="font-medium" {...sel}>{book.name}</div>
|
||||
<div className="text-sm text-muted-foreground" {...sel}>{book.author}</div>
|
||||
<div className="text-sm text-muted-foreground">库存:{book.stock}</div>
|
||||
</div>
|
||||
{token && (
|
||||
@@ -105,8 +107,8 @@ export default function BooksPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function BooksSkeleton({ isDesktop }: { isDesktop: boolean }) {
|
||||
if (!isDesktop) {
|
||||
function BooksSkeleton({ isMobile }: { isMobile: boolean }) {
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="space-y-3 px-4 py-4">
|
||||
<Skeleton className="h-7 w-24" />
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useMyBorrows, useReturnBook } from "./hooks"
|
||||
import { formatDateTime } from "@/lib/formatters"
|
||||
import { usePlatform } from "@/hooks/usePlatform"
|
||||
import { useIsMobile } from "@/hooks/useIsMobile"
|
||||
|
||||
export default function MyBorrowsPage() {
|
||||
const { data: records, isLoading, error } = useMyBorrows()
|
||||
const returnBook = useReturnBook()
|
||||
const platform = usePlatform()
|
||||
const isMobile = useIsMobile()
|
||||
const sel = platform === "web" ? ({ "data-selectable": true } as const) : ({} as const)
|
||||
|
||||
if (isLoading) return <MyBorrowsSkeleton />
|
||||
if (isLoading) return <MyBorrowsSkeleton isMobile={isMobile} />
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -33,36 +39,77 @@ export default function MyBorrowsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1200px] px-4 py-8">
|
||||
<h1 className="mb-4 text-xl font-semibold">我的借阅</h1>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>书名</TableHead>
|
||||
<TableHead>作者</TableHead>
|
||||
<TableHead>借阅时间</TableHead>
|
||||
<TableHead>归还时间</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="w-[100px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{list.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium" {...sel}>{r.bookBorrowVo?.name ?? "-"}</TableCell>
|
||||
<TableCell {...sel}>{r.bookBorrowVo?.author ?? "-"}</TableCell>
|
||||
<TableCell>{formatDateTime(r.borrowTime)}</TableCell>
|
||||
<TableCell>
|
||||
{r.returnTime ? formatDateTime(r.returnTime) : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={r.status === "BORROWED" ? "default" : "secondary"}>
|
||||
{r.status === "BORROWED" ? "借阅中" : "已归还"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{r.status === "BORROWED" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={returnBook.isPending}
|
||||
onClick={() => returnBook.mutate(r.id)}
|
||||
>
|
||||
{returnBook.isPending && <Loader2Icon className="animate-spin" />}
|
||||
归还
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1200px] px-4 py-8">
|
||||
<div className="px-4 py-4">
|
||||
<h1 className="mb-4 text-xl font-semibold">我的借阅</h1>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>书名</TableHead>
|
||||
<TableHead>作者</TableHead>
|
||||
<TableHead>借阅时间</TableHead>
|
||||
<TableHead>归还时间</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="w-[100px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{list.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">{r.bookBorrowVo.name}</TableCell>
|
||||
<TableCell>{r.bookBorrowVo.author}</TableCell>
|
||||
<TableCell>{formatDateTime(r.borrowTime)}</TableCell>
|
||||
<TableCell>
|
||||
{r.returnTime ? formatDateTime(r.returnTime) : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-3">
|
||||
{list.map((r) => (
|
||||
<Card key={r.id}>
|
||||
<CardContent className="space-y-1.5 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">#{r.id}</span>
|
||||
<Badge variant={r.status === "BORROWED" ? "default" : "secondary"}>
|
||||
{r.status === "BORROWED" ? "借阅中" : "已归还"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{r.status === "BORROWED" && (
|
||||
</div>
|
||||
<div className="font-medium" {...sel}>{r.bookBorrowVo?.name ?? "-"}</div>
|
||||
<div className="text-sm text-muted-foreground" {...sel}>{r.bookBorrowVo?.author ?? "-"}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
借阅:{formatDateTime(r.borrowTime)}
|
||||
{r.returnTime && ` | 归还:${formatDateTime(r.returnTime)}`}
|
||||
</div>
|
||||
{r.status === "BORROWED" && (
|
||||
<div className="pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -72,17 +119,35 @@ export default function MyBorrowsPage() {
|
||||
{returnBook.isPending && <Loader2Icon className="animate-spin" />}
|
||||
归还
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MyBorrowsSkeleton() {
|
||||
function MyBorrowsSkeleton({ isMobile }: { isMobile: boolean }) {
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="space-y-3 px-4 py-4">
|
||||
<Skeleton className="h-7 w-24" />
|
||||
{Array.from({ length: 5 }).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 className="mx-auto max-w-[1200px] px-4 py-8">
|
||||
<Skeleton className="mb-4 h-7 w-24" />
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
const BP = "(max-width: 767px)"
|
||||
|
||||
export function useIsMobile(): boolean {
|
||||
const [mobile, setMobile] = useState(() => window.matchMedia(BP).matches)
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(BP)
|
||||
function onChange(e: MediaQueryListEvent) {
|
||||
setMobile(e.matches)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return mobile
|
||||
}
|
||||
+44
-4
@@ -1,5 +1,6 @@
|
||||
export function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
export function formatDateTime(raw: unknown): string {
|
||||
const d = toDate(raw)
|
||||
if (!d) return "-"
|
||||
const Y = d.getFullYear()
|
||||
const M = String(d.getMonth() + 1).padStart(2, "0")
|
||||
const D = String(d.getDate()).padStart(2, "0")
|
||||
@@ -8,6 +9,45 @@ export function formatDateTime(iso: string): string {
|
||||
return `${Y}-${M}-${D} ${h}:${m}`
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString()
|
||||
export function formatDate(raw: unknown): string {
|
||||
const d = toDate(raw)
|
||||
if (!d) return "-"
|
||||
return d.toLocaleDateString()
|
||||
}
|
||||
|
||||
function toDate(raw: unknown): Date | null {
|
||||
if (raw == null) return null
|
||||
|
||||
if (raw instanceof Date) return isNaN(raw.getTime()) ? null : raw
|
||||
|
||||
if (typeof raw === "string") {
|
||||
const d = new Date(raw)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
if (typeof raw === "number") {
|
||||
const d = new Date(raw)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
// Java LocalDateTime array: [year, month, day, hour, minute, second, nanos]
|
||||
if (Array.isArray(raw) && raw.length >= 3) {
|
||||
const [y, mo, d, h = 0, mi = 0, s = 0] = raw
|
||||
return new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s))
|
||||
}
|
||||
|
||||
if (typeof raw === "object") {
|
||||
const obj = raw as Record<string, unknown>
|
||||
const y = Number(obj.year ?? 0)
|
||||
const mo = Number(obj.monthValue ?? obj.month ?? 1)
|
||||
const d = Number(obj.dayOfMonth ?? obj.day ?? 1)
|
||||
const h = Number(obj.hour ?? 0)
|
||||
const mi = Number(obj.minute ?? 0)
|
||||
const s = Number(obj.second ?? 0)
|
||||
if (!isNaN(y) && !isNaN(mo) && !isNaN(d)) {
|
||||
return new Date(y, mo - 1, d, h, mi, s)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export function ProtectedRoute() {
|
||||
|
||||
export function AdminRoute() {
|
||||
const isLoggedIn = useAuthStore((s) => !!s.token)
|
||||
const isAdmin = useAuthStore((s) => s.role === "ADMIN")
|
||||
const isAdmin = useAuthStore((s) => s.isAdmin())
|
||||
if (!isLoggedIn) return <Navigate to="/login" replace />
|
||||
if (!isAdmin) return <Navigate to="/books" replace />
|
||||
return <Outlet />
|
||||
|
||||
+10
-1
@@ -45,10 +45,19 @@ export const useAuthStore = create<AuthState>((set, get) => {
|
||||
},
|
||||
|
||||
isLoggedIn: () => !!get().token,
|
||||
isAdmin: () => get().role === "ADMIN",
|
||||
isAdmin: () => {
|
||||
const r = get().role
|
||||
return r != null && (r.toUpperCase() === "ADMIN" || r.toUpperCase() === "ROLE_ADMIN")
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export function getToken(): string | null {
|
||||
return useAuthStore.getState().token
|
||||
}
|
||||
|
||||
export function isAdminRole(role: string | null): boolean {
|
||||
if (!role) return false
|
||||
const u = role.toUpperCase()
|
||||
return u === "ADMIN" || u === "ROLE_ADMIN"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user