Forráskód Böngészése

✨ feat(location): 完善地点区域层级结构

- 修改北京地点数据的cityId和districtId,统一使用市辖区编码
- 重构地点数据结构,将area字段拆分为province、city和district三级结构
- 优化API响应处理,增加状态码检查和错误处理
- 改进地点列表页UI,显示完整的省市区三级标签
- 修复区域筛选和状态筛选的"全部"选项逻辑
- 调整分页逻辑,使用current代替page作为当前页码字段

♻️ refactor(schema): 优化区域和地点数据验证

- 将parentId改为可空类型,支持顶级区域
- 为level字段添加coerce转换,确保数值类型正确
- 完善地点响应schema,明确省市区层级验证规则
yourname 4 hónapja
szülő
commit
e10ee3f9d6

+ 27 - 27
scripts/seed.ts

@@ -41,81 +41,81 @@ async function seed() {
       // 北京地点
       {
         name: '工人体育场',
-        provinceId: 1, // 北京市
-        cityId: 2,     // 北京市
-        districtId: 5, // 朝阳区
+        provinceId: 1,  // 北京市
+        cityId: 34,     // 市辖区 (北京市的市级行政区)
+        districtId: 40, // 朝阳区
         address: '北京市朝阳区工人体育场北路',
         latitude: 39.929986,
         longitude: 116.447221,
       },
       {
         name: '鸟巢',
-        provinceId: 1, // 北京市
-        cityId: 2,     // 北京市
-        districtId: 5, // 朝阳区
+        provinceId: 1,  // 北京市
+        cityId: 34,     // 市辖区 (北京市的市级行政区)
+        districtId: 40, // 朝阳区
         address: '北京市朝阳区国家体育场南路1号',
         latitude: 39.992894,
         longitude: 116.396284,
       },
       {
         name: '五棵松体育馆',
-        provinceId: 1, // 北京市
-        cityId: 2,     // 北京市
-        districtId: 6, // 海淀区
+        provinceId: 1,  // 北京市
+        cityId: 34,     // 市辖区 (北京市的市级行政区)
+        districtId: 43, // 海淀区
         address: '北京市海淀区复兴路69号',
         latitude: 39.9042,
         longitude: 116.2734,
       },
       {
         name: '中关村',
-        provinceId: 1, // 北京市
-        cityId: 2,     // 北京市
-        districtId: 6, // 海淀区
+        provinceId: 1,  // 北京市
+        cityId: 34,     // 市辖区 (北京市的市级行政区)
+        districtId: 43, // 海淀区
         address: '北京市海淀区中关村大街',
         latitude: 39.9836,
         longitude: 116.3184,
       },
       {
         name: '国贸',
-        provinceId: 1, // 北京市
-        cityId: 2,     // 北京市
-        districtId: 5, // 朝阳区
+        provinceId: 1,  // 北京市
+        cityId: 34,     // 市辖区 (北京市的市级行政区)
+        districtId: 40, // 朝阳区
         address: '北京市朝阳区建国门外大街1号',
         latitude: 39.9092,
         longitude: 116.4558,
       },
       {
         name: '望京',
-        provinceId: 1, // 北京市
-        cityId: 2,     // 北京市
-        districtId: 5, // 朝阳区
+        provinceId: 1,  // 北京市
+        cityId: 34,     // 市辖区 (北京市的市级行政区)
+        districtId: 40, // 朝阳区
         address: '北京市朝阳区望京街道',
         latitude: 39.9895,
         longitude: 116.4815,
       },
       {
         name: '五道口',
-        provinceId: 1, // 北京市
-        cityId: 2,     // 北京市
-        districtId: 6, // 海淀区
+        provinceId: 1,  // 北京市
+        cityId: 34,     // 市辖区 (北京市的市级行政区)
+        districtId: 43, // 海淀区
         address: '北京市海淀区五道口',
         latitude: 39.9969,
         longitude: 116.3375,
       },
       {
         name: '西直门',
-        provinceId: 1, // 北京市
-        cityId: 2,     // 北京市
-        districtId: 4, // 西城区
+        provinceId: 1,  // 北京市
+        cityId: 34,     // 市辖区 (北京市的市级行政区)
+        districtId: 37, // 西城区
         address: '北京市西城区西直门外大街',
         latitude: 39.9416,
         longitude: 116.3556,
       },
       {
         name: '朝阳门',
-        provinceId: 1, // 北京市
-        cityId: 2,     // 北京市
-        districtId: 5, // 朝阳区
+        provinceId: 1,  // 北京市
+        cityId: 34,     // 市辖区 (北京市的市级行政区)
+        districtId: 40, // 朝阳区
         address: '北京市朝阳区朝阳门外大街',
         latitude: 39.9244,
         longitude: 116.4342,

+ 75 - 36
src/client/admin/pages/Locations.tsx

@@ -1,17 +1,13 @@
 import { useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { useForm } from 'react-hook-form';
-import { zodResolver } from '@hookform/resolvers/zod';
 import { Plus, Search, Edit, Trash2, MapPin } from 'lucide-react';
 import { Button } from '@/client/components/ui/button';
 import { Input } from '@/client/components/ui/input';
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
 import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
 import { Badge } from '@/client/components/ui/badge';
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/client/components/ui/dialog';
-import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/client/components/ui/form';
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/client/components/ui/dialog';
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select';
-import { Textarea } from '@/client/components/ui/textarea';
 import { toast } from 'sonner';
 import { locationClient, areaClient } from '@/client/api';
 import { LocationForm } from '../components/LocationForm';
@@ -20,13 +16,23 @@ interface Location {
   id: number;
   name: string;
   address: string;
-  area: {
+  province: {
     id: number;
     name: string;
     code: string;
-  };
-  latitude?: number;
-  longitude?: number;
+  } | null;
+  city: {
+    id: number;
+    name: string;
+    code: string;
+  } | null;
+  district: {
+    id: number;
+    name: string;
+    code: string;
+  } | null;
+  latitude?: number | null;
+  longitude?: number | null;
   isDisabled: number;
   createdAt: string;
   updatedAt: string;
@@ -55,62 +61,81 @@ export const LocationsPage = () => {
   // 获取地点列表
   const { data: locationsData, isLoading } = useQuery({
     queryKey: ['locations', searchParams],
-    queryFn: () => locationClient.$get({ query: searchParams }),
+    queryFn: async () => {
+      const res = await locationClient.$get({ query: searchParams });
+      if (res.status !== 200) throw new Error('获取地点列表失败');
+      return await res.json();
+    },
   });
 
   // 获取区域列表
   const { data: areasData } = useQuery({
     queryKey: ['areas'],
-    queryFn: () => areaClient.$get({ query: { pageSize: 100 } }),
+    queryFn: async () => {
+      const res = await areaClient.$get({ query: { pageSize: 100 } });
+      if (res.status !== 200) throw new Error('获取区域列表失败');
+      return await res.json();
+    },
   });
 
   // 创建地点
   const createMutation = useMutation({
-    mutationFn: (data: any) => locationClient.$post({ json: data }),
+    mutationFn: async (data: any) => {
+      const res = await locationClient.$post({ json: data });
+      if (res.status !== 201) throw new Error('创建地点失败');
+    },
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['locations'] });
       setIsCreateDialogOpen(false);
       toast.success('地点已成功创建');
     },
-    onError: (error: any) => {
+    onError: (error: Error) => {
       toast.error(error.message || '创建地点失败');
     },
   });
 
   // 更新地点
   const updateMutation = useMutation({
-    mutationFn: ({ id, data }: { id: number; data: any }) => locationClient[':id'].$put({ param: { id }, json: data }),
+    mutationFn: async ({ id, data }: { id: number; data: any }) => {
+      const res = await locationClient[':id'].$put({ param: { id }, json: data });
+      if (res.status !== 200) throw new Error('更新地点失败');
+    },
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['locations'] });
       setEditingLocation(null);
       toast.success('地点已成功更新');
     },
-    onError: (error: any) => {
+    onError: (error: Error) => {
       toast.error(error.message || '更新地点失败');
     },
   });
 
   // 删除地点
   const deleteMutation = useMutation({
-    mutationFn: (id: number) => locationClient[':id'].$delete({ param: { id } }),
+    mutationFn: async (id: number) => {
+      const res = await locationClient[':id'].$delete({ param: { id } });
+      if (res.status !== 204) throw new Error('删除地点失败');
+    },
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['locations'] });
       toast.success('地点已成功删除');
     },
