|
|
@@ -0,0 +1,378 @@
|
|
|
+import React, { useState, useMemo, useCallback } from 'react';
|
|
|
+import { useQuery } from '@tanstack/react-query';
|
|
|
+import { format } from 'date-fns';
|
|
|
+import { Search, Filter, X, Download } from 'lucide-react';
|
|
|
+import { passengerClient } from '@/client/api';
|
|
|
+import type { InferResponseType } from 'hono/client';
|
|
|
+import { Button } from '@/client/components/ui/button';
|
|
|
+import { Input } from '@/client/components/ui/input';
|
|
|
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
|
|
|
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
|
|
|
+import { Badge } from '@/client/components/ui/badge';
|
|
|
+import { DataTablePagination } from '@/client/admin/components/DataTablePagination';
|
|
|
+import { Skeleton } from '@/client/components/ui/skeleton';
|
|
|
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select';
|
|
|
+import { toast } from 'sonner';
|
|
|
+
|
|
|
+// 使用RPC方式提取类型
|
|
|
+type PassengerResponse = InferResponseType<typeof passengerClient.$get, 200>['data'][0];
|
|
|
+
|
|
|
+export const PassengersPage = () => {
|
|
|
+ const [searchParams, setSearchParams] = useState({
|
|
|
+ page: 1,
|
|
|
+ limit: 10,
|
|
|
+ keyword: ''
|
|
|
+ });
|
|
|
+ const [filters, setFilters] = useState({
|
|
|
+ userId: undefined as number | undefined,
|
|
|
+ idType: undefined as string | undefined
|
|
|
+ });
|
|
|
+ const [showFilters, setShowFilters] = useState(false);
|
|
|
+
|
|
|
+ const { data: passengersData, isLoading } = useQuery({
|
|
|
+ queryKey: ['passengers', searchParams, filters],
|
|
|
+ queryFn: async () => {
|
|
|
+ const filterParams: Record<string, unknown> = {};
|
|
|
+
|
|
|
+ if (filters.userId !== undefined) {
|
|
|
+ filterParams.userId = filters.userId;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (filters.idType !== undefined) {
|
|
|
+ filterParams.idType = filters.idType;
|
|
|
+ }
|
|
|
+
|
|
|
+ const res = await passengerClient.$get({
|
|
|
+ query: {
|
|
|
+ page: searchParams.page,
|
|
|
+ pageSize: searchParams.limit,
|
|
|
+ keyword: searchParams.keyword,
|
|
|
+ filters: Object.keys(filterParams).length > 0 ? JSON.stringify(filterParams) : undefined
|
|
|
+ }
|
|
|
+ });
|
|
|
+ if (res.status !== 200) {
|
|
|
+ throw new Error('获取乘客列表失败');
|
|
|
+ }
|
|
|
+ return await res.json();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const passengers = passengersData?.data || [];
|
|
|
+ const totalCount = passengersData?.pagination?.total || 0;
|
|
|
+
|
|
|
+ // 防抖搜索函数
|
|
|
+ const debounce = (func: Function, delay: number) => {
|
|
|
+ let timeoutId: NodeJS.Timeout;
|
|
|
+ return (...args: any[]) => {
|
|
|
+ clearTimeout(timeoutId);
|
|
|
+ timeoutId = setTimeout(() => func(...args), delay);
|
|
|
+ };
|
|
|
+ };
|
|
|
+
|
|
|
+ // 使用useCallback包装防抖搜索
|
|
|
+ const debouncedSearch = useCallback(
|
|
|
+ debounce((keyword: string) => {
|
|
|
+ setSearchParams(prev => ({ ...prev, keyword, page: 1 }));
|
|
|
+ }, 300),
|
|
|
+ []
|
|
|
+ );
|
|
|
+
|
|
|
+ // 处理搜索输入变化
|
|
|
+ const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
+ const keyword = e.target.value;
|
|
|
+ setSearchParams(prev => ({ ...prev, keyword }));
|
|
|
+ debouncedSearch(keyword);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 处理搜索表单提交
|
|
|
+ const handleSearch = (e: React.FormEvent) => {
|
|
|
+ e.preventDefault();
|
|
|
+ setSearchParams(prev => ({ ...prev, page: 1 }));
|
|
|
+ };
|
|
|
+
|
|
|
+ // 处理分页
|
|
|
+ const handlePageChange = (page: number, limit: number) => {
|
|
|
+ setSearchParams(prev => ({ ...prev, page, limit }));
|
|
|
+ };
|
|
|
+
|
|
|
+ // 处理过滤条件变化
|
|
|
+ const handleFilterChange = (newFilters: Partial<typeof filters>) => {
|
|
|
+ setFilters(prev => ({ ...prev, ...newFilters }));
|
|
|
+ setSearchParams(prev => ({ ...prev, page: 1 }));
|
|
|
+ };
|
|
|
+
|
|
|
+ // 重置所有过滤条件
|
|
|
+ const resetFilters = () => {
|
|
|
+ setFilters({
|
|
|
+ userId: undefined,
|
|
|
+ idType: undefined
|
|
|
+ });
|
|
|
+ setSearchParams(prev => ({ ...prev, page: 1 }));
|
|
|
+ };
|
|
|
+
|
|
|
+ // 检查是否有活跃的过滤条件
|
|
|
+ const hasActiveFilters = useMemo(() => {
|
|
|
+ return filters.userId !== undefined || filters.idType !== undefined;
|
|
|
+ }, [filters]);
|
|
|
+
|
|
|
+ // 导出乘客数据
|
|
|
+ const handleExport = async () => {
|
|
|
+ try {
|
|
|
+ const res = await passengerClient.$get({
|
|
|
+ query: {
|
|
|
+ page: 1,
|
|
|
+ pageSize: totalCount,
|
|
|
+ keyword: searchParams.keyword,
|
|
|
+ filters: hasActiveFilters ? JSON.stringify(filters) : undefined
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (res.status !== 200) {
|
|
|
+ throw new Error('获取导出数据失败');
|
|
|
+ }
|
|
|
+
|
|
|
+ const data = await res.json();
|
|
|
+ const passengers = data.data;
|
|
|
+
|
|
|
+ // 创建CSV内容
|
|
|
+ const headers = ['姓名', '证件类型', '证件号码', '手机号', '默认乘客', '所属用户', '创建时间'];
|
|
|
+ const csvContent = [
|
|
|
+ headers.join(','),
|
|
|
+ ...passengers.map((passenger: PassengerResponse) => [
|
|
|
+ passenger.name,
|
|
|
+ passenger.idType,
|
|
|
+ passenger.idNumber,
|
|
|
+ passenger.phone,
|
|
|
+ passenger.isDefault ? '是' : '否',
|
|
|
+ passenger.user?.username || '未知用户',
|
|
|
+ format(new Date(passenger.createdAt), 'yyyy-MM-dd HH:mm')
|
|
|
+ ].join(','))
|
|
|
+ ].join('\n');
|
|
|
+
|
|
|
+ // 创建Blob并下载
|
|
|
+ const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
|
+ const link = document.createElement('a');
|
|
|
+ const url = URL.createObjectURL(blob);
|
|
|
+ link.setAttribute('href', url);
|
|
|
+ link.setAttribute('download', `乘客信息_${format(new Date(), 'yyyy-MM-dd_HH-mm')}.csv`);
|
|
|
+ link.style.visibility = 'hidden';
|
|
|
+ document.body.appendChild(link);
|
|
|
+ link.click();
|
|
|
+ document.body.removeChild(link);
|
|
|
+
|
|
|
+ toast.success('导出成功');
|
|
|
+ } catch (error) {
|
|
|
+ toast.error('导出失败,请重试');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 渲染表格部分的骨架屏
|
|
|
+ const renderTableSkeleton = () => (
|
|
|
+ <div className="space-y-2">
|
|
|
+ {Array.from({ length: 5 }).map((_, index) => (
|
|
|
+ <div key={index} className="flex space-x-4">
|
|
|
+ <Skeleton className="h-4 flex-1" />
|
|
|
+ <Skeleton className="h-4 flex-1" />
|
|
|
+ <Skeleton className="h-4 flex-1" />
|
|
|
+ <Skeleton className="h-4 flex-1" />
|
|
|
+ <Skeleton className="h-4 flex-1" />
|
|
|
+ <Skeleton className="h-4 flex-1" />
|
|
|
+ <Skeleton className="h-4 w-16" />
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <div className="flex justify-between items-center">
|
|
|
+ <h1 className="text-2xl font-bold">乘客信息管理</h1>
|
|
|
+ <Button onClick={handleExport}>
|
|
|
+ <Download className="mr-2 h-4 w-4" />
|
|
|
+ 导出数据
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle>乘客列表</CardTitle>
|
|
|
+ <CardDescription>
|
|
|
+ 管理系统中的所有乘客信息,共 {totalCount} 位乘客
|
|
|
+ </CardDescription>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <div className="mb-4 space-y-4">
|
|
|
+ <form onSubmit={handleSearch} className="flex gap-2">
|
|
|
+ <div className="relative flex-1 max-w-sm">
|
|
|
+ <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
|
+ <Input
|
|
|
+ placeholder="搜索乘客姓名、手机号或证件号码..."
|
|
|
+ value={searchParams.keyword}
|
|
|
+ onChange={handleSearchChange}
|
|
|
+ className="pl-8"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <Button type="submit" variant="outline">
|
|
|
+ 搜索
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ type="button"
|
|
|
+ variant="outline"
|
|
|
+ onClick={() => setShowFilters(!showFilters)}
|
|
|
+ className="flex items-center gap-2"
|
|
|
+ >
|
|
|
+ <Filter className="h-4 w-4" />
|
|
|
+ 高级筛选
|
|
|
+ {hasActiveFilters && (
|
|
|
+ <Badge variant="secondary" className="ml-1">
|
|
|
+ {Object.values(filters).filter(v => v !== undefined).length}
|
|
|
+ </Badge>
|
|
|
+ )}
|
|
|
+ </Button>
|
|
|
+ {hasActiveFilters && (
|
|
|
+ <Button
|
|
|
+ type="button"
|
|
|
+ variant="ghost"
|
|
|
+ onClick={resetFilters}
|
|
|
+ className="flex items-center gap-2"
|
|
|
+ >
|
|
|
+ <X className="h-4 w-4" />
|
|
|
+ 重置
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
+ </form>
|
|
|
+
|
|
|
+ {showFilters && (
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 border rounded-lg bg-muted/50">
|
|
|
+ {/* 证件类型筛选 */}
|
|
|
+ <div className="space-y-2">
|
|
|
+ <label className="text-sm font-medium">证件类型</label>
|
|
|
+ <Select
|
|
|
+ value={filters.idType || 'all'}
|
|
|
+ onValueChange={(value) =>
|
|
|
+ handleFilterChange({
|
|
|
+ idType: value === 'all' ? undefined : value
|
|
|
+ })
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <SelectTrigger>
|
|
|
+ <SelectValue placeholder="选择证件类型" />
|
|
|
+ </SelectTrigger>
|
|
|
+ <SelectContent>
|
|
|
+ <SelectItem value="all">全部类型</SelectItem>
|
|
|
+ <SelectItem value="身份证">身份证</SelectItem>
|
|
|
+ <SelectItem value="港澳通行证">港澳通行证</SelectItem>
|
|
|
+ <SelectItem value="台湾通行证">台湾通行证</SelectItem>
|
|
|
+ <SelectItem value="护照">护照</SelectItem>
|
|
|
+ <SelectItem value="其他证件">其他证件</SelectItem>
|
|
|
+ </SelectContent>
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 用户筛选 */}
|
|
|
+ <div className="space-y-2">
|
|
|
+ <label className="text-sm font-medium">所属用户</label>
|
|
|
+ <Input
|
|
|
+ type="number"
|
|
|
+ placeholder="输入用户ID"
|
|
|
+ value={filters.userId || ''}
|
|
|
+ onChange={(e) =>
|
|
|
+ handleFilterChange({
|
|
|
+ userId: e.target.value ? parseInt(e.target.value) : undefined
|
|
|
+ })
|
|
|
+ }
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 过滤条件标签 */}
|
|
|
+ {hasActiveFilters && (
|
|
|
+ <div className="flex flex-wrap gap-2">
|
|
|
+ {filters.idType && (
|
|
|
+ <Badge variant="secondary" className="flex items-center gap-1">
|
|
|
+ 证件类型: {filters.idType}
|
|
|
+ <X
|
|
|
+ className="h-3 w-3 cursor-pointer"
|
|
|
+ onClick={() => handleFilterChange({ idType: undefined })}
|
|
|
+ />
|
|
|
+ </Badge>
|
|
|
+ )}
|
|
|
+ {filters.userId !== undefined && (
|
|
|
+ <Badge variant="secondary" className="flex items-center gap-1">
|
|
|
+ 用户ID: {filters.userId}
|
|
|
+ <X
|
|
|
+ className="h-3 w-3 cursor-pointer"
|
|
|
+ onClick={() => handleFilterChange({ userId: undefined })}
|
|
|
+ />
|
|
|
+ </Badge>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="rounded-md border">
|
|
|
+ <Table>
|
|
|
+ <TableHeader>
|
|
|
+ <TableRow>
|
|
|
+ <TableHead>姓名</TableHead>
|
|
|
+ <TableHead>证件类型</TableHead>
|
|
|
+ <TableHead>证件号码</TableHead>
|
|
|
+ <TableHead>手机号</TableHead>
|
|
|
+ <TableHead>默认乘客</TableHead>
|
|
|
+ <TableHead>所属用户</TableHead>
|
|
|
+ <TableHead>创建时间</TableHead>
|
|
|
+ </TableRow>
|
|
|
+ </TableHeader>
|
|
|
+ <TableBody>
|
|
|
+ {isLoading ? (
|
|
|
+ // 显示表格骨架屏
|
|
|
+ <TableRow>
|
|
|
+ <TableCell colSpan={7} className="p-4">
|
|
|
+ {renderTableSkeleton()}
|
|
|
+ </TableCell>
|
|
|
+ </TableRow>
|
|
|
+ ) : (
|
|
|
+ // 显示实际乘客数据
|
|
|
+ passengers.map((passenger) => (
|
|
|
+ <TableRow key={passenger.id}>
|
|
|
+ <TableCell className="font-medium">{passenger.name}</TableCell>
|
|
|
+ <TableCell>
|
|
|
+ <Badge variant="outline">
|
|
|
+ {passenger.idType}
|
|
|
+ </Badge>
|
|
|
+ </TableCell>
|
|
|
+ <TableCell>{passenger.idNumber}</TableCell>
|
|
|
+ <TableCell>{passenger.phone}</TableCell>
|
|
|
+ <TableCell>
|
|
|
+ <Badge
|
|
|
+ variant={passenger.isDefault ? 'default' : 'secondary'}
|
|
|
+ className="capitalize"
|
|
|
+ >
|
|
|
+ {passenger.isDefault ? '是' : '否'}
|
|
|
+ </Badge>
|
|
|
+ </TableCell>
|
|
|
+ <TableCell>
|
|
|
+ {passenger.user?.username || '未知用户'}
|
|
|
+ </TableCell>
|
|
|
+ <TableCell>
|
|
|
+ {format(new Date(passenger.createdAt), 'yyyy-MM-dd HH:mm')}
|
|
|
+ </TableCell>
|
|
|
+ </TableRow>
|
|
|
+ ))
|
|
|
+ )}
|
|
|
+ </TableBody>
|
|
|
+ </Table>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <DataTablePagination
|
|
|
+ currentPage={searchParams.page}
|
|
|
+ totalCount={totalCount}
|
|
|
+ pageSize={searchParams.limit}
|
|
|
+ onPageChange={handlePageChange}
|
|
|
+ />
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|