Bladeren bron

✨ feat(admin): 添加四级地址选择组件及相关页面更新

- 新增AreaSelect4Level组件,支持省/市/区/乡镇四级地址联动选择
- 实现基于React Query的数据获取与缓存策略,优化地址选择体验
- 为DeliveryAddresses页面替换原有AreaSelect为新的四级地址选择组件
- 添加类型定义和组件属性,支持自定义配置与事件回调
- 实现地址选择状态管理,包括外部值同步和内部状态重置逻辑
yourname 1 maand geleden
bovenliggende
commit
4e91b15a52

+ 350 - 0
web/src/client/admin/components/AreaSelect4Level.tsx

@@ -0,0 +1,350 @@
+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 AreaSelect4LevelProps {
+  provinceValue?: number;
+  cityValue?: number;
+  districtValue?: number;
+  townValue?: number;
+  onProvinceChange?: (value: number) => void;
+  onCityChange?: (value: number) => void;
+  onDistrictChange?: (value: number) => void;
+  onTownChange?: (value: number) => void;
+  disabled?: boolean;
+  required?: boolean;
+  className?: string;
+  showLabels?: boolean;
+}
+
+export const AreaSelect4Level: React.FC<AreaSelect4LevelProps> = ({
+  provinceValue = 0,
+  cityValue = 0,
+  districtValue = 0,
+  townValue = 0,
+  onProvinceChange,
+  onCityChange,
+  onDistrictChange,
+  onTownChange,
+  disabled = false,
+  required = false,
+  className = '',
+  showLabels = true
+}) => {
+  const [selectedProvince, setSelectedProvince] = useState<number>(provinceValue);
+  const [selectedCity, setSelectedCity] = useState<number>(cityValue);
+  const [selectedDistrict, setSelectedDistrict] = useState<number>(districtValue);
+  const [selectedTown, setSelectedTown] = useState<number>(townValue);
+
+  // 查询省份列表
+  const { data: provinces, isLoading: isLoadingProvinces } = useQuery({
+    queryKey: ['areas', 'provinces'],
+    queryFn: async () => {
+      const res = await areaClient.$get({
+        query: {
+          page: 1,
+          pageSize: 100,
+          filters: JSON.stringify({
+            level: 1,
+            isDisabled: 0
+          }),
+          sortBy: 'id',
+          sortOrder: 'ASC'
+        }
+      });
+      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,
+          filters: JSON.stringify({
+            level: 2,
+            parentId: selectedProvince,
+            isDisabled: 0
+          }),
+          sortBy: 'id',
+          sortOrder: 'ASC'
+        }
+      });
+      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,
+          filters: JSON.stringify({
+            level: 3,
+            parentId: selectedCity,
+            isDisabled: 0
+          }),
+          sortBy: 'id',
+          sortOrder: 'ASC'
+        }
+      });
+      if (res.status !== 200) throw new Error('获取区县列表失败');
+      return await res.json();
+    },
+    staleTime: 10 * 60 * 1000,
+    gcTime: 30 * 60 * 1000,
+    enabled: !!selectedCity,
+  });
+
+  // 查询乡镇列表
+  const { data: towns, isLoading: isLoadingTowns } = useQuery({
+    queryKey: ['areas', 'towns', selectedDistrict],
+    queryFn: async () => {
+      if (!selectedDistrict) return { data: [] };
+      const res = await areaClient.$get({
+        query: {
+          page: 1,
+          pageSize: 100,
+          filters: JSON.stringify({
+            level: 4,
+            parentId: selectedDistrict,
+            isDisabled: 0
+          }),
+          sortBy: 'id',
+          sortOrder: 'ASC'
+        }
+      });
+      if (res.status !== 200) throw new Error('获取乡镇列表失败');
+      return await res.json();
+    },
+    staleTime: 10 * 60 * 1000,
+    gcTime: 30 * 60 * 1000,
+    enabled: !!selectedDistrict,
+  });
+
+  // 处理省份选择
+  const handleProvinceChange = (provinceId: string) => {
+    const id = provinceId && provinceId !== 'none' ? Number(provinceId) : 0;
+    setSelectedProvince(id);
+    setSelectedCity(0);
+    setSelectedDistrict(0);
+    setSelectedTown(0);
+    onProvinceChange?.(id);
+    onCityChange?.(0);
+    onDistrictChange?.(0);
+    onTownChange?.(0);
+  };
+
+  // 处理城市选择
+  const handleCityChange = (cityId: string) => {
+    const id = cityId && cityId !== 'none' ? Number(cityId) : 0;
+    setSelectedCity(id);
+    setSelectedDistrict(0);
+    setSelectedTown(0);
+    onCityChange?.(id);
+    onDistrictChange?.(0);
+    onTownChange?.(0);
+  };
+
+  // 处理区县选择
+  const handleDistrictChange = (districtId: string) => {
+    const id = districtId && districtId !== 'none' ? Number(districtId) : 0;
+    setSelectedDistrict(id);
+    setSelectedTown(0);
+    onDistrictChange?.(id);
+    onTownChange?.(0);
+  };
+
+  // 处理乡镇选择
+  const handleTownChange = (townId: string) => {
+    const id = townId && townId !== 'none' ? Number(townId) : 0;
+    setSelectedTown(id);
+    onTownChange?.(id);
+  };
+
+  // 同步外部值变化
+  useEffect(() => {
+    setSelectedProvince(provinceValue);
+  }, [provinceValue]);
+
+  useEffect(() => {
+    setSelectedCity(cityValue);
+  }, [cityValue]);
+
+  useEffect(() => {
+    setSelectedDistrict(districtValue);
+  }, [districtValue]);
+
+  useEffect(() => {
+    setSelectedTown(townValue);
+  }, [townValue]);
+
+  return (
+    <div className={`grid grid-cols-1 md:grid-cols-4 gap-4 ${className}`}>
+      {/* 省份选择 */}
+      <div>
+        <FormItem>
+          {showLabels && (
+            <FormLabel>
+              省份{required && <span className="text-destructive">*</span>}
+            </FormLabel>
+          )}
+          <Select
+            value={selectedProvince?.toString() || '0'}
+            onValueChange={handleProvinceChange}
+            disabled={disabled || isLoadingProvinces}
+          >
+            <FormControl>
+              <SelectTrigger>
+                <SelectValue placeholder="选择省份" />
+              </SelectTrigger>
+            </FormControl>
+            <SelectContent>
+              <SelectItem value="0">请选择省份</SelectItem>
+              {provinces?.data.map((province: AreaResponse) => (
+                <SelectItem key={province.id} value={province.id.toString()}>
+                  {province.name}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+          {showLabels && (
+            <FormDescription>
+              选择所在省份
+            </FormDescription>
+          )}
+          <FormMessage />
+        </FormItem>
+      </div>
+
+      {/* 城市选择 */}
+      <div>
+        <FormItem>
+          {showLabels && (
+            <FormLabel>
+              城市{required && selectedProvince && <span className="text-destructive">*</span>}
+            </FormLabel>
+          )}
+          <Select
+            value={selectedCity?.toString() || '0'}
+            onValueChange={handleCityChange}
+            disabled={disabled || !selectedProvince || isLoadingCities}
+          >
+            <FormControl>
+              <SelectTrigger>
+                <SelectValue placeholder="选择城市" />
+              </SelectTrigger>
+            </FormControl>
+            <SelectContent>
+              <SelectItem value="0">请选择城市</SelectItem>
+              {cities?.data.map((city: AreaResponse) => (
+                <SelectItem key={city.id} value={city.id.toString()}>
+                  {city.name}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+          {showLabels && (
+            <FormDescription>
+              选择所在城市
+            </FormDescription>
+          )}
+          <FormMessage />
+        </FormItem>
+      </div>
+
+      {/* 区县选择 */}
+      <div>
+        <FormItem>
+          {showLabels && (
+            <FormLabel>
+              区县{required && selectedCity && <span className="text-destructive">*</span>}
+            </FormLabel>
+          )}
+          <Select
+            value={selectedDistrict?.toString() || '0'}
+            onValueChange={handleDistrictChange}
+            disabled={disabled || !selectedCity || isLoadingDistricts}
+          >
+            <FormControl>
+              <SelectTrigger>
+                <SelectValue placeholder="选择区县" />
+              </SelectTrigger>
+            </FormControl>
+            <SelectContent>
+              <SelectItem value="0">请选区县</SelectItem>
+              {districts?.data.map((district: AreaResponse) => (
+                <SelectItem key={district.id} value={district.id.toString()}>
+                  {district.name}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+          {showLabels && (
+            <FormDescription>
+              选择所在区县
+            </FormDescription>
+          )}
+          <FormMessage />
+        </FormItem>
+      </div>
+
+      {/* 乡镇选择 */}
+      <div>
+        <FormItem>
+          {showLabels && (
+            <FormLabel>
+              乡镇{required && selectedDistrict && <span className="text-destructive">*</span>}
+            </FormLabel>
+          )}
+          <Select
+            value={selectedTown?.toString() || '0'}
+            onValueChange={handleTownChange}
+            disabled={disabled || !selectedDistrict || isLoadingTowns}
+          >
+            <FormControl>
+              <SelectTrigger>
+                <SelectValue placeholder="选择乡镇" />
+              </SelectTrigger>
+            </FormControl>
+            <SelectContent>
+              <SelectItem value="0">请选择乡镇</SelectItem>
+              {towns?.data.map((town: AreaResponse) => (
+                <SelectItem key={town.id} value={town.id.toString()}>
+                  {town.name}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+          {showLabels && (
+            <FormDescription>
+              选择所在乡镇
+            </FormDescription>
+          )}
+          <FormMessage />
+        </FormItem>
+      </div>
+    </div>
+  );
+};

+ 3 - 3
web/src/client/admin/pages/DeliveryAddresses.tsx

@@ -21,7 +21,7 @@ import { Switch } from '@/client/components/ui/switch';
 import { Skeleton } from '@/client/components/ui/skeleton';
 import { DataTablePagination } from '@/client/admin/components/DataTablePagination';
 import { UserSelector } from '@/client/admin/components/UserSelector';
-import { AreaSelect } from '@/client/admin/components/AreaSelect';
+import { AreaSelect4Level } from '@/client/admin/components/AreaSelect4Level';
 
 // 类型定义
 type DeliveryAddressResponse = InferResponseType<typeof deliveryAddressClient.$get, 200>['data'][0];
@@ -456,7 +456,7 @@ export const DeliveryAddressesPage = () => {
 
                 <div className="space-y-2">
                   <FormLabel>四级地址选择<span className="text-red-500 ml-1">*</span></FormLabel>
-                  <AreaSelect
+                  <AreaSelect4Level
                     provinceValue={createForm.watch('receiverProvince') || 0}
                     cityValue={createForm.watch('receiverCity') || 0}
                     districtValue={createForm.watch('receiverDistrict') || 0}
@@ -545,7 +545,7 @@ export const DeliveryAddressesPage = () => {
 
                 <div className="space-y-2">
                   <FormLabel>四级地址选择<span className="text-red-500 ml-1">*</span></FormLabel>
-                  <AreaSelect
+                  <AreaSelect4Level
                     provinceValue={updateForm.watch('receiverProvince') || 0}
                     cityValue={updateForm.watch('receiverCity') || 0}
                     districtValue={updateForm.watch('receiverDistrict') || 0}