feat(admin): add search with submit and safe-area layout

Add submit-based search to admin books page and switch borrows
search from debounced to submit-based for consistency. Update
layout headers and nav bars to respect mobile safe-area insets
via CSS custom properties.
This commit is contained in:
2026-05-24 21:51:29 +08:00
parent 9780083476
commit 3a341393ef
14 changed files with 576 additions and 401 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Tauri + React + Typescript</title>
</head>
+2 -2
View File
@@ -59,7 +59,7 @@ export default function AdminLayout() {
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-40 flex h-12 items-center justify-between border-b bg-background px-4">
<header className="sticky top-[var(--safe-top,0px)] z-40 flex h-12 items-center justify-between border-b bg-background px-4">
<span className="font-heading text-sm font-semibold"></span>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{username}</span>
@@ -71,7 +71,7 @@ export default function AdminLayout() {
<main className="flex-1 px-4 py-4">
<Outlet />
</main>
<nav className="sticky bottom-0 z-40 flex border-t bg-background">
<nav className="sticky bottom-[var(--safe-bottom,0px)] z-40 flex border-t bg-background">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={linkClass + " flex-1 justify-center py-3"} end>
<Icon className="size-4" />
+1 -1
View File
@@ -15,7 +15,7 @@ export default function AppLayout() {
return (
<div className="min-h-screen">
<header className="sticky top-0 z-40 border-b bg-background">
<header className="sticky top-[var(--safe-top,0px)] 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">
+125 -95
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, type FormEvent } from "react"
import { Loader2Icon, PlusIcon } from "lucide-react"
import { Loader2Icon, PlusIcon, SearchIcon, XIcon } 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"
@@ -17,7 +17,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useAdminBooks, useAddBook, useUpdateBook, useUpdateStock, useDeleteBook, useAdminBorrowBook } from "./hooks"
import { useAdminBooks, useAdminSearchBooks, useAddBook, useUpdateBook, useUpdateStock, useDeleteBook, useAdminBorrowBook } from "./hooks"
import { usePlatform } from "@/hooks/usePlatform"
import { useIsMobile } from "@/hooks/useIsMobile"
import type { Book } from "@/types/api"
@@ -33,27 +33,114 @@ interface BorrowDialog { open: boolean; book: Book }
const addInitial: AddDialog = { open: false }
export default function AdminBooksPage() {
const { data: books, isLoading, error } = useAdminBooks()
const platform = usePlatform()
const isMobile = useIsMobile()
const sel = platform === "web" ? ({ "data-selectable": true } as const) : ({} as const)
const [searchText, setSearchText] = useState("")
const [submitted, setSubmitted] = useState("")
const isSearching = submitted.length > 0
const allQuery = useAdminBooks()
const searchQuery = useAdminSearchBooks(submitted)
const { data, isLoading, error } = isSearching ? searchQuery : allQuery
function doSearch(e: FormEvent) {
e.preventDefault()
setSubmitted(searchText.trim())
}
function clearSearch() {
setSearchText("")
setSubmitted("")
}
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 })
const [borrow, setBorrow] = useState<BorrowDialog | null>(null)
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">
{getErrorMessage(error)}
</div>
)
}
const list = data ?? []
const list = books ?? []
const content = isLoading ? (
<AdminBooksSkeleton isMobile={isMobile} />
) : error ? (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{getErrorMessage(error)}
</div>
) : list.length === 0 ? (
<p className="py-16 text-center text-muted-foreground">
{isSearching ? "无匹配书目" : "暂无书目"}
</p>
) : !isMobile ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((b) => (
<TableRow key={b.id}>
<TableCell>{b.id}</TableCell>
<TableCell className="font-medium" {...sel}>{b.name}</TableCell>
<TableCell {...sel}>{b.author}</TableCell>
<TableCell>{b.stock}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button size="xs" variant="outline" onClick={() => setEdit({ open: true, book: b })}>
</Button>
<Button size="xs" variant="outline" onClick={() => setStock({ open: true, book: b })}>
</Button>
<Button size="xs" variant="destructive" onClick={() => setDel({ open: true, book: b })}>
</Button>
<Button size="xs" variant="outline" disabled={b.stock <= 0} onClick={() => setBorrow({ open: true, book: b })}>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="space-y-3">
{list.map((b) => (
<Card key={b.id}>
<CardContent className="space-y-1.5 p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">#{b.id}</span>
<span className="text-sm">{b.stock}</span>
</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 })}>
</Button>
<Button size="xs" variant="outline" onClick={() => setStock({ open: true, book: b })}>
</Button>
<Button size="xs" variant="destructive" onClick={() => setDel({ open: true, book: b })}>
</Button>
<Button size="xs" variant="outline" disabled={b.stock <= 0} onClick={() => setBorrow({ open: true, book: b })}>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)
return (
<div>
@@ -65,76 +152,29 @@ export default function AdminBooksPage() {
</Button>
</div>
{list.length === 0 ? (
<p className="py-16 text-center text-muted-foreground"></p>
) : !isMobile ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((b) => (
<TableRow key={b.id}>
<TableCell>{b.id}</TableCell>
<TableCell className="font-medium" {...sel}>{b.name}</TableCell>
<TableCell {...sel}>{b.author}</TableCell>
<TableCell>{b.stock}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button size="xs" variant="outline" onClick={() => setEdit({ open: true, book: b })}>
</Button>
<Button size="xs" variant="outline" onClick={() => setStock({ open: true, book: b })}>
</Button>
<Button size="xs" variant="destructive" onClick={() => setDel({ open: true, book: b })}>
</Button>
<Button size="xs" variant="outline" disabled={b.stock <= 0} onClick={() => setBorrow({ open: true, book: b })}>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="space-y-3">
{list.map((b) => (
<Card key={b.id}>
<CardContent className="space-y-1.5 p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">#{b.id}</span>
<span className="text-sm">{b.stock}</span>
</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 })}>
</Button>
<Button size="xs" variant="outline" onClick={() => setStock({ open: true, book: b })}>
</Button>
<Button size="xs" variant="destructive" onClick={() => setDel({ open: true, book: b })}>
</Button>
<Button size="xs" variant="outline" disabled={b.stock <= 0} onClick={() => setBorrow({ open: true, book: b })}>
</Button>
</div>
</CardContent>
</Card>
))}
<form onSubmit={doSearch} className="mb-4 flex gap-2">
<div className="relative flex-1 max-w-sm">
<Input
placeholder="搜索书名或作者..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
{isSearching && (
<button
type="button"
onClick={clearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<XIcon className="size-4" />
</button>
)}
</div>
)}
<Button type="submit" size="icon-sm" variant="outline">
<SearchIcon className="size-4" />
</Button>
</form>
{content}
<AddBookDialog open={add.open} onClose={() => setAdd(addInitial)} />
{edit && <EditBookDialog book={edit.book} open onClose={() => setEdit(null)} />}
@@ -356,10 +396,6 @@ function AdminBooksSkeleton({ isMobile }: { isMobile: boolean }) {
if (isMobile) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-8 w-24 rounded-lg" />
</div>
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardContent className="space-y-2 p-4">
@@ -378,16 +414,10 @@ function AdminBooksSkeleton({ isMobile }: { isMobile: boolean }) {
)
}
return (
<div>
<div className="mb-4 flex items-center justify-between">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-8 w-24 rounded-lg" />
</div>
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
)
}
+95 -84
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"
import { Loader2Icon } from "lucide-react"
import { useState, type FormEvent } from "react"
import { Loader2Icon, SearchIcon, XIcon } 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"
@@ -29,91 +29,108 @@ export default function AdminBorrowsPage() {
const sel = platform === "web" ? ({ "data-selectable": true } as const) : ({} as const)
const [searchText, setSearchText] = useState("")
const [debounced, setDebounced] = useState("")
const [submitted, setSubmitted] = useState("")
useEffect(() => {
const t = setTimeout(() => setDebounced(searchText), 300)
return () => clearTimeout(t)
}, [searchText])
const isSearching = debounced.length > 0
const isSearching = submitted.length > 0
const allQuery = useAdminBorrows()
const searchQuery = useSearchBorrows(debounced)
const searchQuery = useSearchBorrows(submitted)
const { data, isLoading, error } = isSearching ? searchQuery : allQuery
function doSearch(e: FormEvent) {
e.preventDefault()
setSubmitted(searchText.trim())
}
function clearSearch() {
setSearchText("")
setSubmitted("")
}
const returnMutation = useAdminReturnBook()
const [returnTarget, setReturnTarget] = useState<BorrowInfoVo | null>(null)
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">
{getErrorMessage(error)}
</div>
)
}
const list = data ?? []
const content = isLoading ? (
<AdminBorrowsSkeleton isMobile={isMobile} />
) : error ? (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{getErrorMessage(error)}
</div>
) : list.length === 0 ? (
<p className="py-16 text-center text-muted-foreground">
{isSearching ? "无匹配记录" : "暂无借阅记录"}
</p>
) : !isMobile ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[110px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((r) => (
<BorrowRow
key={r.id}
record={r}
isMobile={false}
returning={returnMutation.isPending}
onReturn={() => setReturnTarget(r)}
sel={sel}
/>
))}
</TableBody>
</Table>
) : (
<div className="space-y-3">
{list.map((r) => (
<Card key={r.id}>
<CardContent className="space-y-1.5 p-4">
<BorrowRow
record={r}
isMobile
returning={returnMutation.isPending}
onReturn={() => setReturnTarget(r)}
sel={sel}
/>
</CardContent>
</Card>
))}
</div>
)
return (
<div>
<h1 className="mb-4 text-xl font-semibold"></h1>
<div className="mb-4">
<Input
placeholder="搜索借阅记录(书名/用户名)..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="max-w-sm"
/>
</div>
{list.length === 0 ? (
<p className="py-16 text-center text-muted-foreground">
{isSearching ? "无匹配记录" : "暂无借阅记录"}
</p>
) : !isMobile ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[110px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((r) => (
<BorrowRow
key={r.id}
record={r}
isMobile={false}
returning={returnMutation.isPending}
onReturn={() => setReturnTarget(r)}
sel={sel}
/>
))}
</TableBody>
</Table>
) : (
<div className="space-y-3">
{list.map((r) => (
<Card key={r.id}>
<CardContent className="space-y-1.5 p-4">
<BorrowRow
record={r}
isMobile
returning={returnMutation.isPending}
onReturn={() => setReturnTarget(r)}
sel={sel}
/>
</CardContent>
</Card>
))}
<form onSubmit={doSearch} className="mb-4 flex gap-2">
<div className="relative flex-1 max-w-sm">
<Input
placeholder="搜索借阅记录(书名/用户名)..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
{isSearching && (
<button
type="button"
onClick={clearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<XIcon className="size-4" />
</button>
)}
</div>
)}
<Button type="submit" size="icon-sm" variant="outline">
<SearchIcon className="size-4" />
</Button>
</form>
{content}
<AlertDialog open={!!returnTarget} onOpenChange={(o) => !o && setReturnTarget(null)}>
<AlertDialogContent>
@@ -200,8 +217,6 @@ function AdminBorrowsSkeleton({ isMobile }: { isMobile: boolean }) {
if (isMobile) {
return (
<div className="space-y-3">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-8 w-full max-w-sm rounded-lg" />
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardContent className="space-y-2 p-4">
@@ -216,14 +231,10 @@ function AdminBorrowsSkeleton({ isMobile }: { isMobile: boolean }) {
)
}
return (
<div>
<Skeleton className="mb-4 h-7 w-24" />
<Skeleton className="mb-4 h-8 w-64 rounded-lg" />
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
)
}
+9 -1
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { getAllBooks } from "@/api/books"
import { getAllBooks, searchBook } from "@/api/books"
import { addBook, deleteBook, updateBook, updateStock } from "@/api/admin/books"
import { getAllBorrows, searchBorrows, returnBook, borrowBook as adminBorrowBook } from "@/api/admin/borrows"
import { getErrorMessage } from "@/lib/errors"
@@ -12,6 +12,14 @@ export function useAdminBooks() {
})
}
export function useAdminSearchBooks(query: string) {
return useQuery({
queryKey: ["admin", "books", "search", query],
queryFn: () => searchBook(query),
enabled: query.length > 0,
})
}
export function useAddBook() {
const qc = useQueryClient()
return useMutation({
+119 -82
View File
@@ -1,96 +1,95 @@
import { Loader2Icon } from "lucide-react"
import { useState, type FormEvent } from "react"
import { Loader2Icon, SearchIcon, XIcon } 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 { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { useBooks, useBorrowBook } from "./hooks"
import { useBooks, useSearchBooks, useBorrowBook } from "./hooks"
import { useAuthStore } from "@/store/authStore"
import { usePlatform } from "@/hooks/usePlatform"
import { useIsMobile } from "@/hooks/useIsMobile"
import { getErrorMessage } from "@/lib/errors"
export default function BooksPage() {
const { data: books, isLoading, error } = useBooks()
const borrow = useBorrowBook()
const token = useAuthStore((s) => s.token)
const platform = usePlatform()
const isMobile = useIsMobile()
const sel = platform === "web" ? ({ "data-selectable": true } as const) : ({} as const)
if (isLoading) return <BooksSkeleton isMobile={isMobile} />
const [searchText, setSearchText] = useState("")
const [submitted, setSubmitted] = useState("")
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">
{getErrorMessage(error)}
</div>
</div>
)
const isSearching = submitted.length > 0
const allQuery = useBooks()
const searchQuery = useSearchBooks(submitted)
const { data, isLoading, error } = isSearching ? searchQuery : allQuery
function doSearch(e: FormEvent) {
e.preventDefault()
setSubmitted(searchText.trim())
}
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>
)
function clearSearch() {
setSearchText("")
setSubmitted("")
}
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>
{token && <TableHead className="w-[120px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{list.map((book) => (
<TableRow key={book.id}>
<TableCell className="font-medium" {...sel}>{book.name}</TableCell>
<TableCell {...sel}>{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>
)
}
const list = data ?? []
return (
<div className="px-4 py-4">
<h1 className="mb-4 text-xl font-semibold"></h1>
<div className="space-y-3">
const searchBar = (
<form onSubmit={doSearch} className="mb-4 flex gap-2">
<div className="relative flex-1 max-w-sm">
<Input
placeholder="搜索书名或作者..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
{isSearching && (
<button
type="button"
onClick={clearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<XIcon className="size-4" />
</button>
)}
</div>
<Button type="submit" size="icon-sm" variant="outline">
<SearchIcon className="size-4" />
</Button>
</form>
)
const content = isLoading ? (
<BooksSkeleton isMobile={isMobile} />
) : error ? (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{getErrorMessage(error)}
</div>
) : list.length === 0 ? (
<p className="py-16 text-center text-muted-foreground">
{isSearching ? "无匹配书目" : "暂无书目"}
</p>
) : !isMobile ? (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
{token && <TableHead className="w-[120px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{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" {...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 && (
<TableRow key={book.id}>
<TableCell className="font-medium" {...sel}>{book.name}</TableCell>
<TableCell {...sel}>{book.author}</TableCell>
<TableCell>{book.stock}</TableCell>
{token && (
<TableCell>
<Button
size="sm"
disabled={book.stock <= 0 || borrow.isPending}
@@ -99,11 +98,53 @@ export default function BooksPage() {
{borrow.isPending && <Loader2Icon className="animate-spin" />}
{book.stock <= 0 ? "暂无库存" : "借阅"}
</Button>
)}
</CardContent>
</Card>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
) : (
<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" {...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 && (
<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>
)
if (isMobile) {
return (
<div className="px-4 py-4">
<h1 className="mb-4 text-xl font-semibold"></h1>
{searchBar}
{content}
</div>
)
}
return (
<div className="mx-auto max-w-[1200px] px-4 py-8">
<h1 className="mb-4 text-xl font-semibold"></h1>
{searchBar}
{content}
</div>
)
}
@@ -111,8 +152,7 @@ export default function BooksPage() {
function BooksSkeleton({ isMobile }: { isMobile: boolean }) {
if (isMobile) {
return (
<div className="space-y-3 px-4 py-4">
<Skeleton className="h-7 w-24" />
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Card key={i}>
<CardContent className="flex items-center justify-between p-4">
@@ -130,13 +170,10 @@ function BooksSkeleton({ isMobile }: { isMobile: boolean }) {
}
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 className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
)
}
+9 -1
View File
@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { getAllBooks } from "@/api/books"
import { getAllBooks, searchBook } from "@/api/books"
import { borrowBookForMe } from "@/api/borrows"
import { toast } from "sonner"
import { getErrorMessage } from "@/lib/errors"
@@ -12,6 +12,14 @@ export function useBooks() {
})
}
export function useSearchBooks(query: string) {
return useQuery({
queryKey: ["books", "search", query],
queryFn: () => searchBook(query),
enabled: query.length > 0,
})
}
export function useBorrowBook() {
const qc = useQueryClient()
return useMutation({
+145 -108
View File
@@ -1,131 +1,172 @@
import { Loader2Icon } from "lucide-react"
import { useState, type FormEvent } from "react"
import { Loader2Icon, SearchIcon, XIcon } 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 { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { useMyBorrows, useReturnBook } from "./hooks"
import { useMyBorrows, useSearchMyBorrows, useReturnBook } from "./hooks"
import { formatDateTime } from "@/lib/formatters"
import { usePlatform } from "@/hooks/usePlatform"
import { useIsMobile } from "@/hooks/useIsMobile"
import { getErrorMessage } from "@/lib/errors"
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 isMobile={isMobile} />
const [searchText, setSearchText] = useState("")
const [submitted, setSubmitted] = useState("")
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">
{getErrorMessage(error)}
</div>
</div>
)
const isSearching = submitted.length > 0
const allQuery = useMyBorrows()
const searchQuery = useSearchMyBorrows(submitted)
const { data, isLoading, error } = isSearching ? searchQuery : allQuery
function doSearch(e: FormEvent) {
e.preventDefault()
setSubmitted(searchText.trim())
}
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>
)
function clearSearch() {
setSearchText("")
setSubmitted("")
}
if (!isMobile) {
const list = data ?? []
const searchBar = (
<form onSubmit={doSearch} className="mb-4 flex gap-2">
<div className="relative flex-1 max-w-sm">
<Input
placeholder="搜索书名..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
{isSearching && (
<button
type="button"
onClick={clearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<XIcon className="size-4" />
</button>
)}
</div>
<Button type="submit" size="icon-sm" variant="outline">
<SearchIcon className="size-4" />
</Button>
</form>
)
const content = isLoading ? (
<MyBorrowsSkeleton isMobile={isMobile} />
) : error ? (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{getErrorMessage(error)}
</div>
) : list.length === 0 ? (
<div className="py-16 text-center text-muted-foreground">
<p className="text-lg">{isSearching ? "无匹配记录" : "暂无借阅记录"}</p>
{!isSearching && <p className="mt-1 text-sm"></p>}
</div>
) : !isMobile ? (
<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 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>
</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"
disabled={returnBook.isPending}
onClick={() => returnBook.mutate(r.id)}
>
{returnBook.isPending && <Loader2Icon className="animate-spin" />}
</Button>
</div>
)}
</CardContent>
</Card>
))}
</div>
)
if (isMobile) {
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" {...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>
{searchBar}
{content}
</div>
)
}
return (
<div className="px-4 py-4">
<div className="mx-auto max-w-[1200px] px-4 py-8">
<h1 className="mb-4 text-xl font-semibold"></h1>
<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>
</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"
disabled={returnBook.isPending}
onClick={() => returnBook.mutate(r.id)}
>
{returnBook.isPending && <Loader2Icon className="animate-spin" />}
</Button>
</div>
)}
</CardContent>
</Card>
))}
</div>
{searchBar}
{content}
</div>
)
}
@@ -133,8 +174,7 @@ export default function MyBorrowsPage() {
function MyBorrowsSkeleton({ isMobile }: { isMobile: boolean }) {
if (isMobile) {
return (
<div className="space-y-3 px-4 py-4">
<Skeleton className="h-7 w-24" />
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Card key={i}>
<CardContent className="space-y-2 p-4">
@@ -150,13 +190,10 @@ function MyBorrowsSkeleton({ isMobile }: { isMobile: boolean }) {
}
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 className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
)
}
+9 -1
View File
@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { getAllMyBorrows, returnBookForMe } from "@/api/borrows"
import { getAllMyBorrows, searchMyBorrows, returnBookForMe } from "@/api/borrows"
import { toast } from "sonner"
import { getErrorMessage } from "@/lib/errors"
@@ -10,6 +10,14 @@ export function useMyBorrows() {
})
}
export function useSearchMyBorrows(query: string) {
return useQuery({
queryKey: ["myBorrows", "search", query],
queryFn: () => searchMyBorrows(query),
enabled: query.length > 0,
})
}
export function useReturnBook() {
const qc = useQueryClient()
return useMutation({
+24
View File
@@ -16,3 +16,27 @@ export function useIsMobile(): boolean {
return mobile
}
export function MobileClass() {
useEffect(() => {
const root = document.documentElement
const mql = window.matchMedia(BP)
function sync() {
if (mql.matches) {
root.classList.add("mobile")
} else {
root.classList.remove("mobile")
}
}
sync()
mql.addEventListener("change", sync)
return () => {
mql.removeEventListener("change", sync)
root.classList.remove("mobile")
}
}, [])
return null
}
+19 -19
View File
@@ -4,18 +4,31 @@ export type Platform = "web" | "desktop" | "mobile"
const PlatformContext = createContext<Platform>("web")
function isTauri(): boolean {
return !!(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__
}
// Default to safe: add no-select immediately, remove only if definitely web
document.documentElement.classList.add("no-select")
if (!isTauri()) {
document.documentElement.classList.remove("no-select")
}
export function PlatformProvider({ children }: { children: ReactNode }) {
const [platform, setPlatform] = useState<Platform>("web")
const [platform, setPlatform] = useState<Platform>(isTauri() ? "desktop" : "web")
useEffect(() => {
if (!isTauri()) return
let cancelled = false
;(async () => {
const p = await detectPlatform()
if (!cancelled) {
setPlatform(p)
if (p !== "web") {
document.documentElement.classList.add("no-select")
try {
const { platform: osPlatform } = await import("@tauri-apps/plugin-os")
const p = osPlatform()
if (!cancelled) {
setPlatform(p === "android" || p === "ios" ? "mobile" : "desktop")
}
} catch {
// stays "desktop"
}
})()
return () => {
@@ -33,16 +46,3 @@ export function PlatformProvider({ children }: { children: ReactNode }) {
export function usePlatform(): Platform {
return useContext(PlatformContext)
}
async function detectPlatform(): Promise<Platform> {
const w = window as unknown as Record<string, unknown>
if (!w.__TAURI_INTERNALS__) return "web"
try {
const { platform } = await import("@tauri-apps/plugin-os")
const p = platform()
return p === "android" || p === "ios" ? "mobile" : "desktop"
} catch {
return "desktop"
}
}
+16 -6
View File
@@ -129,12 +129,22 @@
}
}
.no-select * {
user-select: none;
html.mobile {
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
.no-select input,
.no-select textarea,
.no-select [data-selectable] {
user-select: text;
html.mobile body {
padding-top: var(--safe-top);
padding-bottom: var(--safe-bottom);
}
html.no-select * {
user-select: none !important;
}
html.no-select input,
html.no-select textarea,
html.no-select [data-selectable] {
user-select: text !important;
}
+2
View File
@@ -4,6 +4,7 @@ import { RouterProvider } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Toaster } from "@/components/ui/sonner"
import { PlatformProvider } from "@/hooks/usePlatform"
import { MobileClass } from "@/hooks/useIsMobile"
import { router } from "@/router"
import "./index.css"
@@ -12,6 +13,7 @@ const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<MobileClass />
<PlatformProvider>
<RouterProvider router={router} />
<Toaster />