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:
+1
-1
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
图书管理系统
|
||||
|
||||
@@ -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,40 +33,46 @@ 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 (
|
||||
const list = data ?? []
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
const list = books ?? []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">图书管理</h1>
|
||||
<Button size="sm" onClick={() => setAdd({ open: true })}>
|
||||
<PlusIcon />
|
||||
添加图书
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{list.length === 0 ? (
|
||||
<p className="py-16 text-center text-muted-foreground">暂无书目</p>
|
||||
) : list.length === 0 ? (
|
||||
<p className="py-16 text-center text-muted-foreground">
|
||||
{isSearching ? "无匹配书目" : "暂无书目"}
|
||||
</p>
|
||||
) : !isMobile ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -134,7 +140,41 @@ export default function AdminBooksPage() {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">图书管理</h1>
|
||||
<Button size="sm" onClick={() => setAdd({ open: true })}>
|
||||
<PlusIcon />
|
||||
添加图书
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,45 +29,35 @@ 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 (
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
const list = data ?? []
|
||||
|
||||
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 ? (
|
||||
) : list.length === 0 ? (
|
||||
<p className="py-16 text-center text-muted-foreground">
|
||||
{isSearching ? "无匹配记录" : "暂无借阅记录"}
|
||||
</p>
|
||||
@@ -113,7 +103,34 @@ export default function AdminBorrowsPage() {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-4 text-xl font-semibold">借阅管理</h1>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,48 +1,78 @@
|
||||
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">
|
||||
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())
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
setSearchText("")
|
||||
setSubmitted("")
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<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>
|
||||
</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 (!isMobile) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1200px] px-4 py-8">
|
||||
<h1 className="mb-4 text-xl font-semibold">书目列表</h1>
|
||||
) : list.length === 0 ? (
|
||||
<p className="py-16 text-center text-muted-foreground">
|
||||
{isSearching ? "无匹配书目" : "暂无书目"}
|
||||
</p>
|
||||
) : !isMobile ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -74,13 +104,7 @@ export default function BooksPage() {
|
||||
))}
|
||||
</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}>
|
||||
@@ -104,6 +128,23 @@ export default function BooksPage() {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,49 +1,79 @@
|
||||
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">
|
||||
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())
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
setSearchText("")
|
||||
setSubmitted("")
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1200px] px-4 py-8">
|
||||
<h1 className="mb-4 text-xl font-semibold">我的借阅</h1>
|
||||
) : !isMobile ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -86,13 +116,7 @@ export default function MyBorrowsPage() {
|
||||
))}
|
||||
</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((r) => (
|
||||
<Card key={r.id}>
|
||||
@@ -126,6 +150,23 @@ export default function MyBorrowsPage() {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+18
-18
@@ -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()
|
||||
try {
|
||||
const { platform: osPlatform } = await import("@tauri-apps/plugin-os")
|
||||
const p = osPlatform()
|
||||
if (!cancelled) {
|
||||
setPlatform(p)
|
||||
if (p !== "web") {
|
||||
document.documentElement.classList.add("no-select")
|
||||
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
@@ -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;
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user