Files
bookMgr-client/src/features/admin/AdminBorrowsPage.tsx
T
msksbr 9780083476 feat(admin): add book borrowing dialog and root redirect
- Add admin book borrowing dialog with user selection
- Add useAdminBorrowBook mutation with success/error toasts
- Fix sidebar z-index layering on admin layout
- Add RootRedirect component that routes based on admin role
2026-05-24 20:59:18 +08:00

230 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from "react"
import { Loader2Icon } from "lucide-react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useAdminBorrows, useSearchBorrows, useAdminReturnBook } from "./hooks"
import { usePlatform } from "@/hooks/usePlatform"
import { useIsMobile } from "@/hooks/useIsMobile"
import { formatDateTime } from "@/lib/formatters"
import { getErrorMessage } from "@/lib/errors"
import type { BorrowInfoVo } from "@/types/api"
export default function AdminBorrowsPage() {
const platform = usePlatform()
const isMobile = useIsMobile()
const sel = platform === "web" ? ({ "data-selectable": true } as const) : ({} as const)
const [searchText, setSearchText] = useState("")
const [debounced, setDebounced] = useState("")
useEffect(() => {
const t = setTimeout(() => setDebounced(searchText), 300)
return () => clearTimeout(t)
}, [searchText])
const isSearching = debounced.length > 0
const allQuery = useAdminBorrows()
const searchQuery = useSearchBorrows(debounced)
const { data, isLoading, error } = isSearching ? searchQuery : allQuery
const returnMutation = useAdminReturnBook()
const [returnTarget, setReturnTarget] = useState<BorrowInfoVo | null>(null)
if (isLoading) return <AdminBorrowsSkeleton isMobile={isMobile} />
if (error) {
return (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{getErrorMessage(error)}
</div>
)
}
const list = data ?? []
return (
<div>
<h1 className="mb-4 text-xl font-semibold"></h1>
<div className="mb-4">
<Input
placeholder="搜索借阅记录(书名/用户名)..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="max-w-sm"
/>
</div>
{list.length === 0 ? (
<p className="py-16 text-center text-muted-foreground">
{isSearching ? "无匹配记录" : "暂无借阅记录"}
</p>
) : !isMobile ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[110px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((r) => (
<BorrowRow
key={r.id}
record={r}
isMobile={false}
returning={returnMutation.isPending}
onReturn={() => setReturnTarget(r)}
sel={sel}
/>
))}
</TableBody>
</Table>
) : (
<div className="space-y-3">
{list.map((r) => (
<Card key={r.id}>
<CardContent className="space-y-1.5 p-4">
<BorrowRow
record={r}
isMobile
returning={returnMutation.isPending}
onReturn={() => setReturnTarget(r)}
sel={sel}
/>
</CardContent>
</Card>
))}
</div>
)}
<AlertDialog open={!!returnTarget} onOpenChange={(o) => !o && setReturnTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{returnTarget?.userBorrowVo.username} {returnTarget?.bookBorrowVo.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={returnMutation.isPending}></AlertDialogCancel>
<AlertDialogAction
disabled={returnMutation.isPending}
onClick={() => returnMutation.mutateAsync(returnTarget!.id).then(() => setReturnTarget(null))}
>
{returnMutation.isPending && <Loader2Icon className="animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
function BorrowRow({
record,
isMobile,
returning,
onReturn,
sel,
}: {
record: BorrowInfoVo
isMobile: boolean
returning: boolean
onReturn: () => void
sel: Record<string, unknown>
}) {
const statusBadge = (
<Badge variant={record.status === "BORROWED" ? "default" : "secondary"}>
{record.status === "BORROWED" ? "借阅中" : "已归还"}
</Badge>
)
const returnButton = record.status === "BORROWED" && (
<Button size={isMobile ? "sm" : "xs"} variant="outline" disabled={returning} onClick={onReturn}>
{returning && <Loader2Icon className="animate-spin" />}
</Button>
)
if (!isMobile) {
return (
<TableRow>
<TableCell>{record.id}</TableCell>
<TableCell className="font-medium" {...sel}>{record.bookBorrowVo?.name ?? "-"}</TableCell>
<TableCell>{record.userBorrowVo.username}</TableCell>
<TableCell>{formatDateTime(record.borrowTime)}</TableCell>
<TableCell>{record.returnTime ? formatDateTime(record.returnTime) : "-"}</TableCell>
<TableCell>{statusBadge}</TableCell>
<TableCell>{returnButton}</TableCell>
</TableRow>
)
}
return (
<>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">#{record.id}</span>
{statusBadge}
</div>
<div className="font-medium" {...sel}>{record.bookBorrowVo?.name ?? "-"}</div>
<div className="text-sm text-muted-foreground">{record.userBorrowVo.username}</div>
<div className="text-sm text-muted-foreground">
{formatDateTime(record.borrowTime)}
{record.returnTime && ` | 归还:${formatDateTime(record.returnTime)}`}
</div>
{returnButton && <div className="pt-1">{returnButton}</div>}
</>
)
}
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">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-5 w-36" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-40" />
</CardContent>
</Card>
))}
</div>
)
}
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>
)
}