Explorar el Código

✨ feat(areas): 实现省市区树形视图和多级选择功能

- 新增AreaSelect组件,实现省市区三级联动选择
- 新增AreaTree组件,以树形结构展示区域层级关系
- 在AreaForm中替换父级ID输入框为区域选择器,提升用户体验
- 在AreasPage中添加表格/树形视图切换功能
- 实现树形节点展开/折叠状态管理
- 添加筛选重置按钮,优化搜索体验
- 优化表单标签和描述文本,增强可读性
yourname hace 4 meses
padre
commit
1fb8ba1424

+ 52 - 18
src/client/admin/components/AreaForm.tsx

@@ -9,6 +9,7 @@ import { createAreaSchema, updateAreaSchema } from '@/server/modules/areas/area.
 import type { CreateAreaInput, UpdateAreaInput } from '@/server/modules/areas/area.schema';
 import { AreaLevel } from '@/server/modules/areas/area.entity';
 import { DisabledStatus } from '@/share/types';
+import { AreaSelect } from './AreaSelect';
 
 interface AreaFormProps {
   area?: UpdateAreaInput & { id?: number };
@@ -84,27 +85,60 @@ export const AreaForm: React.FC<AreaFormProps> = ({
             )}
           />
 
-          {/* 父级ID */}
+          {/* 父级区域选择 */}
           <FormField
             control={form.control}
             name="parentId"
-            render={({ field }) => (
-              <FormItem>
-                <FormLabel>父级ID</FormLabel>
-                <FormControl>
-                  <Input
-                    type="number"
-                    placeholder="输入父级区域ID,省/直辖市填0"
-                    {...field}
-                    onChange={(e) => field.onChange(e.target.value === '' ? 0 : Number(e.target.value))}
-                  />
-                </FormControl>
-                <FormDescription>
-                  省/直辖市填0,市/区县填对应的上级区域ID
-                </FormDescription>
-                <FormMessage />
-              </FormItem>
-            )}
+            render={({ field }) => {
+              const level = form.watch('level');
+              const showParentSelect = level !== AreaLevel.PROVINCE;
+
+              return (
+                <FormItem>
+                  <FormLabel>父级区域</FormLabel>
+                  {showParentSelect ? (
+                    <>
+                      <FormControl>
+                        <AreaSelect
+                          value={{
+                            districtId: level === AreaLevel.DISTRICT ? field.value || undefined : undefined
+                          }}
+                          onChange={(value) => {
+                            // 根据层级设置父级ID
+                            if (level === AreaLevel.CITY) {
+                              field.onChange(value.provinceId || 0);
+                            } else if (level === AreaLevel.DISTRICT) {
+                              field.onChange(value.cityId || 0);
+                            }
+                          }}
+                          required
+                        />
+                      </FormControl>
+                      <FormDescription>
+                        {level === AreaLevel.CITY
+                          ? '选择所属省份'
+                          : '选择所属城市'}
+                      </FormDescription>
+                    </>
+                  ) : (
+                    <>
+                      <FormControl>
+                        <Input
+                          type="number"
+                          value={0}
+                          disabled
+                          className="bg-muted"
+                        />
+                      </FormControl>
+                      <FormDescription>
+                        省/直辖市没有父级区域
+                      </FormDescription>
+                    </>
+                  )}
+                  <FormMessage />
+                </FormItem>
+              );
+            }}
           />
 
           {/* 区域名称 */}

+ 246 - 0
src/client/admin/components/AreaSelect.tsx

@@ -0,0 +1,246 @@
+import React, { useState, useEffect } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select';
+import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from '@/client/components/ui/form';
+import { areaClient } from '@/client/api';
+import type { InferResponseType } from 'hono/client';
+
+// 类型定义
+type AreaResponse = InferResponseType<typeof areaClient.$get, 200>['data'][0];
+
+interface AreaSelectProps {
+  value?: {
+    provinceId?: number;
+    cityId?: number;
+    districtId?: number;
+  };
+  onChange?: (value: {
+    provinceId?: number;
+    cityId?: number;
+    districtId?: number;
+  }) => void;
+  disabled?: boolean;
+  required?: boolean;
+  className?: string;
+}
+
+export const AreaSelect: React.FC<AreaSelectProps> = ({
+  value = {},
+  onChange,
+  disabled = false,
+  required = false,
+  className
+}) => {
+  const [selectedProvince, setSelectedProvince] = useState<number | undefined>(value.provinceId);
+  const [selectedCity, setSelectedCity] = useState<number | undefined>(value.cityId);
+  const [selectedDistrict, setSelectedDistrict] = useState<number | undefined>(value.districtId);
+
+  // 查询省份列表
+  const { data: provinces, isLoading: isLoadingProvinces } = useQuery({
+    queryKey: ['areas', 'provinces'],
+    queryFn: async () => {
+      const res = await areaClient.$get({
+        query: {
+          page: 1,
+          pageSize: 100,
+          level: 1,
+          isDisabled: 0
+        }
+      });
+      if (res.status !== 200) throw new Error('获取省份列表失败');
+      return await res.json();
+    },
+    staleTime: 10 * 60 * 1000,
+    gcTime: 30 * 60 * 1000,
+  });
+
+  // 查询城市列表
+  const { data: cities, isLoading: isLoadingCities } = useQuery({
+    queryKey: ['areas', 'cities', selectedProvince],
+    queryFn: async () => {
+      if (!selectedProvince) return { data: [] };
+      const res = await areaClient.$get({
+        query: {
+          page: 1,
+          pageSize: 100,
+          level: 2,
+          parentId: selectedProvince,
+          isDisabled: 0
+        }
+      });
+      if (res.status !== 200) throw new Error('获取城市列表失败');
+      return await res.json();
+    },
+    staleTime: 10 * 60 * 1000,
+    gcTime: 30 * 60 * 1000,
+    enabled: !!selectedProvince,
+  });
+
+  // 查询区县列表
+  const { data: districts, isLoading: isLoadingDistricts } = useQuery({
+    queryKey: ['areas', 'districts', selectedCity],
+    queryFn: async () => {
+      if (!selectedCity) return { data: [] };
+      const res = await areaClient.$get({
+        query: {
+          page: 1,
+          pageSize: 100,
+          level: 3,
+          parentId: selectedCity,
+          isDisabled: 0
+        }
+      });
+      if (res.status !== 200) throw new Error('获取区县列表失败');
+      return await res.json();
+    },
+    staleTime: 10 * 60 * 1000,
+    gcTime: 30 * 60 * 1000,
+    enabled: !!selectedCity,
+  });
+
+  // 处理省份选择
+  const handleProvinceChange = (provinceId: string) => {
+    const id = provinceId ? Number(provinceId) : undefined;
+    setSelectedProvince(id);
+    setSelectedCity(undefined);
+    setSelectedDistrict(undefined);
+
+    onChange?.({
+      provinceId: id,
+      cityId: undefined,
+      districtId: undefined
+    });
+  };
+
+  // 处理城市选择
+  const handleCityChange = (cityId: string) => {
+    const id = cityId ? Number(cityId) : undefined;
+    setSelectedCity(id);
+    setSelectedDistrict(undefined);
+
+    onChange?.({
+      provinceId: selectedProvince,
+      cityId: id,
+      districtId: undefined
+    });
+  };
+
+  // 处理区县选择
+  const handleDistrictChange = (districtId: string) => {
+    const id = districtId ? Number(districtId) : undefined;
+    setSelectedDistrict(id);
+
+    onChange?.({
+      provinceId: selectedProvince,
+      cityId: selectedCity,
+      districtId: id
+    });
+  };
+
+  // 同步外部值变化
+  useEffect(() => {
+    setSelectedProvince(value.provinceId);
+    setSelectedCity(value.cityId);
+    setSelectedDistrict(value.districtId);
+  }, [value.provinceId, value.cityId, value.districtId]);
+
+  return (
+    <div className={`grid grid-cols-1 md:grid-cols-3 gap-4 ${className}`}>
+      {/* 省份选择 */}
+      <div>
+        <FormItem>
+          <FormLabel>
+            省份{required && <span className="text-destructive">*</span>}
+          </FormLabel>
+          <Select
+            value={selectedProvince?.toString() || ''}
+            onValueChange={handleProvinceChange}
+            disabled={disabled || isLoadingProvinces}
+          >
+            <FormControl>
+              <SelectTrigger>
+                <SelectValue placeholder="选择省份" />
+              </SelectTrigger>
+            </FormControl>
+            <SelectContent>
+              <SelectItem value="">请选择省份</SelectItem>
+              {provinces?.data.map((province: AreaResponse) => (
+                <SelectItem key={province.id} value={province.id.toString()}>
+                  {province.name}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+          <FormDescription>
+            选择所在省份
+          </FormDescription>
+          <FormMessage />
+        </FormItem>
+      </div>
+
+      {/* 城市选择 */}
+      <div>
+        <FormItem>
+          <FormLabel>
+            城市{required && selectedProvince && <span className="text-destructive">*</span>}
+          </FormLabel>
+          <Select
+            value={selectedCity?.toString() || ''}
+            onValueChange={handleCityChange}
+            disabled={disabled || !selectedProvince || isLoadingCities}
+          >
+            <FormControl>
+              <SelectTrigger>
+                <SelectValue placeholder="选择城市" />
+              </SelectTrigger>
+            </FormControl>
+            <SelectContent>
+              <SelectItem value="">请选择城市</SelectItem>
+              {cities?.data.map((city: AreaResponse) => (
+                <SelectItem key={city.id} value={city.id.toString()}>
+                  {city.name}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+          <FormDescription>
+            选择所在城市
+          </FormDescription>
+          <FormMessage />
+        </FormItem>
+      </div>
+
+      {/* 区县选择 */}
+      <div>
+        <FormItem>
+          <FormLabel>
+            区县{required && selectedCity && <span className="text-destructive">*</span>}
+          </FormLabel>
+          <Select
+            value={selectedDistrict?.toString() || ''}
+            onValueChange={handleDistrictChange}
+            disabled={disabled || !selectedCity || isLoadingDistricts}
+          >
+            <FormControl>
+              <SelectTrigger>
+                <SelectValue placeholder="选择区县" />
+              </SelectTrigger>
+            </FormControl>
+            <SelectContent>
+              <SelectItem value="">请选区县</SelectItem>
+              {districts?.data.map((district: AreaResponse) => (
+                <SelectItem key={district.id} value={district.id.toString()}>
+                  {district.name}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+          <FormDescription>
+            选择所在区县
+          </FormDescription>
+          <FormMessage />
+        </FormItem>
+      </div>
+    </div>
+  );
+};

+ 155 - 0
src/client/admin/components/AreaTree.tsx

@@ -0,0 +1,155 @@
+import React from 'react';
+import { ChevronRight, ChevronDown, Folder, FolderOpen } from 'lucide-react';
+import { Button } from '@/client/components/ui/button';
+import { Badge } from '@/client/components/ui/badge';
+import { cn } from '@/client/lib/utils';
+
+interface AreaNode {
+  id: number;
+  name: string;
+  code: string;
+  level: number;
+  parentId: number | null;
+  isDisabled: number;
+  children?: AreaNode[];
+}
+
+interface AreaTreeProps {
+  areas: AreaNode[];
+  expandedNodes: Set<number>;
+  onToggleNode: (nodeId: number) => void;
+  onEdit: (area: AreaNode) => void;
+  onDelete: (area: AreaNode) => void;
+  onToggleStatus: (area: AreaNode) => void;
+}
+
+export const AreaTree: React.FC<AreaTreeProps> = ({
+  areas,
+  expandedNodes,
+  onToggleNode,
+  onEdit,
+  onDelete,
+  onToggleStatus
+}) => {
+  const renderTreeNode = (node: AreaNode, depth = 0) => {
+    const hasChildren = node.children && node.children.length > 0;
+    const isExpanded = expandedNodes.has(node.id);
+    const isDisabled = node.isDisabled === 1;
+
+    return (
+      <div key={node.id} className="select-none">
+        {/* 节点行 */}
+        <div
+          className={cn(
+            "flex items-center gap-2 py-2 px-3 hover:bg-muted/50 cursor-pointer border-b",
+            depth > 0 && "ml-6"
+          )}
+          style={{ marginLeft: `${depth * 24}px` }}
+        >
+          {/* 展开/收起按钮 */}
+          {hasChildren && (
+            <Button
+              variant="ghost"
+              size="sm"
+              className="h-6 w-6 p-0"
+              onClick={() => onToggleNode(node.id)}
+            >
+              {isExpanded ? (
+                <ChevronDown className="h-4 w-4" />
+              ) : (
+                <ChevronRight className="h-4 w-4" />
+              )}
+            </Button>
+          )}
+          {!hasChildren && <div className="w-6" />}
+
+          {/* 图标 */}
+          <div className="flex-shrink-0">
+            {hasChildren ? (
+              isExpanded ? (
+                <FolderOpen className="h-4 w-4 text-blue-500" />
+              ) : (
+                <Folder className="h-4 w-4 text-blue-400" />
+              )
+            ) : (
+              <div className="h-4 w-4" />
+            )}
+          </div>
+
+          {/* 节点信息 */}
+          <div className="flex-1 flex items-center gap-3">
+            <span className={cn("font-medium", isDisabled && "text-muted-foreground line-through")}>
+              {node.name}
+            </span>
+            <Badge variant="outline" className="text-xs">
+              {getLevelName(node.level)}
+            </Badge>
+            <span className="text-xs text-muted-foreground">
+              {node.code}
+            </span>
+            <Badge variant={isDisabled ? "secondary" : "default"} className="text-xs">
+              {isDisabled ? '禁用' : '启用'}
+            </Badge>
+          </div>
+
+          {/* 操作按钮 */}
+          <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={(e) => {
+                e.stopPropagation();
+                onEdit(node);
+              }}
+            >
+              编辑
+            </Button>
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={(e) => {
+                e.stopPropagation();
+                onToggleStatus(node);
+              }}
+            >
+              {isDisabled ? '启用' : '禁用'}
+            </Button>
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={(e) => {
+                e.stopPropagation();
+                onDelete(node);
+              }}
+            >
+              删除
+            </Button>
+          </div>
+        </div>
+
+        {/* 子节点 */}
+        {isExpanded && hasChildren && (
+          <div>
+            {node.children!.map(child => renderTreeNode(child, depth + 1))}
+          </div>
+        )}
+      </div>
+    );
+  };
+
+  return (
+    <div className="border rounded-lg bg-background">
+      {areas.map(area => renderTreeNode(area))}
+    </div>
+  );
+};
+
+// 获取层级显示名称
+const getLevelName = (level: number) => {
+  switch (level) {
+    case 1: return '省/直辖市';
+    case 2: return '市';
+    case 3: return '区/县';
+    default: return '未知';
+  }
+};

+ 164 - 14
src/client/admin/pages/Areas.tsx

@@ -4,7 +4,7 @@ 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, Power } from 'lucide-react';
+import { Plus, Edit, Trash2, Search, Power, ListTree, Table as TableIcon, RotateCcw } from 'lucide-react';
 import { useState, useCallback } from 'react';
 import { areaClient } from '@/client/api';
 import type { InferResponseType, InferRequestType } from 'hono/client';
@@ -14,7 +14,9 @@ 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 { AreaTree } from '../components/AreaTree';
 import type { CreateAreaInput, UpdateAreaInput } from '@/server/modules/areas/area.schema';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/client/components/ui/tabs';
 
 // 类型提取规范
 type AreaResponse = InferResponseType<typeof areaClient.$get, 200>['data'][0];
@@ -51,6 +53,8 @@ export const AreasPage: React.FC = () => {
   const [level, setLevel] = useState<string>('all');
   const [parentId, setParentId] = useState<string>('');
   const [isDisabled, setIsDisabled] = useState<string>('all');
+  const [viewMode, setViewMode] = useState<'table' | 'tree'>('table');
+  const [expandedNodes, setExpandedNodes] = useState<Set<number>>(new Set());
   const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
   const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
   const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
@@ -81,6 +85,26 @@ export const AreasPage: React.FC = () => {
     gcTime: 10 * 60 * 1000,
   });
 
+  // 查询树形结构数据
+  const { data: treeData, isLoading: isTreeLoading } = useQuery({
+    queryKey: ['areas-tree'],
+    queryFn: async () => {
+      const res = await areaClient.$get({
+        query: {
+          page: 1,
+          pageSize: 1000, // 获取所有数据用于构建树
+          relations: ['children']
+        }
+      });
+      if (res.status !== 200) throw new Error('获取省市区树形数据失败');
+      const response = await res.json();
+      return buildTree(response.data);
+    },
+    staleTime: 5 * 60 * 1000,
+    gcTime: 10 * 60 * 1000,
+    enabled: viewMode === 'tree'
+  });
+
   // 创建省市区
   const createMutation = useMutation({
     mutationFn: async (data: CreateAreaRequest) => {
@@ -219,6 +243,54 @@ export const AreasPage: React.FC = () => {
     setIsStatusDialogOpen(true);
   };
 
+  // 构建树形结构
+  const buildTree = (areas: AreaResponse[]): AreaResponse[] => {
+    const areaMap = new Map<number, AreaResponse>();
+    const tree: AreaResponse[] = [];
+
+    // 创建映射
+    areas.forEach(area => {
+      areaMap.set(area.id, { ...area, children: [] });
+    });
+
+    // 构建树
+    areas.forEach(area => {
+      const node = areaMap.get(area.id)!;
+      if (area.parentId === null || area.parentId === 0) {
+        tree.push(node);
+      } else {
+        const parent = areaMap.get(area.parentId);
+        if (parent) {
+          parent.children!.push(node);
+        }
+      }
+    });
+
+    return tree;
+  };
+
+  // 切换节点展开状态
+  const handleToggleNode = (nodeId: number) => {
+    setExpandedNodes(prev => {
+      const newSet = new Set(prev);
+      if (newSet.has(nodeId)) {
+        newSet.delete(nodeId);
+      } else {
+        newSet.add(nodeId);
+      }
+      return newSet;
+    });
+  };
+
+  // 重置筛选
+  const handleResetFilters = () => {
+    setKeyword('');
+    setLevel('all');
+    setParentId('');
+    setIsDisabled('all');
+    setPage(1);
+  };
+
   // 获取层级显示名称
   const getLevelName = (level: number) => {
     switch (level) {
@@ -238,20 +310,52 @@ export const AreasPage: React.FC = () => {
             管理省市区三级联动数据
           </p>
         </div>
-        <Button onClick={() => setIsCreateDialogOpen(true)}>
-          <Plus className="mr-2 h-4 w-4" />
-          新增省市区
-        </Button>
+        <div className="flex gap-2">
+          <Button
+            variant={viewMode === 'table' ? 'default' : 'outline'}
+            size="sm"
+            onClick={() => setViewMode('table')}
+          >
+            <TableIcon className="mr-2 h-4 w-4" />
+            表格视图
+          </Button>
+          <Button
+            variant={viewMode === 'tree' ? 'default' : 'outline'}
+            size="sm"
+            onClick={() => setViewMode('tree')}
+          >
+            <ListTree className="mr-2 h-4 w-4" />
+            树形视图
+          </Button>
+          <Button onClick={() => setIsCreateDialogOpen(true)}>
+            <Plus className="mr-2 h-4 w-4" />
+            新增省市区
+          </Button>
+        </div>
       </div>
 
-      <Card>
-        <CardHeader>
-          <CardTitle>省市区列表</CardTitle>
-          <CardDescription>
-            查看和管理所有省市区数据
-          </CardDescription>
-        </CardHeader>
-        <CardContent>
+      <Tabs value={viewMode} onValueChange={(value) => setViewMode(value as 'table' | 'tree')}>
+        <TabsList className="mb-4">
+          <TabsTrigger value="table">
+            <TableIcon className="mr-2 h-4 w-4" />
+            表格视图
+          </TabsTrigger>
+          <TabsTrigger value="tree">
+            <ListTree className="mr-2 h-4 w-4" />
+            树形视图
+          </TabsTrigger>
+        </TabsList>
+
+        {/* 表格视图 */}
+        <TabsContent value="table">
+          <Card>
+            <CardHeader>
+              <CardTitle>省市区列表</CardTitle>
+              <CardDescription>
+                查看和管理所有省市区数据
+              </CardDescription>
+            </CardHeader>
+            <CardContent>
           {/* 搜索和筛选区域 */}
           <div className="flex flex-col gap-4 mb-6">
             <div className="flex gap-4">
@@ -261,10 +365,22 @@ export const AreasPage: React.FC = () => {
                   <Input
                     placeholder="搜索省市区名称或代码..."
                     className="pl-8"
-                    onChange={handleSearchChange}
+                    value={keyword}
+                    onChange={(e) => {
+                      setKeyword(e.target.value);
+                      debouncedSearch(e.target.value);
+                    }}
                   />
                 </div>
               </div>
+              <Button
+                variant="outline"
+                onClick={handleResetFilters}
+                disabled={!keyword && level === 'all' && isDisabled === 'all'}
+              >
+                <RotateCcw className="mr-2 h-4 w-4" />
+                重置
+              </Button>
             </div>
             <div className="flex gap-4">
               <Select value={level} onValueChange={(value) => handleFilterChange('level', value)}>
@@ -387,6 +503,40 @@ export const AreasPage: React.FC = () => {
           )}
         </CardContent>
       </Card>
+        </TabsContent>
+
+        {/* 树形视图 */}
+        <TabsContent value="tree">
+          <Card>
+            <CardHeader>
+              <CardTitle>省市区树形结构</CardTitle>
+              <CardDescription>
+                以树形结构查看和管理省市区层级关系
+              </CardDescription>
+            </CardHeader>
+            <CardContent>
+              {isTreeLoading ? (
+                <div className="text-center py-8">
+                  加载中...
+                </div>
+              ) : !treeData || treeData.length === 0 ? (
+                <div className="text-center py-8">
+                  暂无数据
+                </div>
+              ) : (
+                <AreaTree
+                  areas={treeData}
+                  expandedNodes={expandedNodes}
+                  onToggleNode={handleToggleNode}
+                  onEdit={handleEdit}
+                  onDelete={handleDelete}
+                  onToggleStatus={handleToggleStatusDialog}
+                />
+              )}
+            </CardContent>
+          </Card>
+        </TabsContent>
+      </Tabs>
 
       {/* 创建省市区对话框 */}
       <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>