|
|
@@ -0,0 +1,474 @@
|
|
|
+import React from 'react';
|
|
|
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
+import { Button } from '@/client/components/ui/button';
|
|
|
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
|
|
|
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
|
|
|
+import { DataTablePagination } from '../components/DataTablePagination';
|
|
|
+import { Plus, Edit, Trash2, Search, Filter, Power, MapPin } from 'lucide-react';
|
|
|
+import { useState, useCallback } from 'react';
|
|
|
+import { areaClient } from '@/client/api';
|
|
|
+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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/client/components/ui/alert-dialog';
|
|
|
+import { AreaForm } from '../components/AreaForm';
|
|
|
+import type { CreateAreaInput, UpdateAreaInput } from '@/server/modules/areas/area.schema';
|
|
|
+
|
|
|
+// 类型提取规范
|
|
|
+type AreaResponse = InferResponseType<typeof areaClient.$get, 200>['data'][0];
|
|
|
+type CreateAreaRequest = InferRequestType<typeof areaClient.$post>['json'];
|
|
|
+type UpdateAreaRequest = InferRequestType<typeof areaClient[':id']['$put']>['json'];
|
|
|
+
|
|
|
+// 统一操作处理函数
|
|
|
+const handleOperation = async (operation: () => Promise<any>) => {
|
|
|
+ try {
|
|
|
+ await operation();
|
|
|
+ // toast.success('操作成功');
|
|
|
+ console.log('操作成功');
|
|
|
+ } catch (error) {
|
|
|
+ console.error('操作失败:', error);
|
|
|
+ // toast.error('操作失败,请重试');
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 防抖搜索函数
|
|
|
+const debounce = (func: Function, delay: number) => {
|
|
|
+ let timeoutId: NodeJS.Timeout;
|
|
|
+ return (...args: any[]) => {
|
|
|
+ clearTimeout(timeoutId);
|
|
|
+ timeoutId = setTimeout(() => func(...args), delay);
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+export const AreasPage: React.FC = () => {
|
|
|
+ const queryClient = useQueryClient();
|
|
|
+ const [page, setPage] = useState(1);
|
|
|
+ const [pageSize, setPageSize] = useState(20);
|
|
|
+ const [keyword, setKeyword] = useState('');
|
|
|
+ const [level, setLevel] = useState<string>('');
|
|
|
+ const [parentId, setParentId] = useState<string>('');
|
|
|
+ const [isDisabled, setIsDisabled] = useState<string>('');
|
|
|
+ const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
|
|
+ const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
|
|
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
|
+ const [isStatusDialogOpen, setIsStatusDialogOpen] = useState(false);
|
|
|
+ const [selectedArea, setSelectedArea] = useState<AreaResponse | null>(null);
|
|
|
+
|
|
|
+ // 构建搜索参数
|
|
|
+ const searchParams = {
|
|
|
+ page,
|
|
|
+ pageSize,
|
|
|
+ keyword: keyword || undefined,
|
|
|
+ level: level ? Number(level) : undefined,
|
|
|
+ parentId: parentId ? Number(parentId) : undefined,
|
|
|
+ isDisabled: isDisabled ? Number(isDisabled) : undefined,
|
|
|
+ };
|
|
|
+
|
|
|
+ // 查询省市区列表
|
|
|
+ const { data, isLoading, refetch } = useQuery({
|
|
|
+ queryKey: ['areas', searchParams],
|
|
|
+ queryFn: async () => {
|
|
|
+ const res = await areaClient.$get({
|
|
|
+ query: searchParams
|
|
|
+ });
|
|
|
+ if (res.status !== 200) throw new Error('获取省市区列表失败');
|
|
|
+ return await res.json();
|
|
|
+ },
|
|
|
+ staleTime: 5 * 60 * 1000,
|
|
|
+ cacheTime: 10 * 60 * 1000,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 创建省市区
|
|
|
+ const createMutation = useMutation({
|
|
|
+ mutationFn: async (data: CreateAreaRequest) => {
|
|
|
+ await handleOperation(async () => {
|
|
|
+ const res = await areaClient.$post({ json: data });
|
|
|
+ if (res.status !== 201) throw new Error('创建省市区失败');
|
|
|
+ });
|
|
|
+ },
|
|
|
+ onSuccess: () => {
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['areas'] });
|
|
|
+ setIsCreateDialogOpen(false);
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ // 更新省市区
|
|
|
+ const updateMutation = useMutation({
|
|
|
+ mutationFn: async ({ id, data }: { id: number; data: UpdateAreaRequest }) => {
|
|
|
+ await handleOperation(async () => {
|
|
|
+ const res = await areaClient[':id'].$put({
|
|
|
+ param: { id: id.toString() },
|
|
|
+ json: data
|
|
|
+ });
|
|
|
+ if (res.status !== 200) throw new Error('更新省市区失败');
|
|
|
+ });
|
|
|
+ },
|
|
|
+ onSuccess: () => {
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['areas'] });
|
|
|
+ setIsEditDialogOpen(false);
|
|
|
+ setSelectedArea(null);
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ // 删除省市区
|
|
|
+ const deleteMutation = useMutation({
|
|
|
+ mutationFn: async (id: number) => {
|
|
|
+ await handleOperation(async () => {
|
|
|
+ const res = await areaClient[':id'].$delete({
|
|
|
+ param: { id: id.toString() }
|
|
|
+ });
|
|
|
+ if (res.status !== 200) throw new Error('删除省市区失败');
|
|
|
+ });
|
|
|
+ },
|
|
|
+ onSuccess: () => {
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['areas'] });
|
|
|
+ setIsDeleteDialogOpen(false);
|
|
|
+ setSelectedArea(null);
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ // 启用/禁用省市区
|
|
|
+ const toggleStatusMutation = useMutation({
|
|
|
+ mutationFn: async ({ id, isDisabled }: { id: number; isDisabled: number }) => {
|
|
|
+ await handleOperation(async () => {
|
|
|
+ const res = await areaClient[':id']['toggle-status'].$put({
|
|
|
+ param: { id: id.toString() },
|
|
|
+ json: { isDisabled }
|
|
|
+ });
|
|
|
+ if (res.status !== 200) throw new Error('状态切换失败');
|
|
|
+ });
|
|
|
+ },
|
|
|
+ onSuccess: () => {
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['areas'] });
|
|
|
+ setIsStatusDialogOpen(false);
|
|
|
+ setSelectedArea(null);
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ // 防抖搜索
|
|
|
+ const debouncedSearch = useCallback(
|
|
|
+ debounce((keyword: string) => {
|
|
|
+ setKeyword(keyword);
|
|
|
+ setPage(1);
|
|
|
+ }, 300),
|
|
|
+ []
|
|
|
+ );
|
|
|
+
|
|
|
+ // 处理搜索输入变化
|
|
|
+ const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
+ debouncedSearch(e.target.value);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 处理筛选变化
|
|
|
+ const handleFilterChange = (filterType: string, value: string) => {
|
|
|
+ switch (filterType) {
|
|
|
+ case 'level':
|
|
|
+ setLevel(value);
|
|
|
+ break;
|
|
|
+ case 'parentId':
|
|
|
+ setParentId(value);
|
|
|
+ break;
|
|
|
+ case 'isDisabled':
|
|
|
+ setIsDisabled(value);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ setPage(1);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 处理创建省市区
|
|
|
+ const handleCreateArea = async (data: CreateAreaInput) => {
|
|
|
+ await createMutation.mutateAsync(data);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 处理更新省市区
|
|
|
+ const handleUpdateArea = async (data: UpdateAreaInput) => {
|
|
|
+ if (!selectedArea) return;
|
|
|
+ await updateMutation.mutateAsync({ id: selectedArea.id, data });
|
|
|
+ };
|
|
|
+
|
|
|
+ // 处理删除省市区
|
|
|
+ const handleDeleteArea = async () => {
|
|
|
+ if (!selectedArea) return;
|
|
|
+ await deleteMutation.mutateAsync(selectedArea.id);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 处理启用/禁用省市区
|
|
|
+ const handleToggleStatus = async (isDisabled: number) => {
|
|
|
+ if (!selectedArea) return;
|
|
|
+ await toggleStatusMutation.mutateAsync({ id: selectedArea.id, isDisabled });
|
|
|
+ };
|
|
|
+
|
|
|
+ // 打开编辑对话框
|
|
|
+ const handleEdit = (area: AreaResponse) => {
|
|
|
+ setSelectedArea(area);
|
|
|
+ setIsEditDialogOpen(true);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 打开删除对话框
|
|
|
+ const handleDelete = (area: AreaResponse) => {
|
|
|
+ setSelectedArea(area);
|
|
|
+ setIsDeleteDialogOpen(true);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 打开状态切换对话框
|
|
|
+ const handleToggleStatusDialog = (area: AreaResponse) => {
|
|
|
+ setSelectedArea(area);
|
|
|
+ setIsStatusDialogOpen(true);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 获取层级显示名称
|
|
|
+ const getLevelName = (level: number) => {
|
|
|
+ switch (level) {
|
|
|
+ case 1: return '省/直辖市';
|
|
|
+ case 2: return '市';
|
|
|
+ case 3: return '区/县';
|
|
|
+ default: return '未知';
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="space-y-6">
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <div>
|
|
|
+ <h1 className="text-3xl font-bold tracking-tight">省市区管理</h1>
|
|
|
+ <p className="text-muted-foreground">
|
|
|
+ 管理省市区三级联动数据
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <Button onClick={() => setIsCreateDialogOpen(true)}>
|
|
|
+ <Plus className="mr-2 h-4 w-4" />
|
|
|
+ 新增省市区
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle>省市区列表</CardTitle>
|
|
|
+ <CardDescription>
|
|
|
+ 查看和管理所有省市区数据
|
|
|
+ </CardDescription>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ {/* 搜索和筛选区域 */}
|
|
|
+ <div className="flex flex-col gap-4 mb-6">
|
|
|
+ <div className="flex gap-4">
|
|
|
+ <div className="flex-1">
|
|
|
+ <div className="relative">
|
|
|
+ <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
|
+ <Input
|
|
|
+ placeholder="搜索省市区名称或代码..."
|
|
|
+ className="pl-8"
|
|
|
+ onChange={handleSearchChange}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="flex gap-4">
|
|
|
+ <Select value={level} onValueChange={(value) => handleFilterChange('level', value)}>
|
|
|
+ <SelectTrigger className="w-[180px]">
|
|
|
+ <SelectValue placeholder="选择层级" />
|
|
|
+ </SelectTrigger>
|
|
|
+ <SelectContent>
|
|
|
+ <SelectItem value="">全部层级</SelectItem>
|
|
|
+ <SelectItem value="1">省/直辖市</SelectItem>
|
|
|
+ <SelectItem value="2">市</SelectItem>
|
|
|
+ <SelectItem value="3">区/县</SelectItem>
|
|
|
+ </SelectContent>
|
|
|
+ </Select>
|
|
|
+ <Select value={isDisabled} onValueChange={(value) => handleFilterChange('isDisabled', value)}>
|
|
|
+ <SelectTrigger className="w-[180px]">
|
|
|
+ <SelectValue placeholder="选择状态" />
|
|
|
+ </SelectTrigger>
|
|
|
+ <SelectContent>
|
|
|
+ <SelectItem value="">全部状态</SelectItem>
|
|
|
+ <SelectItem value="0">启用</SelectItem>
|
|
|
+ <SelectItem value="1">禁用</SelectItem>
|
|
|
+ </SelectContent>
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 数据表格 */}
|
|
|
+ <div className="rounded-md border">
|
|
|
+ <Table>
|
|
|
+ <TableHeader>
|
|
|
+ <TableRow>
|
|
|
+ <TableHead>ID</TableHead>
|
|
|
+ <TableHead>名称</TableHead>
|
|
|
+ <TableHead>代码</TableHead>
|
|
|
+ <TableHead>层级</TableHead>
|
|
|
+ <TableHead>父级ID</TableHead>
|
|
|
+ <TableHead>状态</TableHead>
|
|
|
+ <TableHead>创建时间</TableHead>
|
|
|
+ <TableHead className="text-right">操作</TableHead>
|
|
|
+ </TableRow>
|
|
|
+ </TableHeader>
|
|
|
+ <TableBody>
|
|
|
+ {isLoading ? (
|
|
|
+ <TableRow>
|
|
|
+ <TableCell colSpan={8} className="text-center py-8">
|
|
|
+ 加载中...
|
|
|
+ </TableCell>
|
|
|
+ </TableRow>
|
|
|
+ ) : data?.data?.length === 0 ? (
|
|
|
+ <TableRow>
|
|
|
+ <TableCell colSpan={8} className="text-center py-8">
|
|
|
+ 暂无数据
|
|
|
+ </TableCell>
|
|
|
+ </TableRow>
|
|
|
+ ) : (
|
|
|
+ data?.data?.map((area) => (
|
|
|
+ <TableRow key={area.id}>
|
|
|
+ <TableCell className="font-medium">{area.id}</TableCell>
|
|
|
+ <TableCell>{area.name}</TableCell>
|
|
|
+ <TableCell>{area.code}</TableCell>
|
|
|
+ <TableCell>
|
|
|
+ <Badge variant="outline">
|
|
|
+ {getLevelName(area.level)}
|
|
|
+ </Badge>
|
|
|
+ </TableCell>
|
|
|
+ <TableCell>{area.parentId || '-'}</TableCell>
|
|
|
+ <TableCell>
|
|
|
+ <Badge variant={area.isDisabled === 0 ? 'default' : 'secondary'}>
|
|
|
+ {area.isDisabled === 0 ? '启用' : '禁用'}
|
|
|
+ </Badge>
|
|
|
+ </TableCell>
|
|
|
+ <TableCell>
|
|
|
+ {new Date(area.createdAt).toLocaleDateString('zh-CN')}
|
|
|
+ </TableCell>
|
|
|
+ <TableCell className="text-right">
|
|
|
+ <div className="flex justify-end gap-2">
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ size="sm"
|
|
|
+ onClick={() => handleEdit(area)}
|
|
|
+ >
|
|
|
+ <Edit className="h-4 w-4" />
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ size="sm"
|
|
|
+ onClick={() => handleToggleStatusDialog(area)}
|
|
|
+ >
|
|
|
+ <Power className="h-4 w-4" />
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ size="sm"
|
|
|
+ onClick={() => handleDelete(area)}
|
|
|
+ >
|
|
|
+ <Trash2 className="h-4 w-4" />
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </TableCell>
|
|
|
+ </TableRow>
|
|
|
+ ))
|
|
|
+ )}
|
|
|
+ </TableBody>
|
|
|
+ </Table>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 分页 */}
|
|
|
+ {data && (
|
|
|
+ <div className="mt-4">
|
|
|
+ <DataTablePagination
|
|
|
+ page={page}
|
|
|
+ pageSize={pageSize}
|
|
|
+ total={data.total}
|
|
|
+ onPageChange={setPage}
|
|
|
+ onPageSizeChange={setPageSize}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ {/* 创建省市区对话框 */}
|
|
|
+ <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
|
|
+ <DialogContent className="max-w-2xl">
|
|
|
+ <DialogHeader>
|
|
|
+ <DialogTitle>新增省市区</DialogTitle>
|
|
|
+ <DialogDescription>
|
|
|
+ 填写省市区信息
|
|
|
+ </DialogDescription>
|
|
|
+ </DialogHeader>
|
|
|
+ <AreaForm
|
|
|
+ onSubmit={handleCreateArea}
|
|
|
+ isLoading={createMutation.isPending}
|
|
|
+ onCancel={() => setIsCreateDialogOpen(false)}
|
|
|
+ />
|
|
|
+ </DialogContent>
|
|
|
+ </Dialog>
|
|
|
+
|
|
|
+ {/* 编辑省市区对话框 */}
|
|
|
+ <Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
|
|
+ <DialogContent className="max-w-2xl">
|
|
|
+ <DialogHeader>
|
|
|
+ <DialogTitle>编辑省市区</DialogTitle>
|
|
|
+ <DialogDescription>
|
|
|
+ 修改省市区信息
|
|
|
+ </DialogDescription>
|
|
|
+ </DialogHeader>
|
|
|
+ {selectedArea && (
|
|
|
+ <AreaForm
|
|
|
+ area={selectedArea}
|
|
|
+ onSubmit={handleUpdateArea}
|
|
|
+ isLoading={updateMutation.isPending}
|
|
|
+ onCancel={() => {
|
|
|
+ setIsEditDialogOpen(false);
|
|
|
+ setSelectedArea(null);
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </DialogContent>
|
|
|
+ </Dialog>
|
|
|
+
|
|
|
+ {/* 删除确认对话框 */}
|
|
|
+ <AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
|
|
+ <AlertDialogContent>
|
|
|
+ <AlertDialogHeader>
|
|
|
+ <AlertDialogTitle>确认删除</AlertDialogTitle>
|
|
|
+ <AlertDialogDescription>
|
|
|
+ 确定要删除省市区 "{selectedArea?.name}" 吗?此操作不可恢复。
|
|
|
+ </AlertDialogDescription>
|
|
|
+ </AlertDialogHeader>
|
|
|
+ <AlertDialogFooter>
|
|
|
+ <AlertDialogCancel>取消</AlertDialogCancel>
|
|
|
+ <AlertDialogAction
|
|
|
+ onClick={handleDeleteArea}
|
|
|
+ disabled={deleteMutation.isPending}
|
|
|
+ >
|
|
|
+ {deleteMutation.isPending ? '删除中...' : '确认删除'}
|
|
|
+ </AlertDialogAction>
|
|
|
+ </AlertDialogFooter>
|
|
|
+ </AlertDialogContent>
|
|
|
+ </AlertDialog>
|
|
|
+
|
|
|
+ {/* 状态切换确认对话框 */}
|
|
|
+ <AlertDialog open={isStatusDialogOpen} onOpenChange={setIsStatusDialogOpen}>
|
|
|
+ <AlertDialogContent>
|
|
|
+ <AlertDialogHeader>
|
|
|
+ <AlertDialogTitle>
|
|
|
+ {selectedArea?.isDisabled === 0 ? '禁用' : '启用'}确认
|
|
|
+ </AlertDialogTitle>
|
|
|
+ <AlertDialogDescription>
|
|
|
+ 确定要{selectedArea?.isDisabled === 0 ? '禁用' : '启用'}省市区 "{selectedArea?.name}" 吗?
|
|
|
+ </AlertDialogDescription>
|
|
|
+ </AlertDialogHeader>
|
|
|
+ <AlertDialogFooter>
|
|
|
+ <AlertDialogCancel>取消</AlertDialogCancel>
|
|
|
+ <AlertDialogAction
|
|
|
+ onClick={() => handleToggleStatus(selectedArea?.isDisabled === 0 ? 1 : 0)}
|
|
|
+ disabled={toggleStatusMutation.isPending}
|
|
|
+ >
|
|
|
+ {toggleStatusMutation.isPending ? '处理中...' : '确认'}
|
|
|
+ </AlertDialogAction>
|
|
|
+ </AlertDialogFooter>
|
|
|
+ </AlertDialogContent>
|
|
|
+ </AlertDialog>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|