9780083476
- 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
230 lines
7.5 KiB
TypeScript
230 lines
7.5 KiB
TypeScript
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>
|
||
)
|
||
}
|