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>
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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">
|
||||||
图书管理系统
|
图书管理系统
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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 />
|
||||||
|
|||||||
Reference in New Issue
Block a user