-    onError: (error: any) => {
+    onError: (error: Error) => {
       toast.error(error.message || '删除地点失败');
     },
   });
 
   // 切换状态
   const toggleStatusMutation = useMutation({
-    mutationFn: ({ id, isDisabled }: { id: number; isDisabled: number }) =>
-      locationClient[':id'].status.$patch({ param: { id }, json: { isDisabled } }),
+    mutationFn: async ({ id, isDisabled }: { id: number; isDisabled: number }) => {
+      const res = await locationClient[':id'].$put({ param: { id }, json: { isDisabled } });
+      if (res.status !== 200) throw new Error('更新状态失败');
+    },
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['locations'] });
       toast.success('地点状态已更新');
     },
-    onError: (error: any) => {
+    onError: (error: Error) => {
       toast.error(error.message || '更新状态失败');
     },
   });
@@ -126,7 +151,7 @@ export const LocationsPage = () => {
   const handleAreaFilter = (areaId: string) => {
     setSearchParams(prev => ({
       ...prev,
-      areaId: areaId ? parseInt(areaId) : undefined,
+      areaId: areaId && areaId !== 'all' ? parseInt(areaId) : undefined,
       page: 1,
     }));
   };
@@ -134,7 +159,7 @@ export const LocationsPage = () => {
   const handleStatusFilter = (status: string) => {
     setSearchParams(prev => ({
       ...prev,
-      isDisabled: status ? parseInt(status) : undefined,
+      isDisabled: status && status !== 'all' ? parseInt(status) : undefined,
       page: 1,
     }));
   };
@@ -164,8 +189,8 @@ export const LocationsPage = () => {
     setEditingLocation(location);
   };
 
-  const locations = locationsData?.data?.data || [];
-  const pagination = locationsData?.data?.pagination;
+  const locations = locationsData?.data || [];
+  const pagination = locationsData?.pagination;
 
   return (
     <div className="space-y-6">
@@ -193,7 +218,7 @@ export const LocationsPage = () => {
             <LocationForm
               onSubmit={handleCreate}
               isLoading={createMutation.isPending}
-              areas={areasData?.data?.data || []}
+              areas={areasData?.data || []}
             />
           </DialogContent>
         </Dialog>
@@ -225,8 +250,8 @@ export const LocationsPage = () => {
                   <SelectValue placeholder="选择区域" />
                 </SelectTrigger>
                 <SelectContent>
-                  <SelectItem value="">全部区域</SelectItem>
-                  {areasData?.data?.data?.map((area: any) => (
+                  <SelectItem value="all">全部区域</SelectItem>
+                  {areasData?.data?.map((area: any) => (
                     <SelectItem key={area.id} value={area.id.toString()}>
                       {area.name}
                     </SelectItem>
@@ -238,7 +263,7 @@ export const LocationsPage = () => {
                   <SelectValue placeholder="状态筛选" />
                 </SelectTrigger>
                 <SelectContent>
-                  <SelectItem value="">全部状态</SelectItem>
+                  <SelectItem value="all">全部状态</SelectItem>
                   <SelectItem value="0">启用</SelectItem>
                   <SelectItem value="1">禁用</SelectItem>
                 </SelectContent>
@@ -281,9 +306,23 @@ export const LocationsPage = () => {
                         <TableCell className="font-medium">{location.name}</TableCell>
                         <TableCell>{location.address}</TableCell>
                         <TableCell>
-                          <Badge variant="outline">
-                            {location.area?.name}
-                          </Badge>
+                          <div className="flex flex-col gap-1">
+                            {location.province && (
+                              <Badge variant="outline">
+                                {location.province.name}
+                              </Badge>
+                            )}
+                            {location.city && (
+                              <Badge variant="outline">
+                                {location.city.name}
+                              </Badge>
+                            )}
+                            {location.district && (
+                              <Badge variant="outline">
+                                {location.district.name}
+                              </Badge>
+                            )}
+                          </div>
                         </TableCell>
                         <TableCell>
                           {location.latitude && location.longitude ? (
@@ -338,15 +377,15 @@ export const LocationsPage = () => {
             {pagination && pagination.total > 0 && (
               <div className="flex items-center justify-between">
                 <div className="text-sm text-muted-foreground">
-                  显示第 {(pagination.page - 1) * pagination.pageSize + 1} 到{' '}
-                  {Math.min(pagination.page * pagination.pageSize, pagination.total)} 条,
+                  显示第 {(pagination.current - 1) * pagination.pageSize + 1} 到{' '}
+                  {Math.min(pagination.current * pagination.pageSize, pagination.total)} 条,
                   共 {pagination.total} 条记录
                 </div>
                 <div className="flex gap-2">
                   <Button
                     variant="outline"
                     size="sm"
-                    disabled={pagination.page <= 1}
+                    disabled={pagination.current <= 1}
                     onClick={() => setSearchParams(prev => ({ ...prev, page: prev.page! - 1 }))}
                   >
                     上一页
@@ -354,7 +393,7 @@ export const LocationsPage = () => {
                   <Button
                     variant="outline"
                     size="sm"
-                    disabled={pagination.page >= pagination.totalPages}
+                    disabled={pagination.current >= Math.ceil(pagination.total / pagination.pageSize)}
                     onClick={() => setSearchParams(prev => ({ ...prev, page: prev.page! + 1 }))}
                   >
                     下一页
@@ -379,7 +418,7 @@ export const LocationsPage = () => {
             <LocationForm
               onSubmit={handleUpdate}
               isLoading={updateMutation.isPending}
-              areas={areasData?.data?.data || []}
+              areas={areasData?.data || []}
               initialData={editingLocation}
             />
           )}

+ 1 - 1
src/server/modules/areas/area.schema.ts

@@ -82,7 +82,7 @@ export const listAreasSchema = z.object({
 // 省市区列表返回Schema
 export const areaListResponseSchema = z.object({
   id: z.number().int().positive('ID必须为正整数'),
-  parentId: z.number().int().min(0, '父级ID不能为负数'),
+  parentId: z.coerce.number().int().min(0, '父级ID不能为负数').nullable(),
   name: z.string().min(1, '区域名称不能为空').max(100, '区域名称不能超过100个字符'),
   level: z.nativeEnum(AreaLevel, {
     message: '层级必须是1(省/直辖市)、2(市)或3(区/县)'

+ 3 - 3
src/server/modules/locations/location.schema.ts

@@ -76,19 +76,19 @@ export const locationListResponseSchema = z.object({
   province: z.object({
     id: z.number().int().positive('省份ID必须为正整数'),
     name: z.string().min(1, '省份名称不能为空').max(100, '省份名称不能超过100个字符'),
-    level: z.number().int().min(1).max(1, '省份层级必须为1'),
+    level: z.coerce.number().int().min(1).max(1, '省份层级必须为1'),
     code: z.string().min(1, '行政区划代码不能为空').max(20, '行政区划代码不能超过20个字符'),
   }).nullable(),
   city: z.object({
     id: z.number().int().positive('城市ID必须为正整数'),
     name: z.string().min(1, '城市名称不能为空').max(100, '城市名称不能超过100个字符'),
-    level: z.number().int().min(2).max(2, '城市层级必须为2'),
+    level: z.coerce.number().int().min(2).max(2, '城市层级必须为2'),
     code: z.string().min(1, '行政区划代码不能为空').max(20, '行政区划代码不能超过20个字符'),
   }).nullable(),
   district: z.object({
     id: z.number().int().positive('区县ID必须为正整数'),
     name: z.string().min(1, '区县名称不能为空').max(100, '区县名称不能超过100个字符'),
-    level: z.number().int().min(3).max(3, '区县层级必须为3'),
+    level: z.coerce.number().int().min(3).max(3, '区县层级必须为3'),
     code: z.string().min(1, '行政区划代码不能为空').max(20, '行政区划代码不能超过20个字符'),
   }).nullable(),
 });