Sfoglia il codice sorgente

✨ feat(admin): 添加地区选择组件

- 实现省市区三级联动选择功能
- 使用React Query获取地区数据并缓存
- 支持禁用、必填和自定义样式属性
- 提供onChange回调返回选中的地区ID
- 适配响应式布局,在移动端垂直排列,桌面端水平排列
yourname 1 mese fa
parent
commit
60a9bd303d
1 ha cambiato i file con 258 aggiunte e 0 eliminazioni
  1. 258 0
      web/src/client/admin/components/AreaSelect.tsx

+ 258 - 0
web/src/client/admin/components/AreaSelect.tsx

@@ -0,0 +1,258 @@
+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,
+          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 handleProvinceChange = (provinceId: string) => {
+    const id = provinceId && provinceId !== 'none' ? Number(provinceId) : undefined;
+    setSelectedProvince(id);
+    setSelectedCity(undefined);
+    setSelectedDistrict(undefined);
+
+    onChange?.({
+      provinceId: id,
+      cityId: undefined,
+      districtId: undefined
+    });
+  };
+
+  // 处理城市选择
+  const handleCityChange = (cityId: string) => {
+    const id = cityId && cityId !== 'none' ? Number(cityId) : undefined;
+    setSelectedCity(id);
+    setSelectedDistrict(undefined);
+
+    onChange?.({
+      provinceId: selectedProvince,
+      cityId: id,
+      districtId: undefined
+    });
+  };
+
+  // 处理区县选择
+  const handleDistrictChange = (districtId: string) => {
+    const id = districtId && districtId !== 'none' ? 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="none">请选择省份</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="none">请选择城市</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="none">请选区县</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>
+  );
+};