|
@@ -4,13 +4,21 @@ import { Button } from '@/client/components/ui/button';
|
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
|
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
|
|
|
import { DataTablePagination } from '../components/DataTablePagination';
|
|
import { DataTablePagination } from '../components/DataTablePagination';
|
|
|
-import { Plus, Edit, Trash2, MapPin, DollarSign, Users } from 'lucide-react';
|
|
|
|
|
-import { useState } from 'react';
|
|
|
|
|
|
|
+import { Plus, Edit, Trash2, MapPin, DollarSign, Users, Search, Filter, Power } from 'lucide-react';
|
|
|
|
|
+import { useState, useCallback } from 'react';
|
|
|
import { routeClient } from '@/client/api';
|
|
import { routeClient } from '@/client/api';
|
|
|
-import type { InferResponseType } from 'hono/client';
|
|
|
|
|
|
|
+import type { InferResponseType, InferRequestType } from 'hono/client';
|
|
|
|
|
+import { Input } from '@/client/components/ui/input';
|
|
|
|
|
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select';
|
|
|
|
|
+import { Badge } from '@/client/components/ui/badge';
|
|
|
|
|
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
|
|
|
|
|
+import { RouteForm } from '../components/RouteForm';
|
|
|
|
|
+import type { CreateRouteInput, UpdateRouteInput } from '@/server/modules/routes/route.schema';
|
|
|
|
|
|
|
|
// 类型提取规范
|
|
// 类型提取规范
|
|
|
type RouteResponse = InferResponseType<typeof routeClient.$get, 200>['data'][0];
|
|
type RouteResponse = InferResponseType<typeof routeClient.$get, 200>['data'][0];
|
|
|
|
|
+type CreateRouteRequest = InferRequestType<typeof routeClient.$post>['json'];
|
|
|
|
|
+type UpdateRouteRequest = InferRequestType<typeof routeClient[':id']['$put']>['json'];
|
|
|
|
|
|
|
|
// 统一操作处理函数
|
|
// 统一操作处理函数
|
|
|
const handleOperation = async (operation: () => Promise<any>) => {
|
|
const handleOperation = async (operation: () => Promise<any>) => {
|
|
@@ -25,20 +33,52 @@ const handleOperation = async (operation: () => Promise<any>) => {
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+// 防抖搜索函数
|
|
|
|
|
+const debounce = (func: Function, delay: number) => {
|
|
|
|
|
+ let timeoutId: NodeJS.Timeout;
|
|
|
|
|
+ return (...args: any[]) => {
|
|
|
|
|
+ clearTimeout(timeoutId);
|
|
|
|
|
+ timeoutId = setTimeout(() => func(...args), delay);
|
|
|
|
|
+ };
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
export const RoutesPage: React.FC = () => {
|
|
export const RoutesPage: React.FC = () => {
|
|
|
const queryClient = useQueryClient();
|
|
const queryClient = useQueryClient();
|
|
|
const [page, setPage] = useState(1);
|
|
const [page, setPage] = useState(1);
|
|
|
const [pageSize, setPageSize] = useState(20);
|
|
const [pageSize, setPageSize] = useState(20);
|
|
|
|
|
+ const [keyword, setKeyword] = useState('');
|
|
|
|
|
+ const [vehicleTypeFilter, setVehicleTypeFilter] = useState<string>('all');
|
|
|
|
|
+ const [isFormOpen, setIsFormOpen] = useState(false);
|
|
|
|
|
+ const [editingRoute, setEditingRoute] = useState<RouteResponse | null>(null);
|
|
|
|
|
+
|
|
|
|
|
+ // 防抖搜索
|
|
|
|
|
+ const debouncedSearch = useCallback(
|
|
|
|
|
+ debounce((searchKeyword: string) => {
|
|
|
|
|
+ setKeyword(searchKeyword);
|
|
|
|
|
+ setPage(1); // 搜索时重置到第一页
|
|
|
|
|
+ }, 300),
|
|
|
|
|
+ []
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
// 获取路线列表 - 使用RPC客户端
|
|
// 获取路线列表 - 使用RPC客户端
|
|
|
const { data, isLoading, error } = useQuery({
|
|
const { data, isLoading, error } = useQuery({
|
|
|
- queryKey: ['routes', page, pageSize],
|
|
|
|
|
|
|
+ queryKey: ['routes', page, pageSize, keyword, vehicleTypeFilter],
|
|
|
queryFn: async () => {
|
|
queryFn: async () => {
|
|
|
|
|
+ const query: any = {
|
|
|
|
|
+ page,
|
|
|
|
|
+ pageSize
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if (keyword) {
|
|
|
|
|
+ query.keyword = keyword;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (vehicleTypeFilter !== 'all') {
|
|
|
|
|
+ query.filters = JSON.stringify({ vehicleType: vehicleTypeFilter });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
const res = await routeClient.$get({
|
|
const res = await routeClient.$get({
|
|
|
- query: {
|
|
|
|
|
- page,
|
|
|
|
|
- pageSize
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ query
|
|
|
});
|
|
});
|
|
|
if (res.status !== 200) throw new Error('获取路线列表失败');
|
|
if (res.status !== 200) throw new Error('获取路线列表失败');
|
|
|
return await res.json();
|
|
return await res.json();
|
|
@@ -46,6 +86,54 @@ export const RoutesPage: React.FC = () => {
|
|
|
staleTime: 5 * 60 * 1000, // 5分钟缓存
|
|
staleTime: 5 * 60 * 1000, // 5分钟缓存
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // 创建路线 - 使用RPC客户端
|
|
|
|
|
+ const createMutation = useMutation({
|
|
|
|
|
+ mutationFn: async (data: CreateRouteRequest) => {
|
|
|
|
|
+ await handleOperation(async () => {
|
|
|
|
|
+ const res = await routeClient.$post({ json: data });
|
|
|
|
|
+ if (res.status !== 201) throw new Error('创建路线失败');
|
|
|
|
|
+ });
|
|
|
|
|
+ },
|
|
|
|
|
+ onSuccess: () => {
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['routes'] });
|
|
|
|
|
+ setIsFormOpen(false);
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 更新路线 - 使用RPC客户端
|
|
|
|
|
+ const updateMutation = useMutation({
|
|
|
|
|
+ mutationFn: async ({ id, data }: { id: number; data: UpdateRouteRequest }) => {
|
|
|
|
|
+ await handleOperation(async () => {
|
|
|
|
|
+ const res = await routeClient[':id'].$put({
|
|
|
|
|
+ param: { id },
|
|
|
|
|
+ json: data
|
|
|
|
|
+ });
|
|
|
|
|
+ if (res.status !== 200) throw new Error('更新路线失败');
|
|
|
|
|
+ });
|
|
|
|
|
+ },
|
|
|
|
|
+ onSuccess: () => {
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['routes'] });
|
|
|
|
|
+ setIsFormOpen(false);
|
|
|
|
|
+ setEditingRoute(null);
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 启用/禁用路线 - 使用RPC客户端
|
|
|
|
|
+ const toggleStatusMutation = useMutation({
|
|
|
|
|
+ mutationFn: async ({ id, isDisabled }: { id: number; isDisabled: number }) => {
|
|
|
|
|
+ await handleOperation(async () => {
|
|
|
|
|
+ const res = await routeClient[':id'].$put({
|
|
|
|
|
+ param: { id },
|
|
|
|
|
+ json: { isDisabled }
|
|
|
|
|
+ });
|
|
|
|
|
+ if (res.status !== 200) throw new Error('更新路线状态失败');
|
|
|
|
|
+ });
|
|
|
|
|
+ },
|
|
|
|
|
+ onSuccess: () => {
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['routes'] });
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
// 删除路线 - 使用RPC客户端
|
|
// 删除路线 - 使用RPC客户端
|
|
|
const deleteMutation = useMutation({
|
|
const deleteMutation = useMutation({
|
|
|
mutationFn: async (id: number) => {
|
|
mutationFn: async (id: number) => {
|
|
@@ -61,6 +149,49 @@ export const RoutesPage: React.FC = () => {
|
|
|
},
|
|
},
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // 处理表单提交
|
|
|
|
|
+ const handleFormSubmit = async (data: CreateRouteInput | UpdateRouteInput) => {
|
|
|
|
|
+ if (editingRoute) {
|
|
|
|
|
+ await updateMutation.mutateAsync({
|
|
|
|
|
+ id: editingRoute.id,
|
|
|
|
|
+ data: data as UpdateRouteRequest
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await createMutation.mutateAsync(data as CreateRouteRequest);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 打开创建表单
|
|
|
|
|
+ const handleCreate = () => {
|
|
|
|
|
+ setEditingRoute(null);
|
|
|
|
|
+ setIsFormOpen(true);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 打开编辑表单
|
|
|
|
|
+ const handleEdit = (route: RouteResponse) => {
|
|
|
|
|
+ setEditingRoute(route);
|
|
|
|
|
+ setIsFormOpen(true);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 关闭表单
|
|
|
|
|
+ const handleFormClose = () => {
|
|
|
|
|
+ setIsFormOpen(false);
|
|
|
|
|
+ setEditingRoute(null);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 切换路线状态
|
|
|
|
|
+ const handleToggleStatus = (route: RouteResponse) => {
|
|
|
|
|
+ const newStatus = route.isDisabled === 0 ? 1 : 0;
|
|
|
|
|
+ const statusText = newStatus === 0 ? '启用' : '禁用';
|
|
|
|
|
+
|
|
|
|
|
+ if (confirm(`确定要${statusText}这条路线吗?`)) {
|
|
|
|
|
+ toggleStatusMutation.mutate({
|
|
|
|
|
+ id: route.id,
|
|
|
|
|
+ isDisabled: newStatus
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
|
|
|
if (error) {
|
|
if (error) {
|
|
|
return (
|
|
return (
|
|
@@ -85,7 +216,7 @@ export const RoutesPage: React.FC = () => {
|
|
|
管理旅行路线,包括出发地、目的地、车型和价格等信息
|
|
管理旅行路线,包括出发地、目的地、车型和价格等信息
|
|
|
</p>
|
|
</p>
|
|
|
</div>
|
|
</div>
|
|
|
- <Button>
|
|
|
|
|
|
|
+ <Button onClick={handleCreate}>
|
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
|
新建路线
|
|
新建路线
|
|
|
</Button>
|
|
</Button>
|
|
@@ -93,12 +224,72 @@ export const RoutesPage: React.FC = () => {
|
|
|
|
|
|
|
|
<Card>
|
|
<Card>
|
|
|
<CardHeader>
|
|
<CardHeader>
|
|
|
- <CardTitle>路线列表</CardTitle>
|
|
|
|
|
- <CardDescription>
|
|
|
|
|
- 当前共有 {data?.pagination.total || 0} 条路线
|
|
|
|
|
- </CardDescription>
|
|
|
|
|
|
|
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <CardTitle>路线列表</CardTitle>
|
|
|
|
|
+ <CardDescription>
|
|
|
|
|
+ 当前共有 {data?.pagination.total || 0} 条路线
|
|
|
|
|
+ </CardDescription>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex gap-2">
|
|
|
|
|
+ {/* 搜索框 */}
|
|
|
|
|
+ <div className="relative w-full sm:w-64">
|
|
|
|
|
+ <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
|
|
|
+ <Input
|
|
|
|
|
+ placeholder="搜索路线名称、地点或车型..."
|
|
|
|
|
+ className="pl-8"
|
|
|
|
|
+ onChange={(e) => debouncedSearch(e.target.value)}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {/* 车型筛选 */}
|
|
|
|
|
+ <Select value={vehicleTypeFilter} onValueChange={setVehicleTypeFilter}>
|
|
|
|
|
+ <SelectTrigger className="w-32">
|
|
|
|
|
+ <Filter className="h-4 w-4 mr-2" />
|
|
|
|
|
+ <SelectValue placeholder="车型" />
|
|
|
|
|
+ </SelectTrigger>
|
|
|
|
|
+ <SelectContent>
|
|
|
|
|
+ <SelectItem value="all">全部车型</SelectItem>
|
|
|
|
|
+ <SelectItem value="bus">大巴</SelectItem>
|
|
|
|
|
+ <SelectItem value="van">中巴</SelectItem>
|
|
|
|
|
+ <SelectItem value="car">小车</SelectItem>
|
|
|
|
|
+ </SelectContent>
|
|
|
|
|
+ </Select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
</CardHeader>
|
|
</CardHeader>
|
|
|
<CardContent>
|
|
<CardContent>
|
|
|
|
|
+ {/* 筛选标签 */}
|
|
|
|
|
+ {(keyword || vehicleTypeFilter !== 'all') && (
|
|
|
|
|
+ <div className="flex flex-wrap gap-2 mb-4">
|
|
|
|
|
+ {keyword && (
|
|
|
|
|
+ <Badge variant="secondary" className="flex items-center gap-1">
|
|
|
|
|
+ 搜索: {keyword}
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ setKeyword('');
|
|
|
|
|
+ const input = document.querySelector('input[placeholder="搜索路线名称、地点或车型..."]') as HTMLInputElement;
|
|
|
|
|
+ if (input) input.value = '';
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="ml-1 hover:text-red-500"
|
|
|
|
|
+ >
|
|
|
|
|
+ ×
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </Badge>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {vehicleTypeFilter !== 'all' && (
|
|
|
|
|
+ <Badge variant="secondary" className="flex items-center gap-1">
|
|
|
|
|
+ 车型: {vehicleTypeFilter === 'bus' ? '大巴' : vehicleTypeFilter === 'van' ? '中巴' : '小车'}
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => setVehicleTypeFilter('all')}
|
|
|
|
|
+ className="ml-1 hover:text-red-500"
|
|
|
|
|
+ >
|
|
|
|
|
+ ×
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </Badge>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
<div className="rounded-md border">
|
|
<div className="rounded-md border">
|
|
|
<Table>
|
|
<Table>
|
|
|
<TableHeader>
|
|
<TableHeader>
|
|
@@ -165,13 +356,19 @@ export const RoutesPage: React.FC = () => {
|
|
|
</TableCell>
|
|
</TableCell>
|
|
|
<TableCell className="text-right">
|
|
<TableCell className="text-right">
|
|
|
<div className="flex justify-end gap-2">
|
|
<div className="flex justify-end gap-2">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant={route.isDisabled === 0 ? "outline" : "default"}
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ onClick={() => handleToggleStatus(route)}
|
|
|
|
|
+ disabled={toggleStatusMutation.isPending}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Power className="h-4 w-4 mr-1" />
|
|
|
|
|
+ {route.isDisabled === 0 ? '禁用' : '启用'}
|
|
|
|
|
+ </Button>
|
|
|
<Button
|
|
<Button
|
|
|
variant="outline"
|
|
variant="outline"
|
|
|
size="sm"
|
|
size="sm"
|
|
|
- onClick={() => {
|
|
|
|
|
- // TODO: 编辑路线
|
|
|
|
|
- console.log('编辑路线:', route.id);
|
|
|
|
|
- }}
|
|
|
|
|
|
|
+ onClick={() => handleEdit(route)}
|
|
|
>
|
|
>
|
|
|
<Edit className="h-4 w-4" />
|
|
<Edit className="h-4 w-4" />
|
|
|
</Button>
|
|
</Button>
|
|
@@ -213,6 +410,41 @@ export const RoutesPage: React.FC = () => {
|
|
|
)}
|
|
)}
|
|
|
</CardContent>
|
|
</CardContent>
|
|
|
</Card>
|
|
</Card>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 路线表单对话框 */}
|
|
|
|
|
+ <Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
|
|
|
|
|
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
|
|
|
|
+ <DialogHeader>
|
|
|
|
|
+ <DialogTitle>
|
|
|
|
|
+ {editingRoute ? '编辑路线' : '创建路线'}
|
|
|
|
|
+ </DialogTitle>
|
|
|
|
|
+ <DialogDescription>
|
|
|
|
|
+ {editingRoute ? '修改路线信息' : '创建新的旅行路线'}
|
|
|
|
|
+ </DialogDescription>
|
|
|
|
|
+ </DialogHeader>
|
|
|
|
|
+ <RouteForm
|
|
|
|
|
+ initialData={editingRoute ? {
|
|
|
|
|
+ id: editingRoute.id,
|
|
|
|
|
+ name: editingRoute.name,
|
|
|
|
|
+ description: editingRoute.description,
|
|
|
|
|
+ startPoint: editingRoute.startPoint,
|
|
|
|
|
+ endPoint: editingRoute.endPoint,
|
|
|
|
|
+ pickupPoint: editingRoute.pickupPoint,
|
|
|
|
|
+ dropoffPoint: editingRoute.dropoffPoint,
|
|
|
|
|
+ departureTime: editingRoute.departureTime,
|
|
|
|
|
+ vehicleType: editingRoute.vehicleType,
|
|
|
|
|
+ price: editingRoute.price,
|
|
|
|
|
+ seatCount: editingRoute.seatCount,
|
|
|
|
|
+ availableSeats: editingRoute.availableSeats,
|
|
|
|
|
+ activityId: editingRoute.activityId,
|
|
|
|
|
+ isDisabled: editingRoute.isDisabled
|
|
|
|
|
+ } : undefined}
|
|
|
|
|
+ onSubmit={handleFormSubmit}
|
|
|
|
|
+ onCancel={handleFormClose}
|
|
|
|
|
+ isLoading={createMutation.isPending || updateMutation.isPending}
|
|
|
|
|
+ />
|
|
|
|
|
+ </DialogContent>
|
|
|
|
|
+ </Dialog>
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
};
|
|
};
|