Просмотр исходного кода

✨ feat(areas): 实现省市区树形管理功能

- 新增AreaTreeAsync组件,支持异步加载树形结构
- 实现AreasTreePage页面,包含搜索、筛选和树形展示功能
- 添加省市区创建、编辑、删除和状态切换功能
- 优化AreaForm组件,支持默认层级设置

♻️ refactor(areas): 修改地区层级验证方式

- 将level参数从枚举验证改为数字范围验证(min 1, max 3)
- 路由配置更新,使用新的树形管理页面替换旧列表页面
yourname 3 месяцев назад
Родитель
Сommit
68cf37c9af

+ 1 - 1
packages/server/src/api/admin/areas/tree.ts

@@ -55,7 +55,7 @@ const getAreaTreeByLevelRoute = createRoute({
   middleware: [authMiddleware],
   request: {
     params: z.object({
-      level: z.nativeEnum(AreaLevel)
+      level: z.coerce.number().min(1).max(3)
     })
   },
   responses: {

+ 4 - 2
web/src/client/admin/components/AreaForm.tsx

@@ -18,13 +18,15 @@ interface AreaFormProps {
   onSubmit: (data: CreateAreaInput | UpdateAreaInput) => Promise<void>;
   onCancel: () => void;
   isLoading?: boolean;
+  defaultLevel?: number;
 }
 
 export const AreaForm: React.FC<AreaFormProps> = ({
   area,
   onSubmit,
   onCancel,
-  isLoading = false
+  isLoading = false,
+  defaultLevel
 }) => {
   const isEditing = !!area;
   const [parentAreaInfo, setParentAreaInfo] = useState<{
@@ -87,7 +89,7 @@ export const AreaForm: React.FC<AreaFormProps> = ({
     } : {
       parentId: 0,
       name: '',
-      level: AreaLevel.PROVINCE,
+      level: defaultLevel || AreaLevel.PROVINCE,
       code: '',
       isDisabled: DisabledStatus.ENABLED,
     },

+ 280 - 0
web/src/client/admin/components/AreaTreeAsync.tsx

@@ -0,0 +1,280 @@
+import React from 'react';
+import { ChevronRight, ChevronDown, Folder, FolderOpen, Loader2 } from 'lucide-react';
+import { Button } from '@/client/components/ui/button';
+import { Badge } from '@/client/components/ui/badge';
+import { cn } from '@/client/lib/utils';
+import { useQuery } from '@tanstack/react-query';
+import { areaClient } from '@/client/api';
+
+interface AreaNode {
+  id: number;
+  name: string;
+  code: string;
+  level: number;
+  parentId: number | null;
+  isDisabled: number;
+  children?: AreaNode[];
+}
+
+interface AreaTreeAsyncProps {
+  areas: AreaNode[];
+  expandedNodes: Set<number>;
+  onToggleNode: (nodeId: number) => void;
+  onEdit: (area: AreaNode) => void;
+  onDelete: (area: AreaNode) => void;
+  onToggleStatus: (area: AreaNode) => void;
+}
+
+// 子树加载组件
+interface SubTreeLoaderProps {
+  nodeId: number;
+  isExpanded: boolean;
+  hasChildren: boolean;
+  depth: number;
+  expandedNodes: Set<number>;
+  onToggleNode: (nodeId: number) => void;
+  onEdit: (area: AreaNode) => void;
+  onDelete: (area: AreaNode) => void;
+  onToggleStatus: (area: AreaNode) => void;
+}
+
+const SubTreeLoader: React.FC<SubTreeLoaderProps> = ({
+  nodeId,
+  isExpanded,
+  hasChildren,
+  depth,
+  expandedNodes,
+  onToggleNode,
+  onEdit,
+  onDelete,
+  onToggleStatus
+}) => {
+  const { data: subTreeData, isLoading: isSubTreeLoading } = useQuery({
+    queryKey: ['areas-subtree', nodeId],
+    queryFn: async () => {
+      const res = await areaClient.tree[':id'].$get({
+        param: { id: nodeId }
+      });
+      if (res.status !== 200) throw new Error('获取子树失败');
+      const response = await res.json();
+      return response.data;
+    },
+    enabled: isExpanded && hasChildren,
+    staleTime: 5 * 60 * 1000,
+    gcTime: 10 * 60 * 1000,
+  });
+
+  if (isSubTreeLoading) {
+    return (
+      <div className="flex items-center justify-center py-2 px-3 text-muted-foreground">
+        <Loader2 className="h-4 w-4 animate-spin mr-2" />
+        加载中...
+      </div>
+    );
+  }
+
+  if (!subTreeData) {
+    return (
+      <div className="py-2 px-3 text-muted-foreground text-sm">
+        暂无子节点
+      </div>
+    );
+  }
+
+  // subTreeData 是一个 AreaNode 对象,我们需要处理其 children 数组
+  const childNodes = subTreeData.children || [];
+
+  if (childNodes.length === 0) {
+    return (
+      <div className="py-2 px-3 text-muted-foreground text-sm">
+        暂无子节点
+      </div>
+    );
+  }
+
+  return (
+    <div>
+      {childNodes.map(node => (
+        <TreeNode
+          key={node.id}
+          node={node}
+          depth={depth + 1}
+          expandedNodes={expandedNodes}
+          onToggleNode={onToggleNode}
+          onEdit={onEdit}
+          onDelete={onDelete}
+          onToggleStatus={onToggleStatus}
+        />
+      ))}
+    </div>
+  );
+};
+
+// 树节点组件
+interface TreeNodeProps {
+  node: AreaNode;
+  depth?: number;
+  expandedNodes: Set<number>;
+  onToggleNode: (nodeId: number) => void;
+  onEdit: (area: AreaNode) => void;
+  onDelete: (area: AreaNode) => void;
+  onToggleStatus: (area: AreaNode) => void;
+}
+
+const TreeNode: React.FC<TreeNodeProps> = ({
+  node,
+  depth = 0,
+  expandedNodes,
+  onToggleNode,
+  onEdit,
+  onDelete,
+  onToggleStatus
+}) => {
+  const isExpanded = expandedNodes.has(node.id);
+  const isDisabled = node.isDisabled === 1;
+  const hasChildren = node.level < 3; // 省级和市级节点可能有子节点
+
+  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 && (
+        <SubTreeLoader
+          nodeId={node.id}
+          isExpanded={isExpanded}
+          hasChildren={hasChildren}
+          depth={depth}
+          expandedNodes={expandedNodes}
+          onToggleNode={onToggleNode}
+          onEdit={onEdit}
+          onDelete={onDelete}
+          onToggleStatus={onToggleStatus}
+        />
+      )}
+    </div>
+  );
+};
+
+export const AreaTreeAsync: React.FC<AreaTreeAsyncProps> = ({
+  areas,
+  expandedNodes,
+  onToggleNode,
+  onEdit,
+  onDelete,
+  onToggleStatus
+}) => {
+  return (
+    <div className="border rounded-lg bg-background">
+      {areas.map(area => (
+        <TreeNode
+          key={area.id}
+          node={area}
+          depth={0}
+          expandedNodes={expandedNodes}
+          onToggleNode={onToggleNode}
+          onEdit={onEdit}
+          onDelete={onDelete}
+          onToggleStatus={onToggleStatus}
+        />
+      ))}
+    </div>
+  );
+};
+
+// 获取层级显示名称
+const getLevelName = (level: number) => {
+  switch (level) {
+    case 1: return '省/直辖市';
+    case 2: return '市';
+    case 3: return '区/县';
+    default: return '未知';
+  }
+};

+ 452 - 0
web/src/client/admin/pages/AreasTreePage.tsx

@@ -0,0 +1,452 @@
+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 { Plus, Search, RotateCcw } 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 { 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 { AreaTreeAsync } from '../components/AreaTreeAsync';
+import type { CreateAreaInput, UpdateAreaInput } from '@d8d/server/modules/areas/area.schema';
+
+// 类型提取规范
+type AreaResponse = InferResponseType<typeof areaClient.$get, 200>['data'][0];
+type SearchAreaRequest = InferRequestType<typeof areaClient.$get>['query'];
+type CreateAreaRequest = InferRequestType<typeof areaClient.$post>['json'];
+type UpdateAreaRequest = InferRequestType<typeof areaClient[':id']['$put']>['json'];
+
+// 树形节点类型
+interface AreaNode {
+  id: number;
+  name: string;
+  code: string;
+  level: number;
+  parentId: number | null;
+  isDisabled: number;
+  children?: AreaNode[];
+}
+
+// 统一操作处理函数
+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 AreasTreePage: React.FC = () => {
+  const queryClient = useQueryClient();
+  const [keyword, setKeyword] = useState('');
+  const [level, setLevel] = useState<string>('all');
+  const [isDisabled, setIsDisabled] = useState<string>('all');
+  const [expandedNodes, setExpandedNodes] = useState<Set<number>>(new Set());
+  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 filters: Record<string, any> = {};
+  if (level && level !== 'all') filters.level = Number(level);
+  if (isDisabled && isDisabled !== 'all') filters.isDisabled = Number(isDisabled);
+
+  // 查询省级数据(异步加载)
+  const { data: provinceData, isLoading: isProvinceLoading } = useQuery({
+    queryKey: ['areas-tree-province'],
+    queryFn: async () => {
+      const res = await areaClient.tree.level[':level'].$get({
+        param: { level: 1 }
+      });
+      if (res.status !== 200) throw new Error('获取省级数据失败');
+      const response = await res.json();
+      return response.data;
+    },
+    staleTime: 5 * 60 * 1000,
+    gcTime: 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-tree-province'] });
+      setIsCreateDialogOpen(false);
+    },
+  });
+
+  // 更新省市区
+  const updateMutation = useMutation({
+    mutationFn: async ({ id, data }: { id: number; data: UpdateAreaRequest }) => {
+      await handleOperation(async () => {
+        const res = await areaClient[':id'].$put({
+          param: { id },
+          json: data
+        });
+        if (res.status !== 200) throw new Error('更新省市区失败');
+      });
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['areas-tree-province'] });
+      setIsEditDialogOpen(false);
+      setSelectedArea(null);
+    },
+  });
+
+  // 删除省市区
+  const deleteMutation = useMutation({
+    mutationFn: async (id: number) => {
+      await handleOperation(async () => {
+        const res = await areaClient[':id'].$delete({
+          param: { id }
+        });
+        if (res.status !== 204) throw new Error('删除省市区失败');
+      });
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['areas-tree-province'] });
+      setIsDeleteDialogOpen(false);
+      setSelectedArea(null);
+    },
+  });
+
+  // 启用/禁用省市区
+  const toggleStatusMutation = useMutation({
+    mutationFn: async ({ id, isDisabled }: { id: number; isDisabled: number }) => {
+      await handleOperation(async () => {
+        const res = await areaClient[':id'].$put({
+          param: { id },
+          json: { isDisabled }
+        });
+        if (res.status !== 200) throw new Error('更新省市区状态失败');
+      });
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['areas-tree-province'] });
+      setIsStatusDialogOpen(false);
+      setSelectedArea(null);
+    },
+  });
+
+  // 防抖搜索
+  const debouncedSearch = useCallback(
+    debounce((keyword: string) => {
+      setKeyword(keyword);
+    }, 300),
+    []
+  );
+
+  // 处理筛选变化
+  const handleFilterChange = (filterType: string, value: string) => {
+    switch (filterType) {
+      case 'level':
+        setLevel(value);
+        break;
+      case 'isDisabled':
+        setIsDisabled(value);
+        break;
+    }
+  };
+
+  // 处理创建省市区
+  const handleCreateArea = async (data: CreateAreaInput | UpdateAreaInput) => {
+    await createMutation.mutateAsync(data as CreateAreaInput);
+  };
+
+  // 处理更新省市区
+  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: AreaNode) => {
+    // 将 AreaNode 转换为 AreaResponse
+    const areaResponse: AreaResponse = {
+      ...area,
+      isDeleted: 0,
+      createdAt: new Date().toISOString(),
+      updatedAt: new Date().toISOString(),
+      createdBy: null,
+      updatedBy: null
+    };
+    setSelectedArea(areaResponse);
+    setIsEditDialogOpen(true);
+  };
+
+  // 打开删除对话框
+  const handleDelete = (area: AreaNode) => {
+    // 将 AreaNode 转换为 AreaResponse
+    const areaResponse: AreaResponse = {
+      ...area,
+      isDeleted: 0,
+      createdAt: new Date().toISOString(),
+      updatedAt: new Date().toISOString(),
+      createdBy: null,
+      updatedBy: null
+    };
+    setSelectedArea(areaResponse);
+    setIsDeleteDialogOpen(true);
+  };
+
+  // 打开状态切换对话框
+  const handleToggleStatusDialog = (area: AreaNode) => {
+    // 将 AreaNode 转换为 AreaResponse
+    const areaResponse: AreaResponse = {
+      ...area,
+      isDeleted: 0,
+      createdAt: new Date().toISOString(),
+      updatedAt: new Date().toISOString(),
+      createdBy: null,
+      updatedBy: null
+    };
+    setSelectedArea(areaResponse);
+    setIsStatusDialogOpen(true);
+  };
+
+  // 切换节点展开状态
+  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');
+    setIsDisabled('all');
+  };
+
+  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"
+                    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)}>
+                <SelectTrigger className="w-[180px]">
+                  <SelectValue placeholder="选择层级" />
+                </SelectTrigger>
+                <SelectContent>
+                  <SelectItem value="all">全部层级</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="all">全部状态</SelectItem>
+                  <SelectItem value="0">启用</SelectItem>
+                  <SelectItem value="1">禁用</SelectItem>
+                </SelectContent>
+              </Select>
+            </div>
+          </div>
+
+          {/* 树形视图 */}
+          {isProvinceLoading ? (
+            <div className="text-center py-8">
+              加载中...
+            </div>
+          ) : !provinceData || provinceData.length === 0 ? (
+            <div className="text-center py-8">
+              暂无数据
+            </div>
+          ) : (
+            <AreaTreeAsync
+              areas={provinceData}
+              expandedNodes={expandedNodes}
+              onToggleNode={handleToggleNode}
+              onEdit={handleEdit}
+              onDelete={handleDelete}
+              onToggleStatus={handleToggleStatusDialog}
+            />
+          )}
+        </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)}
+            defaultLevel={1} // 默认设置为省级
+          />
+        </DialogContent>
+      </Dialog>
+
+      {/* 编辑省市区对话框 */}
+      <Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
+        <DialogContent className="max-w-2xl">
+          <DialogHeader>
+            <DialogTitle>编辑省市区</DialogTitle>
+            <DialogDescription>
+              修改省市区信息
+            </DialogDescription>
+          </DialogHeader>
+          {selectedArea && (
+            <AreaForm
+              area={{
+                id: selectedArea.id,
+                parentId: selectedArea.parentId || 0,
+                name: selectedArea.name,
+                level: selectedArea.level,
+                code: selectedArea.code,
+                isDisabled: selectedArea.isDisabled
+              }}
+              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>
+  );
+};

+ 2 - 2
web/src/client/admin/routes.tsx

@@ -9,10 +9,10 @@ import { LoginPage } from './pages/Login';
 import { FilesPage } from './pages/Files';
 import { ActivitiesPage } from './pages/Activities';
 import { RoutesPage } from './pages/Routes';
-import { AreasPage } from './pages/Areas';
 import { LocationsPage } from './pages/Locations';
 import { PassengersPage } from './pages/Passengers';
 import { OrdersPage } from './pages/Orders';
+import { AreasTreePage } from './pages/AreasTreePage';
 
 export const router = createBrowserRouter([
   {
@@ -62,7 +62,7 @@ export const router = createBrowserRouter([
       },
       {
         path: 'areas',
-        element: <AreasPage />,
+        element: <AreasTreePage />,
         errorElement: <ErrorPage />
       },
       {