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:
2026-05-24 19:28:29 +08:00
parent 1ca3417f0e
commit 4820370b6a
7 changed files with 356 additions and 19 deletions
+44 -1
View File
@@ -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>
)
+139
View File
@@ -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>
)
}
+27
View File
@@ -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 : "借阅失败")
},
})
}
+96
View File
@@ -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>
)
}
+24
View File
@@ -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 : "归还失败")
},
})
}
+7 -1
View File
@@ -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 {
+8 -6
View File
@@ -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>
}
@@ -24,6 +21,9 @@ export const router = createBrowserRouter([
path: "/login",
element: <LoginPage />,
},
{
element: <AppLayout />,
children: [
{
path: "/",
element: <Navigate to="/books" replace />,
@@ -41,6 +41,8 @@ export const router = createBrowserRouter([
},
],
},
],
},
{
element: <AdminRoute />,
children: [