فهرست منبع

✨ feat(delivery): add city cascade selector component

- 创建城市级联选择器组件CityCascadeSelector,支持省市区街道四级联动
- 在收货地址管理页面集成级联选择器,替换原有的手动输入ID方式
- 优化地址显示格式,整合省市区街道信息为完整地址
- 添加地址格式化函数formatAddressDisplay,提高地址可读性
- 调整收货地址API关联关系,新增province、city、district、town关联查询

✨ feat(entity): add address relationships to delivery address entity

- 为DeliveryAddress实体添加province、city、district、town关联关系
- 更新DeliveryAddressSchema,包含地址关联信息
- 调整DeliveryAddressService查询方法,添加地址关联关系查询

✨ feat(api): update delivery address API to include address relations

- 更新收货地址CRUD路由配置,添加地址关联关系查询
- 调整findByUser和findDefaultByUser方法,包含完整地址关联信息
- 优化地址数据查询性能,添加适当的查询条件和分页参数
yourname 4 ماه پیش
والد
کامیت
aea51e204c

+ 203 - 0
src/client/admin-shadcn/components/CityCascadeSelector.tsx

@@ -0,0 +1,203 @@
+import React, { useState, useEffect } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/client/components/ui/select';
+import { FormItem, FormLabel, FormControl, FormMessage } from '@/client/components/ui/form';
+import { cityClient } from '@/client/api';
+
+interface CityCascadeSelectorProps {
+  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;
+  showLabels?: boolean;
+}
+
+interface City {
+  id: number;
+  name: string;
+  level: number;
+  parentId: number;
+}
+
+export const CityCascadeSelector: React.FC<CityCascadeSelectorProps> = ({
+  provinceValue,
+  cityValue,
+  districtValue,
+  townValue,
+  onProvinceChange,
+  onCityChange,
+  onDistrictChange,
+  onTownChange,
+  disabled = false,
+  showLabels = true,
+}) => {
+  // 获取省份数据 (level=1)
+  const { data: provinces } = useQuery({
+    queryKey: ['cities', 'provinces'],
+    queryFn: async () => {
+      const res = await cityClient.$get({
+        query: {
+          page: 1,
+          pageSize: 100,
+          filters: JSON.stringify({ level: 1 }),
+        },
+      });
+      if (res.status !== 200) throw new Error('获取省份数据失败');
+      const data = await res.json();
+      return data.data as City[];
+    },
+  });
+
+  // 获取城市数据 (level=2)
+  const { data: cities } = useQuery({
+    queryKey: ['cities', 'cities', provinceValue],
+    queryFn: async () => {
+      if (!provinceValue) return [];
+      const res = await cityClient.$get({
+        query: {
+          page: 1,
+          pageSize: 100,
+          filters: JSON.stringify({ level: 2, parentId: provinceValue }),
+        },
+      });
+      if (res.status !== 200) throw new Error('获取城市数据失败');
+      const data = await res.json();
+      return data.data as City[];
+    },
+    enabled: !!provinceValue,
+  });
+
+  // 获取区县数据 (level=3)
+  const { data: districts } = useQuery({
+    queryKey: ['cities', 'districts', cityValue],
+    queryFn: async () => {
+      if (!cityValue) return [];
+      const res = await cityClient.$get({
+        query: {
+          page: 1,
+          pageSize: 100,
+          filters: JSON.stringify({ level: 3, parentId: cityValue }),
+        },
+      });
+      if (res.status !== 200) throw new Error('获取区县数据失败');
+      const data = await res.json();
+      return data.data as City[];
+    },
+    enabled: !!cityValue,
+  });
+
+  // 获取街道数据 (level=4)
+  const { data: towns } = useQuery({
+    queryKey: ['cities', 'towns', districtValue],
+    queryFn: async () => {
+      if (!districtValue) return [];
+      const res = await cityClient.$get({
+        query: {
+          page: 1,
+          pageSize: 100,
+          filters: JSON.stringify({ level: 4, parentId: districtValue }),
+        },
+      });
+      if (res.status !== 200) throw new Error('获取街道数据失败');
+      const data = await res.json();
+      return data.data as City[];
+    },
+    enabled: !!districtValue,
+  });
+
+  // 清除下级选择器
+  useEffect(() => {
+    if (!provinceValue && cityValue !== undefined) {
+      onCityChange?.(0);
+    }
+  }, [provinceValue, cityValue]);
+
+  useEffect(() => {
+    if (!cityValue && districtValue !== undefined) {
+      onDistrictChange?.(0);
+    }
+  }, [cityValue, districtValue]);
+
+  useEffect(() => {
+    if (!districtValue && townValue !== undefined) {
+      onTownChange?.(0);
+    }
+  }, [districtValue, townValue]);
+
+  const renderSelect = (
+    label: string,
+    value: number | undefined,
+    onChange: ((value: number) => void) | undefined,
+    options: City[] | undefined,
+    placeholder: string
+  ) => (
+    <FormItem>
+      {showLabels && <FormLabel>{label}</FormLabel>}
+      <FormControl>
+        <Select
+          value={value?.toString() || ''}
+          onValueChange={(val) => onChange?.(parseInt(val))}
+          disabled={disabled || !options?.length}
+        >
+          <SelectTrigger>
+            <SelectValue placeholder={placeholder} />
+          </SelectTrigger>
+          <SelectContent>
+            {options?.map((option) => (
+              <SelectItem key={option.id} value={option.id.toString()}>
+                {option.name}
+              </SelectItem>
+            ))}
+          </SelectContent>
+        </Select>
+      </FormControl>
+    </FormItem>
+  );
+
+  return (
+    <div className="grid grid-cols-4 gap-4">
+      {renderSelect(
+        '省份',
+        provinceValue,
+        onProvinceChange,
+        provinces,
+        '选择省份'
+      )}
+      
+      {renderSelect(
+        '城市',
+        cityValue,
+        onCityChange,
+        cities,
+        provinceValue ? '选择城市' : '请先选择省份'
+      )}
+      
+      {renderSelect(
+        '区县',
+        districtValue,
+        onDistrictChange,
+        districts,
+        cityValue ? '选择区县' : '请先选择城市'
+      )}
+      
+      {renderSelect(
+        '街道',
+        townValue,
+        onTownChange,
+        towns,
+        districtValue ? '选择街道' : '请先选择区县'
+      )}
+    </div>
+  );
+};

