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

✨ feat(areas): 实现区域层级智能管理功能

- 重构AreaForm组件,支持通过smartLevel和smartParentId实现智能预填
- 新增区域层级显示名称辅助函数getLevelDisplayName
- 优化AreaTreeAsync组件,添加onAddChild回调支持子节点创建
- 在TreeNode组件中添加"新增子节点"按钮,根据层级显示不同文本
- 新增AreasTreePage页面的子节点创建对话框,支持层级递进创建
- 改进表单默认值设置,使用空值合并运算符处理默认层级

✨ refactor(areas): 简化区域表单组件逻辑

- 移除AreaForm中的useQuery和useEffect数据获取逻辑
- 删除AreaSelect组件依赖,简化表单结构
- 将层级选择改为只读显示,通过智能预填控制层级
- 优化父级区域选择为只读输入框,通过智能预填设置父级ID
- 清理组件props,将defaultLevel重命名为更语义化的smartLevel

💄 style(areas): 优化区域管理界面样式

- 改进TreeNode组件样式,添加group类实现悬停效果
- 统一操作按钮样式为outline变体,提升视觉一致性
- 优化对话框标题和描述文本,增强用户引导
- 调整表单布局和间距,提升整体视觉体验
yourname 3 месяцев назад
Родитель
Сommit
8c7418f9d1

+ 50 - 144
web/src/client/admin/components/AreaForm.tsx

@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React from 'react';
 import { useForm } from 'react-hook-form';
 import { useForm } from 'react-hook-form';
 import { zodResolver } from '@hookform/resolvers/zod';
 import { zodResolver } from '@hookform/resolvers/zod';
 import { Button } from '@/client/components/ui/button';
 import { Button } from '@/client/components/ui/button';
@@ -9,74 +9,41 @@ import { createAreaSchema, updateAreaSchema } from '@d8d/server/modules/areas/ar
 import type { CreateAreaInput, UpdateAreaInput } from '@d8d/server/modules/areas/area.schema';
 import type { CreateAreaInput, UpdateAreaInput } from '@d8d/server/modules/areas/area.schema';
 import { AreaLevel } from '@d8d/server/modules/areas/area.entity';
 import { AreaLevel } from '@d8d/server/modules/areas/area.entity';
 import { DisabledStatus } from '@/share/types';
 import { DisabledStatus } from '@/share/types';
-import { AreaSelect } from './AreaSelect';
-import { useQuery } from '@tanstack/react-query';
-import { areaClient } from '@/client/api';
 
 
 interface AreaFormProps {
 interface AreaFormProps {
   area?: UpdateAreaInput & { id?: number };
   area?: UpdateAreaInput & { id?: number };
   onSubmit: (data: CreateAreaInput | UpdateAreaInput) => Promise<void>;
   onSubmit: (data: CreateAreaInput | UpdateAreaInput) => Promise<void>;
   onCancel: () => void;
   onCancel: () => void;
   isLoading?: boolean;
   isLoading?: boolean;
-  defaultLevel?: number;
+  /** 智能预填的层级 */
+  smartLevel?: number;
+  /** 智能预填的父级ID */
+  smartParentId?: number;
 }
 }
 
 
