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> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <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> <title>Tauri + React + Typescript</title>
</head> </head>
+2 -2
View File
@@ -59,7 +59,7 @@ export default function AdminLayout() {
return ( return (
<div className="flex min-h-screen flex-col"> <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> <span className="font-heading text-sm font-semibold"></span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{username}</span> <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"> <main className="flex-1 px-4 py-4">
<Outlet /> <Outlet />
</main> </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 }) => ( {navItems.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={linkClass + " flex-1 justify-center py-3"} end> <NavLink key={to} to={to} className={linkClass + " flex-1 justify-center py-3"} end>
<Icon className="size-4" /> <Icon className="size-4" />
+1 -1
View File
@@ -15,7 +15,7 @@ export default function AppLayout() {
return ( return (
<div className="min-h-screen"> <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"> <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 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 { 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -17,7 +17,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog" } 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 { 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"
@@ -33,27 +33,114 @@ interface BorrowDialog { open: boolean; book: Book }
const addInitial: AddDialog = { open: false } const addInitial: AddDialog = { open: false }
export default function AdminBooksPage() { export default function AdminBooksPage() {
const { data: books, isLoading, error } = useAdminBooks()
const platform = usePlatform() const platform = usePlatform()
const isMobile = useIsMobile() const isMobile = useIsMobile()
const sel = platform === "web" ? ({ "data-selectable": true } as const) : ({} as const) 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 [add, setAdd] = useState<AddDialog>(addInitial)
const [edit, setEdit] = useState<EditDialog | null>(null) const [edit, setEdit] = useState<EditDialog | null>(null)
const [stock, setStock] = useState<StockDialog | null>(null) const [stock, setStock] = useState<StockDialog | null>(null)
const [del, setDel] = useState<DeleteDialog>({ open: false, book: null }) const [del, setDel] = useState<DeleteDialog>({ open: false, book: null })
const [borrow, setBorrow] = useState<BorrowDialog | null>(null) const [borrow, setBorrow] = useState<BorrowDialog | null>(null)
if (isLoading) return <AdminBooksSkeleton isMobile={isMobile} /> const list = data ?? []
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 = 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 ( return (
<div> <div>
@@ -65,76 +152,29 @@ export default function AdminBooksPage() {
</Button> </Button>
</div> </div>
{list.length === 0 ? ( <form onSubmit={doSearch} className="mb-4 flex gap-2">
<p className="py-16 text-center text-muted-foreground"></p> <div className="relative flex-1 max-w-sm">
) : !isMobile ? ( <Input
<Table> placeholder="搜索书名或作者..."
<TableHeader> value={searchText}
<TableRow> onChange={(e) => setSearchText(e.target.value)}
<TableHead className="w-[60px]">ID</TableHead> />
<TableHead></TableHead> {isSearching && (
<TableHead></TableHead> <button
<TableHead className="w-[80px]"></TableHead> type="button"
<TableHead className="w-[200px]"></TableHead> onClick={clearSearch}
</TableRow> className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
</TableHeader> >
<TableBody> <XIcon className="size-4" />
{list.map((b) => ( </button>
<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> </div>
)} <Button type="submit" size="icon-sm" variant="outline">
<SearchIcon className="size-4" />
</Button>
</form>
{content}
<AddBookDialog open={add.open} onClose={() => setAdd(addInitial)} /> <AddBookDialog open={add.open} onClose={() => setAdd(addInitial)} />
{edit && <EditBookDialog book={edit.book} open onClose={() => setEdit(null)} />} {edit && <EditBookDialog book={edit.book} open onClose={() => setEdit(null)} />}
@@ -356,10 +396,6 @@ function AdminBooksSkeleton({ isMobile }: { isMobile: boolean }) {
if (isMobile) { if (isMobile) {
return ( return (
<div className="space-y-3"> <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) => ( {Array.from({ length: 4 }).map((_, i) => (
<Card key={i}> <Card key={i}>
<CardContent className="space-y-2 p-4"> <CardContent className="space-y-2 p-4">
@@ -378,16 +414,10 @@ function AdminBooksSkeleton({ isMobile }: { isMobile: boolean }) {
) )
} }
return ( return (
<div> <div className="space-y-2">
<div className="mb-4 flex items-center justify-between"> {Array.from({ length: 6 }).map((_, i) => (
<Skeleton className="h-7 w-24" /> <Skeleton key={i} className="h-10 w-full" />
<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> </div>
) )
} }
+95 -84
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react" import { useState, type FormEvent } from "react"
import { Loader2Icon } from "lucide-react" import { Loader2Icon, SearchIcon, XIcon } from "lucide-react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button" 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 sel = platform === "web" ? ({ "data-selectable": true } as const) : ({} as const)
const [searchText, setSearchText] = useState("") const [searchText, setSearchText] = useState("")
const [debounced, setDebounced] = useState("") const [submitted, setSubmitted] = useState("")
useEffect(() => { const isSearching = submitted.length > 0
const t = setTimeout(() => setDebounced(searchText), 300)
return () => clearTimeout(t)
}, [searchText])
const isSearching = debounced.length > 0
const allQuery = useAdminBorrows() const allQuery = useAdminBorrows()
const searchQuery = useSearchBorrows(debounced) const searchQuery = useSearchBorrows(submitted)
const { data, isLoading, error } = isSearching ? searchQuery : allQuery const { data, isLoading, error } = isSearching ? searchQuery : allQuery
function doSearch(e: FormEvent) {
e.preventDefault()
setSubmitted(searchText.trim())
}
function clearSearch() {
setSearchText("")
setSubmitted("")
}
const returnMutation = useAdminReturnBook() const returnMutation = useAdminReturnBook()
const [returnTarget, setReturnTarget] = useState<BorrowInfoVo | null>(null) 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 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 ( return (
<div> <div>
<h1 className="mb-4 text-xl font-semibold"></h1> <h1 className="mb-4 text-xl font-semibold"></h1>
<div className="mb-4"> <form onSubmit={doSearch} className="mb-4 flex gap-2">
<Input <div className="relative flex-1 max-w-sm">
placeholder="搜索借阅记录(书名/用户名)..." <Input
value={searchText} placeholder="搜索借阅记录(书名/用户名)..."
onChange={(e) => setSearchText(e.target.value)} value={searchText}
className="max-w-sm" onChange={(e) => setSearchText(e.target.value)}
/> />
</div> {isSearching && (
<button
{list.length === 0 ? ( type="button"
<p className="py-16 text-center text-muted-foreground"> onClick={clearSearch}
{isSearching ? "无匹配记录" : "暂无借阅记录"} className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
</p> >
) : !isMobile ? ( <XIcon className="size-4" />
<Table> </button>
<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> </div>
)} <Button type="submit" size="icon-sm" variant="outline">
<SearchIcon className="size-4" />
</Button>
</form>
{content}
<AlertDialog open={!!returnTarget} onOpenChange={(o) => !o && setReturnTarget(null)}> <AlertDialog open={!!returnTarget} onOpenChange={(o) => !o && setReturnTarget(null)}>
<AlertDialogContent> <AlertDialogContent>
@@ -200,8 +217,6 @@ function AdminBorrowsSkeleton({ isMobile }: { isMobile: boolean }) {
if (isMobile) { if (isMobile) {
return ( return (
<div className="space-y-3"> <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) => ( {Array.from({ length: 4 }).map((_, i) => (
<Card key={i}> <Card key={i}>
<CardContent className="space-y-2 p-4"> <CardContent className="space-y-2 p-4">
@@ -216,14 +231,10 @@ function AdminBorrowsSkeleton({ isMobile }: { isMobile: boolean }) {
) )
} }
return ( return (
<div> <div className="space-y-2">
<Skeleton className="mb-4 h-7 w-24" /> {Array.from({ length: 5 }).map((_, i) => (
<Skeleton className="mb-4 h-8 w-64 rounded-lg" /> <Skeleton key={i} className="h-10 w-full" />
<div className="space-y-2"> ))}
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</div> </div>
) )
} }
+9 -1
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner" 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 { 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" 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() { export function useAddBook() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { useBooks, useBorrowBook } from "./hooks" import { useBooks, useSearchBooks, 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" import { getErrorMessage } from "@/lib/errors"
export default function BooksPage() { export default function BooksPage() {
const { data: books, isLoading, error } = useBooks()
const borrow = useBorrowBook() const borrow = useBorrowBook()
const token = useAuthStore((s) => s.token) const token = useAuthStore((s) => s.token)
const platform = usePlatform() const platform = usePlatform()
const isMobile = useIsMobile() const isMobile = useIsMobile()
const sel = platform === "web" ? ({ "data-selectable": true } as const) : ({} as const) 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) { const isSearching = submitted.length > 0
return ( const allQuery = useBooks()
<div className="mx-auto max-w-[1200px] px-4 py-8"> const searchQuery = useSearchBooks(submitted)
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"> const { data, isLoading, error } = isSearching ? searchQuery : allQuery
{getErrorMessage(error)}
</div> function doSearch(e: FormEvent) {
</div> e.preventDefault()
) setSubmitted(searchText.trim())
} }
const list = books ?? [] function clearSearch() {
setSearchText("")
if (list.length === 0) { setSubmitted("")
return (
<div className="mx-auto max-w-[1200px] px-4 py-16 text-center text-muted-foreground">
</div>
)
} }
if (!isMobile) { const list = data ?? []
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>
)
}
return ( const searchBar = (
<div className="px-4 py-4"> <form onSubmit={doSearch} className="mb-4 flex gap-2">
<h1 className="mb-4 text-xl font-semibold"></h1> <div className="relative flex-1 max-w-sm">
<div className="space-y-3"> <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) => ( {list.map((book) => (
<Card key={book.id}> <TableRow key={book.id}>
<CardContent className="flex items-center justify-between p-4"> <TableCell className="font-medium" {...sel}>{book.name}</TableCell>
<div className="space-y-0.5"> <TableCell {...sel}>{book.author}</TableCell>
<div className="font-medium" {...sel}>{book.name}</div> <TableCell>{book.stock}</TableCell>
<div className="text-sm text-muted-foreground" {...sel}>{book.author}</div> {token && (
<div className="text-sm text-muted-foreground">{book.stock}</div> <TableCell>
</div>
{token && (
<Button <Button
size="sm" size="sm"
disabled={book.stock <= 0 || borrow.isPending} disabled={book.stock <= 0 || borrow.isPending}
@@ -99,11 +98,53 @@ export default function BooksPage() {
{borrow.isPending && <Loader2Icon className="animate-spin" />} {borrow.isPending && <Loader2Icon className="animate-spin" />}
{book.stock <= 0 ? "暂无库存" : "借阅"} {book.stock <= 0 ? "暂无库存" : "借阅"}
</Button> </Button>
)} </TableCell>
</CardContent> )}
</Card> </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> </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> </div>
) )
} }
@@ -111,8 +152,7 @@ export default function BooksPage() {
function BooksSkeleton({ isMobile }: { isMobile: boolean }) { function BooksSkeleton({ isMobile }: { isMobile: boolean }) {
if (isMobile) { if (isMobile) {
return ( return (
<div className="space-y-3 px-4 py-4"> <div className="space-y-3">
<Skeleton className="h-7 w-24" />
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<Card key={i}> <Card key={i}>
<CardContent className="flex items-center justify-between p-4"> <CardContent className="flex items-center justify-between p-4">
@@ -130,13 +170,10 @@ function BooksSkeleton({ isMobile }: { isMobile: boolean }) {
} }
return ( return (
<div className="mx-auto max-w-[1200px] px-4 py-8"> <div className="space-y-2">
<Skeleton className="mb-4 h-7 w-24" /> {Array.from({ length: 8 }).map((_, i) => (
<div className="space-y-2"> <Skeleton key={i} className="h-10 w-full" />
{Array.from({ length: 8 }).map((_, i) => ( ))}
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</div> </div>
) )
} }
+9 -1
View File
@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" 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 { borrowBookForMe } from "@/api/borrows"
import { toast } from "sonner" import { toast } from "sonner"
import { getErrorMessage } from "@/lib/errors" 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() { export function useBorrowBook() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { useMyBorrows, useReturnBook } from "./hooks" import { useMyBorrows, useSearchMyBorrows, 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" import { getErrorMessage } from "@/lib/errors"
export default function MyBorrowsPage() { export default function MyBorrowsPage() {
const { data: records, isLoading, error } = useMyBorrows()
const returnBook = useReturnBook() const returnBook = useReturnBook()
const platform = usePlatform() const platform = usePlatform()
const isMobile = useIsMobile() const isMobile = useIsMobile()
const sel = platform === "web" ? ({ "data-selectable": true } as const) : ({} as const) 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) { const isSearching = submitted.length > 0
return ( const allQuery = useMyBorrows()
<div className="mx-auto max-w-[1200px] px-4 py-8"> const searchQuery = useSearchMyBorrows(submitted)
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"> const { data, isLoading, error } = isSearching ? searchQuery : allQuery
{getErrorMessage(error)}
</div> function doSearch(e: FormEvent) {
</div> e.preventDefault()
) setSubmitted(searchText.trim())
} }
const list = records ?? [] function clearSearch() {
setSearchText("")
if (list.length === 0) { setSubmitted("")
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>
)
} }
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 ( 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> <h1 className="mb-4 text-xl font-semibold"></h1>
<Table> {searchBar}
<TableHeader> {content}
<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> </div>
) )
} }
return ( 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> <h1 className="mb-4 text-xl font-semibold"></h1>
<div className="space-y-3"> {searchBar}
{list.map((r) => ( {content}
<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>
</div> </div>
) )
} }
@@ -133,8 +174,7 @@ export default function MyBorrowsPage() {
function MyBorrowsSkeleton({ isMobile }: { isMobile: boolean }) { function MyBorrowsSkeleton({ isMobile }: { isMobile: boolean }) {
if (isMobile) { if (isMobile) {
return ( return (
<div className="space-y-3 px-4 py-4"> <div className="space-y-3">
<Skeleton className="h-7 w-24" />
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<Card key={i}> <Card key={i}>
<CardContent className="space-y-2 p-4"> <CardContent className="space-y-2 p-4">
@@ -150,13 +190,10 @@ function MyBorrowsSkeleton({ isMobile }: { isMobile: boolean }) {
} }
return ( return (
<div className="mx-auto max-w-[1200px] px-4 py-8"> <div className="space-y-2">
<Skeleton className="mb-4 h-7 w-24" /> {Array.from({ length: 5 }).map((_, i) => (
<div className="space-y-2"> <Skeleton key={i} className="h-10 w-full" />
{Array.from({ length: 5 }).map((_, i) => ( ))}
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</div> </div>
) )
} }
+9 -1
View File
@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" 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 { toast } from "sonner"
import { getErrorMessage } from "@/lib/errors" 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() { export function useReturnBook() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
+24
View File
@@ -16,3 +16,27 @@ export function useIsMobile(): boolean {
return mobile 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") 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 }) { export function PlatformProvider({ children }: { children: ReactNode }) {
const [platform, setPlatform] = useState<Platform>("web") const [platform, setPlatform] = useState<Platform>(isTauri() ? "desktop" : "web")
useEffect(() => { useEffect(() => {
if (!isTauri()) return
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
const p = await detectPlatform() try {
if (!cancelled) { const { platform: osPlatform } = await import("@tauri-apps/plugin-os")
setPlatform(p) const p = osPlatform()
if (p !== "web") { if (!cancelled) {
document.documentElement.classList.add("no-select") setPlatform(p === "android" || p === "ios" ? "mobile" : "desktop")
} }
} catch {
// stays "desktop"
} }
})() })()
return () => { return () => {
@@ -33,16 +46,3 @@ export function PlatformProvider({ children }: { children: ReactNode }) {
export function usePlatform(): Platform { export function usePlatform(): Platform {
return useContext(PlatformContext) 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 * { html.mobile {
user-select: none; --safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
} }
.no-select input, html.mobile body {
.no-select textarea, padding-top: var(--safe-top);
.no-select [data-selectable] { padding-bottom: var(--safe-bottom);
user-select: text; }
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Toaster } from "@/components/ui/sonner" import { Toaster } from "@/components/ui/sonner"
import { PlatformProvider } from "@/hooks/usePlatform" import { PlatformProvider } from "@/hooks/usePlatform"
import { MobileClass } from "@/hooks/useIsMobile"
import { router } from "@/router" import { router } from "@/router"
import "./index.css" import "./index.css"
@@ -12,6 +13,7 @@ const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MobileClass />
<PlatformProvider> <PlatformProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
<Toaster /> <Toaster />