+ 39 - 112
src/client/admin-shadcn/pages/DeliveryAddresses.tsx

@@ -21,6 +21,7 @@ import { Switch } from '@/client/components/ui/switch';
 import { Skeleton } from '@/client/components/ui/skeleton';
 import { DataTablePagination } from '@/client/admin-shadcn/components/DataTablePagination';
 import { UserSelector } from '@/client/admin-shadcn/components/UserSelector';
+import { CityCascadeSelector } from '@/client/admin-shadcn/components/CityCascadeSelector';
 
 // 类型定义
 type DeliveryAddressResponse = InferResponseType<typeof deliveryAddressClient.$get, 200>['data'][0];
@@ -213,6 +214,18 @@ export const DeliveryAddressesPage = () => {
     );
   };
 
+  // 格式化地址显示
+  const formatAddressDisplay = (address: DeliveryAddressResponse) => {
+    const parts = [
+      address.province?.name,
+      address.city?.name,
+      address.district?.name,
+      address.town?.name,
+      address.address
+    ].filter(Boolean);
+    return parts.join(' ');
+  };
+
   // 加载状态
   if (isLoading) {
     return (
@@ -316,8 +329,8 @@ export const DeliveryAddressesPage = () => {
                     <TableCell>{address.user?.username || '-'}</TableCell>
                     <TableCell>{address.name}</TableCell>
                     <TableCell>{address.phone}</TableCell>
-                    <TableCell className="max-w-xs truncate" title={address.address}>
-                      {address.address}
+                    <TableCell className="max-w-xs truncate" title={formatAddressDisplay(address)}>
+                      {formatAddressDisplay(address)}
                     </TableCell>
                     <TableCell>{getStatusBadge(address.state)}</TableCell>
                     <TableCell>{getIsDefaultBadge(address.isDefault)}</TableCell>
@@ -441,61 +454,18 @@ export const DeliveryAddressesPage = () => {
                   )}
                 />
 
-                <div className="grid grid-cols-4 gap-4">
-                  <FormField
-                    control={createForm.control}
-                    name="receiverProvince"
-                    render={({ field }) => (
-                      <FormItem>
-                        <FormLabel>省份ID</FormLabel>
-                        <FormControl>
-                          <Input type="number" placeholder="省份ID" {...field} />
-                        </FormControl>
-                        <FormMessage />
-                      </FormItem>
-                    )}
-                  />
-
-                  <FormField
-                    control={createForm.control}
-                    name="receiverCity"
-                    render={({ field }) => (
-                      <FormItem>
-                        <FormLabel>城市ID</FormLabel>
-                        <FormControl>
-                          <Input type="number" placeholder="城市ID" {...field} />
-                        </FormControl>
-                        <FormMessage />
-                      </FormItem>
-                    )}
-                  />
-
-                  <FormField
-                    control={createForm.control}
-                    name="receiverDistrict"
-                    render={({ field }) => (
-                      <FormItem>
-                        <FormLabel>区县ID</FormLabel>
-                        <FormControl>
-                          <Input type="number" placeholder="区县ID" {...field} />
-                        </FormControl>
-                        <FormMessage />
-                      </FormItem>
-                    )}
-                  />
-
-                  <FormField
-                    control={createForm.control}
-                    name="receiverTown"
-                    render={({ field }) => (
-                      <FormItem>
-                        <FormLabel>街道ID</FormLabel>
-                        <FormControl>
-                          <Input type="number" placeholder="街道ID" {...field} />
-                        </FormControl>
-                        <FormMessage />
-                      </FormItem>
-                    )}
+                <div className="space-y-2">
+                  <FormLabel>四级地址选择<span className="text-red-500 ml-1">*</span></FormLabel>
+                  <CityCascadeSelector
+                    provinceValue={createForm.watch('receiverProvince') || 0}
+                    cityValue={createForm.watch('receiverCity') || 0}
+                    districtValue={createForm.watch('receiverDistrict') || 0}
+                    townValue={createForm.watch('receiverTown') || 0}
+                    onProvinceChange={(value) => createForm.setValue('receiverProvince', value)}
+                    onCityChange={(value) => createForm.setValue('receiverCity', value)}
+                    onDistrictChange={(value) => createForm.setValue('receiverDistrict', value)}
+                    onTownChange={(value) => createForm.setValue('receiverTown', value)}
+                    showLabels={false}
                   />
                 </div>
 
@@ -573,61 +543,18 @@ export const DeliveryAddressesPage = () => {
                   )}
                 />
 
-                <div className="grid grid-cols-4 gap-4">
-                  <FormField
-                    control={updateForm.control}
-                    name="receiverProvince"
-                    render={({ field }) => (
-                      <FormItem>
-                        <FormLabel>省份ID</FormLabel>
-                        <FormControl>
-                          <Input type="number" placeholder="省份ID" {...field} />
-                        </FormControl>
-                        <FormMessage />
-                      </FormItem>
-                    )}
-                  />
-
-                  <FormField
-                    control={updateForm.control}
-                    name="receiverCity"
-                    render={({ field }) => (
-                      <FormItem>
-                        <FormLabel>城市ID</FormLabel>
-                        <FormControl>
-                          <Input type="number" placeholder="城市ID" {...field} />
-                        </FormControl>
-                        <FormMessage />
-                      </FormItem>
-                    )}
-                  />
-
-                  <FormField
-                    control={updateForm.control}
-                    name="receiverDistrict"
-                    render={({ field }) => (
-                      <FormItem>
-                        <FormLabel>区县ID</FormLabel>
-                        <FormControl>
-                          <Input type="number" placeholder="区县ID" {...field} />
-                        </FormControl>
-                        <FormMessage />
-                      </FormItem>
-                    )}
-                  />
-
-                  <FormField
-                    control={updateForm.control}
-                    name="receiverTown"
-                    render={({ field }) => (
-                      <FormItem>
-                        <FormLabel>街道ID</FormLabel>
-                        <FormControl>
-                          <Input type="number" placeholder="街道ID" {...field} />
-                        </FormControl>
-                        <FormMessage />
-                      </FormItem>
-                    )}
+                <div className="space-y-2">
+                  <FormLabel>四级地址选择<span className="text-red-500 ml-1">*</span></FormLabel>
+                  <CityCascadeSelector
+                    provinceValue={updateForm.watch('receiverProvince') || 0}
+                    cityValue={updateForm.watch('receiverCity') || 0}
+                    districtValue={updateForm.watch('receiverDistrict') || 0}
+                    townValue={updateForm.watch('receiverTown') || 0}
+                    onProvinceChange={(value) => updateForm.setValue('receiverProvince', value)}
+                    onCityChange={(value) => updateForm.setValue('receiverCity', value)}
+                    onDistrictChange={(value) => updateForm.setValue('receiverDistrict', value)}
+                    onTownChange={(value) => updateForm.setValue('receiverTown', value)}
+                    showLabels={false}
                   />
                 </div>
 

+ 1 - 1
src/server/api/delivery-address/index.ts

@@ -10,7 +10,7 @@ const deliveryAddressRoutes = createCrudRoutes({
   getSchema: DeliveryAddressSchema,
   listSchema: DeliveryAddressSchema,
   searchFields: ['name', 'phone', 'address'],
-  relations: ['user'],
+  relations: ['user', 'province', 'city', 'district', 'town'],
   middleware: [authMiddleware],
   userTracking: {
     createdByField: 'createdBy',

+ 17 - 0
src/server/modules/delivery-address/delivery-address.entity.ts

@@ -1,5 +1,6 @@
 import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
 import { User } from '@/server/modules/users/user.entity';
+import { City } from '@/server/modules/system/city.entity';
 
 @Entity('delivery_address')
 export class DeliveryAddress {
@@ -51,4 +52,20 @@ export class DeliveryAddress {
   @ManyToOne(() => User)
   @JoinColumn({ name: 'user_id', referencedColumnName: 'id' })
   user!: User;
+
+  @ManyToOne(() => City)
+  @JoinColumn({ name: 'receiver_province', referencedColumnName: 'id' })
+  province!: City;
+
+  @ManyToOne(() => City)
+  @JoinColumn({ name: 'receiver_city', referencedColumnName: 'id' })
+  city!: City;
+
+  @ManyToOne(() => City)
+  @JoinColumn({ name: 'receiver_district', referencedColumnName: 'id' })
+  district!: City;
+
+  @ManyToOne(() => City)
+  @JoinColumn({ name: 'receiver_town', referencedColumnName: 'id' })
+  town!: City;
 }

+ 13 - 0
src/server/modules/delivery-address/delivery-address.schema.ts

@@ -1,5 +1,6 @@
 import { z } from '@hono/zod-openapi';
 import { UserSchema } from '@/server/modules/users/user.schema';
+import { CitySchema } from '@/server/modules/system/city.schema';
 
 // 状态枚举
 export const DeliveryAddressStatusEnum = {
@@ -127,6 +128,18 @@ export const DeliveryAddressSchema = z.object({
     }),
   user: UserSchema.optional().openapi({
     description: '关联用户信息'
+  }),
+  province: CitySchema.optional().openapi({
+    description: '关联省份信息'
+  }),
+  city: CitySchema.optional().openapi({
+    description: '关联城市信息'
+  }),
+  district: CitySchema.optional().openapi({
+    description: '关联区县信息'
+  }),
+  town: CitySchema.optional().openapi({
+    description: '关联街道信息'
   })
 });
 

+ 2 - 2
src/server/modules/delivery-address/delivery-address.service.ts

@@ -15,7 +15,7 @@ export class DeliveryAddressService extends GenericCrudService<DeliveryAddress>
   async findByUser(userId: number): Promise<DeliveryAddress[]> {
     return this.repository.find({
       where: { userId, state: 1 },
-      relations: ['user'],
+      relations: ['user', 'province', 'city', 'district', 'town'],
       order: { isDefault: 'DESC', createdAt: 'DESC' }
     });
   }
@@ -52,7 +52,7 @@ export class DeliveryAddressService extends GenericCrudService<DeliveryAddress>
   async findDefaultByUser(userId: number): Promise<DeliveryAddress | null> {
     return this.repository.findOne({
       where: { userId, isDefault: 1, state: 1 },
-      relations: ['user']
+      relations: ['user', 'province', 'city', 'district', 'town']
     });
   }
 }