feat(ui): add navigation header and wire up real page routes
- Add sticky header with book management nav links and logout button - Replace placeholder page components with real feature imports - Add login page route to router - Fix date-time formatting for consistent YYYY-MM-DD HH:mm output
This commit is contained in:
@@ -1,8 +1,51 @@
|
||||
import { Outlet } from "react-router-dom"
|
||||
import { Link, Outlet, useNavigate } from "react-router-dom"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export default function AppLayout() {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const username = useAuthStore((s) => s.username)
|
||||
const clearAuth = useAuthStore((s) => s.clearAuth)
|
||||
const navigate = useNavigate()
|
||||
|
||||
function handleLogout() {
|
||||
clearAuth()
|
||||
navigate("/books", { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="sticky top-0 z-40 border-b bg-background">
|
||||
<div className="mx-auto flex h-12 max-w-[1200px] items-center gap-6 px-4">
|
||||
<Link to="/books" className="font-heading text-base font-semibold shrink-0">
|
||||
图书管理系统
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4 text-sm">
|
||||
<Link to="/books" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
书目
|
||||
</Link>
|
||||
{token && (
|
||||
<Link to="/my-borrows" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
我的借阅
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{token ? (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground">{username}</span>
|
||||
<Button variant="outline" size="sm" onClick={handleLogout}>
|
||||
退出
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => navigate("/login")}>
|
||||
登录
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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 { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useBooks, useBorrowBook } from "./hooks"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { usePlatform } from "@/hooks/usePlatform"
|
||||
|
||||
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"
|
||||
|
||||
if (isLoading) return <BooksSkeleton isDesktop={isDesktop} />
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<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">
|
||||
加载失败:{error instanceof Error ? error.message : "未知错误"}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const list = books ?? []
|
||||
|
||||
if (list.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1200px] px-4 py-16 text-center text-muted-foreground">
|
||||
暂无书目
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isDesktop) {
|
||||
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>
|
||||
{token && <TableHead className="w-[120px]">操作</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{list.map((book) => (
|
||||
<TableRow key={book.id}>
|
||||
<TableCell className="font-medium">{book.name}</TableCell>
|
||||
<TableCell>{book.author}</TableCell>
|
||||
<TableCell>{book.stock}</TableCell>
|
||||
{token && (
|
||||
<TableCell>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={book.stock <= 0 || borrow.isPending}
|
||||
onClick={() => borrow.mutate(book.id!)}
|
||||
>
|
||||
{borrow.isPending && <Loader2Icon className="animate-spin" />}
|
||||
{book.stock <= 0 ? "暂无库存" : "借阅"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4">
|
||||
<h1 className="mb-4 text-xl font-semibold">书目列表</h1>
|
||||
<div className="space-y-3">
|
||||
{list.map((book) => (
|
||||
<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="text-sm text-muted-foreground">库存:{book.stock}</div>
|
||||
</div>
|
||||
{token && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={book.stock <= 0 || borrow.isPending}
|
||||
onClick={() => borrow.mutate(book.id!)}
|
||||
>
|
||||
{borrow.isPending && <Loader2Icon className="animate-spin" />}
|
||||
{book.stock <= 0 ? "暂无库存" : "借阅"}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BooksSkeleton({ isDesktop }: { isDesktop: boolean }) {
|
||||
if (!isDesktop) {
|
||||
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="flex items-center justify-between p-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-16 rounded-lg" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1200px] px-4 py-8">
|
||||
<Skeleton className="mb-4 h-7 w-24" />
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { getAllBooks } from "@/api/books"
|
||||
import { borrowBookForMe } from "@/api/borrows"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useBooks() {
|
||||
return useQuery({
|
||||
queryKey: ["books"],
|
||||
queryFn: getAllBooks,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useBorrowBook() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: borrowBookForMe,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["books"] })
|
||||
qc.invalidateQueries({ queryKey: ["myBorrows"] })
|
||||
toast.success("借阅成功")
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "借阅失败")
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
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"
|
||||
|
||||
export default function MyBorrowsPage() {
|
||||
const { data: records, isLoading, error } = useMyBorrows()
|
||||
const returnBook = useReturnBook()
|
||||
|
||||
if (isLoading) return <MyBorrowsSkeleton />
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<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">
|
||||
加载失败:{error instanceof Error ? error.message : "未知错误"}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const list = records ?? []
|
||||
|
||||
if (list.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1200px] px-4 py-16 text-center text-muted-foreground">
|
||||
<p className="text-lg">暂无借阅记录</p>
|
||||
<p className="mt-1 text-sm">去书目页面借阅你感兴趣的图书吧</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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">{r.bookBorrowVo.name}</TableCell>
|
||||
<TableCell>{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>
|
||||
)
|
||||
}
|
||||
|
||||
function MyBorrowsSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1200px] px-4 py-8">
|
||||
<Skeleton className="mb-4 h-7 w-24" />
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { getAllMyBorrows, returnBookForMe } from "@/api/borrows"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useMyBorrows() {
|
||||
return useQuery({
|
||||
queryKey: ["myBorrows"],
|
||||
queryFn: getAllMyBorrows,
|
||||
})
|
||||
}
|
||||
|
||||
export function useReturnBook() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: returnBookForMe,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["myBorrows"] })
|
||||
toast.success("归还成功")
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "归还失败")
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
export function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString()
|
||||
const d = new Date(iso)
|
||||
const Y = d.getFullYear()
|
||||
const M = String(d.getMonth() + 1).padStart(2, "0")
|
||||
const D = String(d.getDate()).padStart(2, "0")
|
||||
const h = String(d.getHours()).padStart(2, "0")
|
||||
const m = String(d.getMinutes()).padStart(2, "0")
|
||||
return `${Y}-${M}-${D} ${h}:${m}`
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
|
||||
+19
-17
@@ -1,14 +1,11 @@
|
||||
import { createBrowserRouter, Navigate } from "react-router-dom"
|
||||
import { ProtectedRoute, AdminRoute } from "./guards"
|
||||
import LoginPage from "@/features/auth/LoginPage"
|
||||
import BooksPage from "@/features/books/BooksPage"
|
||||
import MyBorrowsPage from "@/features/borrows/MyBorrowsPage"
|
||||
import AppLayout from "@/components/layout/AppLayout"
|
||||
|
||||
// Placeholder components — real pages will replace these
|
||||
function BooksPage() {
|
||||
return <div>Books</div>
|
||||
}
|
||||
function MyBorrowsPage() {
|
||||
return <div>My Borrows</div>
|
||||
}
|
||||
function AdminBooksPage() {
|
||||
return <div>Admin / Books</div>
|
||||
}
|
||||
@@ -25,19 +22,24 @@ export const router = createBrowserRouter([
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <Navigate to="/books" replace />,
|
||||
},
|
||||
{
|
||||
path: "/books",
|
||||
element: <BooksPage />,
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute />,
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{
|
||||
path: "/my-borrows",
|
||||
element: <MyBorrowsPage />,
|
||||
path: "/",
|
||||
element: <Navigate to="/books" replace />,
|
||||
},
|
||||
{
|
||||
path: "/books",
|
||||
element: <BooksPage />,
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute />,
|
||||
children: [
|
||||
{
|
||||
path: "/my-borrows",
|
||||
element: <MyBorrowsPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user