+// 辅助函数:根据层级值获取显示名称
+const getLevelDisplayName = (level: number | undefined): string => {
+  switch (level) {
+    case AreaLevel.PROVINCE:
+      return '省/直辖市';
+    case AreaLevel.CITY:
+      return '市';
+    case AreaLevel.DISTRICT:
+      return '区/县';
+    default:
+      return '未知层级';
+  }
+};
+
 export const AreaForm: React.FC<AreaFormProps> = ({
 export const AreaForm: React.FC<AreaFormProps> = ({
   area,
   area,
   onSubmit,
   onSubmit,
   onCancel,
   onCancel,
   isLoading = false,
   isLoading = false,
-  defaultLevel
+  smartLevel,
+  smartParentId
 }) => {
 }) => {
   const isEditing = !!area;
   const isEditing = !!area;
-  const [parentAreaInfo, setParentAreaInfo] = useState<{
-    provinceId?: number;
-    cityId?: number;
-  }>({});
-
-  // 查询父级区域的完整层级信息
-  const { data: parentAreaData } = useQuery({
-    queryKey: ['area', 'parent', area?.parentId],
-    queryFn: async () => {
-      if (!area?.parentId || area.parentId === 0) return null;
-      const res = await areaClient[':id'].$get({
-        param: { id: area.parentId }
-      });
-      if (res.status !== 200) throw new Error('获取父级区域信息失败');
-      return await res.json();
-    },
-    enabled: isEditing && !!area?.parentId && area.parentId > 0,
-    staleTime: 5 * 60 * 1000,
-    gcTime: 10 * 60 * 1000,
-  });
-
-  // 根据父级区域信息设置层级关系
-  useEffect(() => {
-    if (parentAreaData) {
-      const parentArea = parentAreaData;
-      if (parentArea.level === AreaLevel.PROVINCE) {
-        // 父级是省份,当前区域是城市
-        setParentAreaInfo({
-          provinceId: parentArea.id
-        });
-      } else if (parentArea.level === AreaLevel.CITY) {
-        // 父级是城市,当前区域是区县,需要查询城市的父级省份
-        const fetchProvinceId = async () => {
-          const res = await areaClient[':id'].$get({
-            param: { id: parentArea.parentId! }
-          });
-          if (res.status === 200) {
-            const provinceResult = await res.json();
-            setParentAreaInfo({
-              provinceId: provinceResult.id,
-              cityId: parentArea.id
-            });
-          }
-        };
-        fetchProvinceId();
-      }
-    }
-  }, [parentAreaData]);
 
 
   const form = useForm<CreateAreaInput | UpdateAreaInput>({
   const form = useForm<CreateAreaInput | UpdateAreaInput>({
     resolver: zodResolver(isEditing ? updateAreaSchema : createAreaSchema),
     resolver: zodResolver(isEditing ? updateAreaSchema : createAreaSchema),
@@ -87,9 +54,9 @@ export const AreaForm: React.FC<AreaFormProps> = ({
       code: area.code,
       code: area.code,
       isDisabled: area.isDisabled,
       isDisabled: area.isDisabled,
     } : {
     } : {
-      parentId: 0,
+      parentId: smartParentId || 0,
       name: '',
       name: '',
-      level: defaultLevel || AreaLevel.PROVINCE,
+      level: smartLevel ?? AreaLevel.PROVINCE,
       code: '',
       code: '',
       isDisabled: DisabledStatus.ENABLED,
       isDisabled: DisabledStatus.ENABLED,
     },
     },
@@ -104,110 +71,49 @@ export const AreaForm: React.FC<AreaFormProps> = ({
     <Form {...form}>
     <Form {...form}>
       <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
       <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
         <div className="grid grid-cols-1 gap-6">
         <div className="grid grid-cols-1 gap-6">
-          {/* 层级选择 */}
+          {/* 层级显示(只读) */}
           <FormField
           <FormField
             control={form.control}
             control={form.control}
             name="level"
             name="level"
             render={({ field }) => (
             render={({ field }) => (
               <FormItem>
               <FormItem>
                 <FormLabel>层级</FormLabel>
                 <FormLabel>层级</FormLabel>
-                <Select onValueChange={(value) => field.onChange(Number(value))} defaultValue={field.value?.toString()}>
-                  <FormControl>
-                    <SelectTrigger>
-                      <SelectValue placeholder="选择层级" />
-                    </SelectTrigger>
-                  </FormControl>
-                  <SelectContent>
-                    <SelectItem value={AreaLevel.PROVINCE.toString()}>
-                      省/直辖市
-                    </SelectItem>
-                    <SelectItem value={AreaLevel.CITY.toString()}>
-                      市
-                    </SelectItem>
-                    <SelectItem value={AreaLevel.DISTRICT.toString()}>
-                      区/县
-                    </SelectItem>
-                  </SelectContent>
-                </Select>
+                <FormControl>
+                  <Input
+                    value={getLevelDisplayName(field.value)}
+                    disabled
+                    className="bg-muted"
+                  />
+                </FormControl>
                 <FormDescription>
                 <FormDescription>
-                  选择省市区层级
+                  根据操作上下文自动设置的层级
                 </FormDescription>
                 </FormDescription>
                 <FormMessage />
                 <FormMessage />
               </FormItem>
               </FormItem>
             )}
             )}
           />
           />
 
 
-          {/* 父级区域选择 */}
+          {/* 父级区域显示(只读) */}
           <FormField
           <FormField
             control={form.control}
             control={form.control}
             name="parentId"
             name="parentId"
-            render={({ field }) => {
-              const level = form.watch('level');
-              const showParentSelect = level !== AreaLevel.PROVINCE;
-
-              // 根据当前层级和编辑状态设置AreaSelect的值
-              const getAreaSelectValue = () => {
-                if (!isEditing || !area) {
-                  return {};
-                }
-
-                if (level === AreaLevel.CITY) {
-                  // 城市级别:需要省份ID
-                  return { provinceId: parentAreaInfo.provinceId };
-                } else if (level === AreaLevel.DISTRICT) {
-                  // 区县级别:需要省份ID和城市ID
-                  return {
-                    provinceId: parentAreaInfo.provinceId,
-                    cityId: parentAreaInfo.cityId
-                  };
-                }
-                return {};
-              };
-
-              return (
-                <FormItem>
-                  <FormLabel>父级区域</FormLabel>
-                  {showParentSelect ? (
-                    <>
-                      <FormControl>
-                        <AreaSelect
-                          value={getAreaSelectValue()}
-                          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>
-              );
-            }}
+            render={({ field }) => (
+              <FormItem>
+                <FormLabel>父级区域</FormLabel>
+                <FormControl>
+                  <Input
+                    type="number"
+                    value={field.value || 0}
+                    disabled
+                    className="bg-muted"
+                  />
+                </FormControl>
+                <FormDescription>
+                  根据操作上下文自动设置的父级区域ID
+                </FormDescription>
+                <FormMessage />
+              </FormItem>
+            )}
           />
           />
 
 
           {/* 区域名称 */}
           {/* 区域名称 */}

+ 29 - 7
web/src/client/admin/components/AreaTreeAsync.tsx

@@ -23,6 +23,7 @@ interface AreaTreeAsyncProps {
   onEdit: (area: AreaNode) => void;
   onEdit: (area: AreaNode) => void;
   onDelete: (area: AreaNode) => void;
   onDelete: (area: AreaNode) => void;
   onToggleStatus: (area: AreaNode) => void;
   onToggleStatus: (area: AreaNode) => void;
+  onAddChild: (area: AreaNode) => void;
 }
 }
 
 
 // 子树加载组件
 // 子树加载组件
@@ -36,6 +37,7 @@ interface SubTreeLoaderProps {
   onEdit: (area: AreaNode) => void;
   onEdit: (area: AreaNode) => void;
   onDelete: (area: AreaNode) => void;
   onDelete: (area: AreaNode) => void;
   onToggleStatus: (area: AreaNode) => void;
   onToggleStatus: (area: AreaNode) => void;
+  onAddChild: (area: AreaNode) => void;
 }
 }
 
 
 const SubTreeLoader: React.FC<SubTreeLoaderProps> = ({
 const SubTreeLoader: React.FC<SubTreeLoaderProps> = ({
@@ -47,7 +49,8 @@ const SubTreeLoader: React.FC<SubTreeLoaderProps> = ({
   onToggleNode,
   onToggleNode,
   onEdit,
   onEdit,
   onDelete,
   onDelete,
-  onToggleStatus
+  onToggleStatus,
+  onAddChild
 }) => {
 }) => {
   const { data: subTreeData, isLoading: isSubTreeLoading } = useQuery({
   const { data: subTreeData, isLoading: isSubTreeLoading } = useQuery({
     queryKey: ['areas-subtree', nodeId],
     queryKey: ['areas-subtree', nodeId],
@@ -104,6 +107,7 @@ const SubTreeLoader: React.FC<SubTreeLoaderProps> = ({
           onEdit={onEdit}
           onEdit={onEdit}
           onDelete={onDelete}
           onDelete={onDelete}
           onToggleStatus={onToggleStatus}
           onToggleStatus={onToggleStatus}
+          onAddChild={onAddChild}
         />
         />
       ))}
       ))}
     </div>
     </div>
@@ -119,6 +123,7 @@ interface TreeNodeProps {
   onEdit: (area: AreaNode) => void;
   onEdit: (area: AreaNode) => void;
   onDelete: (area: AreaNode) => void;
   onDelete: (area: AreaNode) => void;
   onToggleStatus: (area: AreaNode) => void;
   onToggleStatus: (area: AreaNode) => void;
+  onAddChild: (area: AreaNode) => void;
 }
 }
 
 
 const TreeNode: React.FC<TreeNodeProps> = ({
 const TreeNode: React.FC<TreeNodeProps> = ({
@@ -128,7 +133,8 @@ const TreeNode: React.FC<TreeNodeProps> = ({
   onToggleNode,
   onToggleNode,
   onEdit,
   onEdit,
   onDelete,
   onDelete,
-  onToggleStatus
+  onToggleStatus,
+  onAddChild
 }) => {
 }) => {
   const isExpanded = expandedNodes.has(node.id);
   const isExpanded = expandedNodes.has(node.id);
   const isDisabled = node.isDisabled === 1;
   const isDisabled = node.isDisabled === 1;
@@ -139,7 +145,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
       {/* 节点行 */}
       {/* 节点行 */}
       <div
       <div
         className={cn(
         className={cn(
-          "flex items-center gap-2 py-2 px-3 hover:bg-muted/50 cursor-pointer border-b",
+          "group flex items-center gap-2 py-2 px-3 hover:bg-muted/50 cursor-pointer border-b",
           depth > 0 && "ml-6"
           depth > 0 && "ml-6"
         )}
         )}
         style={{ marginLeft: `${depth * 24}px` }}
         style={{ marginLeft: `${depth * 24}px` }}
@@ -192,8 +198,21 @@ const TreeNode: React.FC<TreeNodeProps> = ({
 
 
         {/* 操作按钮 */}
         {/* 操作按钮 */}
         <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
         <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
+          {/* 新增子节点按钮 - 根据层级显示不同文本 */}
+          {node.level < 3 && (
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={(e) => {
+                e.stopPropagation();
+                onAddChild(node);
+              }}
+            >
+              {node.level === 1 ? '新增市' : '新增区'}
+            </Button>
+          )}
           <Button
           <Button
-            variant="ghost"
+            variant="outline"
             size="sm"
             size="sm"
             onClick={(e) => {
             onClick={(e) => {
               e.stopPropagation();
               e.stopPropagation();
@@ -203,7 +222,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
             编辑
             编辑
           </Button>
           </Button>
           <Button
           <Button
-            variant="ghost"
+            variant="outline"
             size="sm"
             size="sm"
             onClick={(e) => {
             onClick={(e) => {
               e.stopPropagation();
               e.stopPropagation();
@@ -213,7 +232,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
             {isDisabled ? '启用' : '禁用'}
             {isDisabled ? '启用' : '禁用'}
           </Button>
           </Button>
           <Button
           <Button
-            variant="ghost"
+            variant="outline"
             size="sm"
             size="sm"
             onClick={(e) => {
             onClick={(e) => {
               e.stopPropagation();
               e.stopPropagation();
@@ -237,6 +256,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
           onEdit={onEdit}
           onEdit={onEdit}
           onDelete={onDelete}
           onDelete={onDelete}
           onToggleStatus={onToggleStatus}
           onToggleStatus={onToggleStatus}
+          onAddChild={onAddChild}
         />
         />
       )}
       )}
     </div>
     </div>
@@ -249,7 +269,8 @@ export const AreaTreeAsync: React.FC<AreaTreeAsyncProps> = ({
   onToggleNode,
   onToggleNode,
   onEdit,
   onEdit,
   onDelete,
   onDelete,
-  onToggleStatus
+  onToggleStatus,
+  onAddChild
 }) => {
 }) => {
   return (
   return (
     <div className="border rounded-lg bg-background">
     <div className="border rounded-lg bg-background">
@@ -263,6 +284,7 @@ export const AreaTreeAsync: React.FC<AreaTreeAsyncProps> = ({
           onEdit={onEdit}
           onEdit={onEdit}
           onDelete={onDelete}
           onDelete={onDelete}
           onToggleStatus={onToggleStatus}
           onToggleStatus={onToggleStatus}
+          onAddChild={onAddChild}
         />
         />
       ))}
       ))}
     </div>
     </div>

+ 41 - 1
web/src/client/admin/pages/AreasTreePage.tsx

@@ -50,6 +50,8 @@ export const AreasTreePage: React.FC = () => {
   const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
   const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
   const [isStatusDialogOpen, setIsStatusDialogOpen] = useState(false);
   const [isStatusDialogOpen, setIsStatusDialogOpen] = useState(false);
   const [selectedArea, setSelectedArea] = useState<AreaResponse | null>(null);
   const [selectedArea, setSelectedArea] = useState<AreaResponse | null>(null);
+  const [isAddChildDialogOpen, setIsAddChildDialogOpen] = useState(false);
+  const [parentAreaForChild, setParentAreaForChild] = useState<AreaNode | null>(null);
 
 
   // 查询省级数据(异步加载)
   // 查询省级数据(异步加载)
   const { data: provinceData, isLoading: isProvinceLoading } = useQuery({
   const { data: provinceData, isLoading: isProvinceLoading } = useQuery({
@@ -157,6 +159,17 @@ export const AreasTreePage: React.FC = () => {
     await toggleStatusMutation.mutateAsync({ id: selectedArea.id, isDisabled });
     await toggleStatusMutation.mutateAsync({ id: selectedArea.id, isDisabled });
   };
   };
 
 
+  // 处理新增子节点
+  const handleAddChild = (area: AreaNode) => {
+    setParentAreaForChild(area);
+    setIsAddChildDialogOpen(true);
+  };
+
+  // 处理创建子节点
+  const handleCreateChildArea = async (data: CreateAreaInput | UpdateAreaInput) => {
+    await createMutation.mutateAsync(data as CreateAreaInput);
+  };
+
   // 打开编辑对话框
   // 打开编辑对话框
   const handleEdit = (area: AreaNode) => {
   const handleEdit = (area: AreaNode) => {
     // 将 AreaNode 转换为 AreaResponse
     // 将 AreaNode 转换为 AreaResponse
@@ -256,6 +269,7 @@ export const AreasTreePage: React.FC = () => {
               onEdit={handleEdit}
               onEdit={handleEdit}
               onDelete={handleDelete}
               onDelete={handleDelete}
               onToggleStatus={handleToggleStatusDialog}
               onToggleStatus={handleToggleStatusDialog}
+              onAddChild={handleAddChild}
             />
             />
           )}
           )}
         </CardContent>
         </CardContent>
@@ -274,7 +288,7 @@ export const AreasTreePage: React.FC = () => {
             onSubmit={handleCreateArea}
             onSubmit={handleCreateArea}
             isLoading={createMutation.isPending}
             isLoading={createMutation.isPending}
             onCancel={() => setIsCreateDialogOpen(false)}
             onCancel={() => setIsCreateDialogOpen(false)}
-            defaultLevel={1} // 默认设置为省级
+            smartLevel={1} // 默认设置为省级
           />
           />
         </DialogContent>
         </DialogContent>
       </Dialog>
       </Dialog>
@@ -309,6 +323,32 @@ export const AreasTreePage: React.FC = () => {
         </DialogContent>
         </DialogContent>
       </Dialog>
       </Dialog>
 
 
+      {/* 新增子节点对话框 */}
+      <Dialog open={isAddChildDialogOpen} onOpenChange={setIsAddChildDialogOpen}>
+        <DialogContent className="max-w-2xl">
+          <DialogHeader>
+            <DialogTitle>
+              {parentAreaForChild?.level === 1 ? '新增市' : '新增区'}
+            </DialogTitle>
+            <DialogDescription>
+              {parentAreaForChild?.level === 1
+                ? `在省份 "${parentAreaForChild?.name}" 下新增市`
+                : `在城市 "${parentAreaForChild?.name}" 下新增区/县`}
+            </DialogDescription>
+          </DialogHeader>
+          <AreaForm
+            onSubmit={handleCreateChildArea}
+            isLoading={createMutation.isPending}
+            onCancel={() => {
+              setIsAddChildDialogOpen(false);
+              setParentAreaForChild(null);
+            }}
+            smartLevel={(parentAreaForChild?.level ?? 0) + 1}
+            smartParentId={parentAreaForChild?.id}
+          />
+        </DialogContent>
+      </Dialog>
+
       {/* 删除确认对话框 */}
       {/* 删除确认对话框 */}
       <AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
       <AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
         <AlertDialogContent>
         <AlertDialogContent>