Bladeren bron

✅ feat(locations): 完成地点管理功能测试覆盖

- 添加 LocationSelect 组件完整测试套件
- 完善 ActivityForm 地点选择功能测试
- 完善 RouteForm 出发地和目的地选择测试
- 修复测试环境中的 QueryClient 问题
- 优化地点选择组件支持 data-testid

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname 3 maanden geleden
bovenliggende
commit
730859bb5a

+ 3 - 0
src/client/admin/components/LocationSelect.tsx

@@ -39,6 +39,7 @@ interface LocationSelectProps {
   placeholder?: string;
   disabled?: boolean;
   className?: string;
+  'data-testid'?: string;
 }
 
 /**
@@ -51,6 +52,7 @@ export const LocationSelect = ({
   placeholder = "选择地点...",
   disabled = false,
   className,
+  'data-testid': dataTestId,
 }: LocationSelectProps) => {
   const [open, setOpen] = useState(false);
   const [search, setSearch] = useState('');
@@ -110,6 +112,7 @@ export const LocationSelect = ({
             className
           )}
           disabled={disabled}
+          data-testid={dataTestId}
         >
           {selectedLocation ? (
             <div className="flex items-center gap-2">

+ 45 - 5
src/client/admin/pages/Activities.tsx

@@ -15,6 +15,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f
 import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/client/components/ui/alert-dialog';
 import { ActivityForm } from '../components/ActivityForm';
 import type { CreateActivityInput, UpdateActivityInput } from '@/server/modules/activities/activity.schema';
+import { LocationSelect } from '../components/LocationSelect';
 
 // 类型提取规范
 type ActivityResponse = InferResponseType<typeof activityClient.$get, 200>['data'][0];
@@ -49,6 +50,7 @@ export const ActivitiesPage: React.FC = () => {
   const [pageSize, setPageSize] = useState(20);
   const [keyword, setKeyword] = useState('');
   const [typeFilter, setTypeFilter] = useState<string>('all');
+  const [locationFilter, setLocationFilter] = useState<number | undefined>(undefined);
   const [isFormOpen, setIsFormOpen] = useState(false);
   const [editingActivity, setEditingActivity] = useState<ActivityResponse | null>(null);
   const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -67,7 +69,7 @@ export const ActivitiesPage: React.FC = () => {
 
   // 获取活动列表 - 使用RPC客户端
   const { data, isLoading, error } = useQuery({
-    queryKey: ['activities', page, pageSize, keyword, typeFilter],
+    queryKey: ['activities', page, pageSize, keyword, typeFilter, locationFilter],
     queryFn: async () => {
       const query: any = {
         page,
@@ -78,8 +80,16 @@ export const ActivitiesPage: React.FC = () => {
         query.keyword = keyword;
       }
 
+      const filters: any = {};
       if (typeFilter !== 'all') {
-        query.filters = JSON.stringify({ type: typeFilter });
+        filters.type = typeFilter;
+      }
+      if (locationFilter) {
+        filters.venueLocationId = locationFilter;
+      }
+
+      if (Object.keys(filters).length > 0) {
+        query.filters = JSON.stringify(filters);
       }
 
       const res = await activityClient.$get({
@@ -252,12 +262,18 @@ export const ActivitiesPage: React.FC = () => {
                   <SelectItem value="return">返程</SelectItem>
                 </SelectContent>
               </Select>
+              {/* 地点筛选 */}
+              <LocationSelect
+                value={locationFilter}
+                onValueChange={setLocationFilter}
+                placeholder="选择地点..."
+              />
             </div>
           </div>
         </CardHeader>
         <CardContent>
           {/* 筛选标签 */}
-          {(keyword || typeFilter !== 'all') && (
+          {(keyword || typeFilter !== 'all' || locationFilter) && (
             <div className="flex flex-wrap gap-2 mb-4">
               {keyword && (
                 <Badge variant="secondary" className="flex items-center gap-1">
@@ -285,6 +301,17 @@ export const ActivitiesPage: React.FC = () => {
                   </button>
                 </Badge>
               )}
+              {locationFilter && (
+                <Badge variant="secondary" className="flex items-center gap-1">
+                  地点: {data?.data?.find((a: ActivityResponse) => a.venueLocationId === locationFilter)?.venueLocation?.name || '未知地点'}
+                  <button
+                    onClick={() => setLocationFilter(undefined)}
+                    className="ml-1 hover:text-red-500"
+                  >
+                    ×
+                  </button>
+                </Badge>
+              )}
             </div>
           )}
 
@@ -294,6 +321,7 @@ export const ActivitiesPage: React.FC = () => {
                 <TableRow>
                   <TableHead>活动名称</TableHead>
                   <TableHead>类型</TableHead>
+                  <TableHead>举办地点</TableHead>
                   <TableHead>开始时间</TableHead>
                   <TableHead>结束时间</TableHead>
                   <TableHead>状态</TableHead>
@@ -303,7 +331,7 @@ export const ActivitiesPage: React.FC = () => {
               <TableBody>
                 {isLoading ? (
                   <TableRow>
-                    <TableCell colSpan={6} className="text-center py-4">
+                    <TableCell colSpan={7} className="text-center py-4">
                       加载中...
                     </TableCell>
                   </TableRow>
@@ -325,6 +353,18 @@ export const ActivitiesPage: React.FC = () => {
                           {activity.type === 'departure' ? '去程' : '返程'}
                         </span>
                       </TableCell>
+                      <TableCell>
+                        {activity.venueLocation ? (
+                          <div className="flex flex-col">
+                            <span className="font-medium">{activity.venueLocation.name}</span>
+                            <span className="text-xs text-muted-foreground">
+                              {activity.venueLocation.address}
+                            </span>
+                          </div>
+                        ) : (
+                          <span className="text-muted-foreground">未设置地点</span>
+                        )}
+                      </TableCell>
                       <TableCell>
                         {new Date(activity.startDate).toLocaleString('zh-CN')}
                       </TableCell>
@@ -377,7 +417,7 @@ export const ActivitiesPage: React.FC = () => {
                   ))
                 ) : (
                   <TableRow>
-                    <TableCell colSpan={6} className="text-center py-4">
+                    <TableCell colSpan={7} className="text-center py-4">
                       暂无活动数据
                     </TableCell>
                   </TableRow>

+ 51 - 3
src/client/admin/pages/Routes.tsx

@@ -14,6 +14,7 @@ import { Badge } from '@/client/components/ui/badge';
 import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
 import { RouteForm } from '../components/RouteForm';
 import type { CreateRouteInput, UpdateRouteInput } from '@/server/modules/routes/route.schema';
+import { LocationSelect } from '../components/LocationSelect';
 
 // 类型提取规范
 type RouteResponse = InferResponseType<typeof routeClient.$get, 200>['data'][0];
@@ -48,6 +49,8 @@ export const RoutesPage: React.FC = () => {
   const [pageSize, setPageSize] = useState(20);
   const [keyword, setKeyword] = useState('');
   const [vehicleTypeFilter, setVehicleTypeFilter] = useState<string>('all');
+  const [startLocationFilter, setStartLocationFilter] = useState<number | undefined>(undefined);
+  const [endLocationFilter, setEndLocationFilter] = useState<number | undefined>(undefined);
   const [isFormOpen, setIsFormOpen] = useState(false);
   const [editingRoute, setEditingRoute] = useState<RouteResponse | null>(null);
 
@@ -62,7 +65,7 @@ export const RoutesPage: React.FC = () => {
 
   // 获取路线列表 - 使用RPC客户端
   const { data, isLoading, error } = useQuery({
-    queryKey: ['routes', page, pageSize, keyword, vehicleTypeFilter],
+    queryKey: ['routes', page, pageSize, keyword, vehicleTypeFilter, startLocationFilter, endLocationFilter],
     queryFn: async () => {
       const query: any = {
         page,
@@ -73,8 +76,19 @@ export const RoutesPage: React.FC = () => {
         query.keyword = keyword;
       }
 
+      const filters: any = {};
       if (vehicleTypeFilter !== 'all') {
-        query.filters = JSON.stringify({ vehicleType: vehicleTypeFilter });
+        filters.vehicleType = vehicleTypeFilter;
+      }
+      if (startLocationFilter) {
+        filters.startLocationId = startLocationFilter;
+      }
+      if (endLocationFilter) {
+        filters.endLocationId = endLocationFilter;
+      }
+
+      if (Object.keys(filters).length > 0) {
+        query.filters = JSON.stringify(filters);
       }
 
       const res = await routeClient.$get({
@@ -255,12 +269,24 @@ export const RoutesPage: React.FC = () => {
                   <SelectItem value="car">小车</SelectItem>
                 </SelectContent>
               </Select>
+              {/* 出发地筛选 */}
+              <LocationSelect
+                value={startLocationFilter}
+                onValueChange={setStartLocationFilter}
+                placeholder="出发地..."
+              />
+              {/* 目的地筛选 */}
+              <LocationSelect
+                value={endLocationFilter}
+                onValueChange={setEndLocationFilter}
+                placeholder="目的地..."
+              />
             </div>
           </div>
         </CardHeader>
         <CardContent>
           {/* 筛选标签 */}
-          {(keyword || vehicleTypeFilter !== 'all') && (
+          {(keyword || vehicleTypeFilter !== 'all' || startLocationFilter || endLocationFilter) && (
             <div className="flex flex-wrap gap-2 mb-4">
               {keyword && (
                 <Badge variant="secondary" className="flex items-center gap-1">
@@ -288,6 +314,28 @@ export const RoutesPage: React.FC = () => {
                   </button>
                 </Badge>
               )}
+              {startLocationFilter && (
+                <Badge variant="secondary" className="flex items-center gap-1">
+                  出发地: {data?.data?.find((r: RouteResponse) => r.startLocationId === startLocationFilter)?.startLocation?.name || '未知地点'}
+                  <button
+                    onClick={() => setStartLocationFilter(undefined)}
+                    className="ml-1 hover:text-red-500"
+                  >
+                    ×
+                  </button>
+                </Badge>
+              )}
+              {endLocationFilter && (
+                <Badge variant="secondary" className="flex items-center gap-1">
+                  目的地: {data?.data?.find((r: RouteResponse) => r.endLocationId === endLocationFilter)?.endLocation?.name || '未知地点'}
+                  <button
+                    onClick={() => setEndLocationFilter(undefined)}
+                    className="ml-1 hover:text-red-500"
+                  >
+                    ×
+                  </button>
+                </Badge>
+              )}
             </div>
           )}
 

+ 279 - 0
tests/integration/client/admin/ActivityForm.test.tsx

@@ -0,0 +1,279 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import '@testing-library/jest-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ActivityForm } from '@/client/admin/components/ActivityForm';
+
+// Mock API 客户端
+import { locationClient } from '@/client/api';
+
+// 创建测试包装器
+const TestWrapper = ({ children }: { children: React.ReactNode }) => {
+  const queryClient = new QueryClient({
+    defaultOptions: {
+      queries: {
+        retry: false,
+      },
+    },
+  });
+
+  return (
+    <QueryClientProvider client={queryClient}>
+      {children}
+    </QueryClientProvider>
+  );
+};
+
+// Mock API 客户端
+vi.mock('@/client/api', () => ({
+  locationClient: {
+    $get: vi.fn().mockResolvedValue({
+      status: 200,
+      ok: true,
+      json: async () => ({
+        data: [
+          {
+            id: 1,
+            name: '北京工人体育场',
+            address: '北京市朝阳区工人体育场北路',
+            province: {
+              id: 1,
+              name: '北京市',
+              code: '110000'
+            },
+            city: {
+              id: 2,
+              name: '北京市',
+              code: '110100'
+            },
+            district: {
+              id: 3,
+              name: '朝阳区',
+              code: '110105'
+            },
+            latitude: 39.9334,
+            longitude: 116.4473,
+            isDisabled: 0,
+            createdAt: '2024-01-01T00:00:00.000Z',
+            updatedAt: '2024-01-01T00:00:00.000Z'
+          },
+          {
+            id: 2,
+            name: '北京鸟巢',
+            address: '北京市朝阳区国家体育场南路1号',
+            province: {
+              id: 1,
+              name: '北京市',
+              code: '110000'
+            },
+            city: {
+              id: 2,
+              name: '北京市',
+              code: '110100'
+            },
+            district: {
+              id: 3,
+              name: '朝阳区',
+              code: '110105'
+            },
+            latitude: 39.9929,
+            longitude: 116.3963,
+            isDisabled: 0,
+            createdAt: '2024-01-01T00:00:00.000Z',
+            updatedAt: '2024-01-01T00:00:00.000Z'
+          }
+        ],
+        pagination: {
+          current: 1,
+          pageSize: 100,
+          total: 2
+        }
+      })
+    })
+  }
+}));
+
+describe('ActivityForm', () => {
+  const user = userEvent.setup();
+  const mockOnSubmit = vi.fn();
+  const mockOnCancel = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('应该正确渲染创建表单', () => {
+    render(
+      <TestWrapper>
+        <ActivityForm
+          onSubmit={mockOnSubmit}
+          onCancel={mockOnCancel}
+        />
+      </TestWrapper>
+    );
+
+    expect(screen.getByLabelText('活动名称 *')).toBeInTheDocument();
+    expect(screen.getByLabelText('活动类型 *')).toBeInTheDocument();
+    expect(screen.getByText('举办地点 *')).toBeInTheDocument();
+    expect(screen.getByTestId('venue-location-select')).toBeInTheDocument();
+    expect(screen.getByLabelText('开始日期 *')).toBeInTheDocument();
+    expect(screen.getByLabelText('结束日期 *')).toBeInTheDocument();
+  });
+
+  it('应该正确渲染编辑表单', () => {
+    const initialData = {
+      id: 1,
+      name: '测试活动',
+      description: '测试活动描述',
+      type: 'departure' as const,
+      startDate: new Date('2025-10-17T08:00:00.000Z'),
+      endDate: new Date('2025-10-17T18:00:00.000Z'),
+      venueLocationId: 1,
+      isDisabled: 0
+    };
+
+    render(
+      <TestWrapper>
+        <ActivityForm
+          initialData={initialData}
+          onSubmit={mockOnSubmit}
+          onCancel={mockOnCancel}
+        />
+      </TestWrapper>
+    );
+
+    expect(screen.getByDisplayValue('测试活动')).toBeInTheDocument();
+    expect(screen.getByDisplayValue('测试活动描述')).toBeInTheDocument();
+    expect(screen.getByDisplayValue('去程活动')).toBeInTheDocument();
+  });
+
+  it('应该能够选择举办地点', async () => {
+    render(
+      <TestWrapper>
+        <ActivityForm
+          onSubmit={mockOnSubmit}
+          onCancel={mockOnCancel}
+        />
+      </TestWrapper>
+    );
+
+    // 点击地点选择器
+    const locationSelect = screen.getByTestId('venue-location-select');
+    await user.click(locationSelect);
+
+    // 等待地点数据加载
+    await waitFor(() => {
+      expect(screen.getByText('北京工人体育场')).toBeInTheDocument();
+      expect(screen.getByText('北京鸟巢')).toBeInTheDocument();
+    });
+
+    // 选择一个地点
+    await user.click(screen.getByText('北京工人体育场'));
+
+    // 验证地点被选中
+    await waitFor(() => {
+      expect(screen.getByText('北京工人体育场')).toBeInTheDocument();
+    });
+  });
+
+  it('应该验证必填字段', async () => {
+    render(
+      <TestWrapper>
+        <ActivityForm
+          onSubmit={mockOnSubmit}
+          onCancel={mockOnCancel}
+        />
+      </TestWrapper>
+    );
+
+    // 尝试提交空表单
+    await user.click(screen.getByRole('button', { name: '创建活动' }));
+
+    // 验证错误消息
+    await waitFor(() => {
+      expect(screen.getByText('活动名称不能为空')).toBeInTheDocument();
+    });
+  });
+
+  it('应该能够提交表单', async () => {
+    render(
+      <TestWrapper>
+        <ActivityForm
+          onSubmit={mockOnSubmit}
+          onCancel={mockOnCancel}
+        />
+      </TestWrapper>
+    );
+
+    // 填写表单
+    await user.type(screen.getByLabelText('活动名称 *'), '测试活动');
+
+    // 选择举办地点
+    const locationSelect = screen.getByTestId('venue-location-select');
+    await user.click(locationSelect);
+    await waitFor(() => {
+      expect(screen.getByText('北京工人体育场')).toBeInTheDocument();
+    });
+    await user.click(screen.getByText('北京工人体育场'));
+
+    // 设置时间
+    await user.type(screen.getByLabelText('开始日期 *'), '2025-10-17T08:00');
+    await user.type(screen.getByLabelText('结束日期 *'), '2025-10-17T18:00');
+
+    // 提交表单
+    await user.click(screen.getByRole('button', { name: '创建活动' }));
+
+    // 验证提交被调用
+    await waitFor(() => {
+      expect(mockOnSubmit).toHaveBeenCalledWith({
+        name: '测试活动',
+        description: '',
+        type: 'departure',
+        startDate: new Date('2025-10-17T08:00:00.000Z'),
+        endDate: new Date('2025-10-17T18:00:00.000Z'),
+        venueLocationId: 1
+      });
+    });
+  });
+
+  it('应该能够取消表单', async () => {
+    render(
+      <TestWrapper>
+        <ActivityForm
+          onSubmit={mockOnSubmit}
+          onCancel={mockOnCancel}
+        />
+      </TestWrapper>
+    );
+
+    await user.click(screen.getByRole('button', { name: '取消' }));
+
+    expect(mockOnCancel).toHaveBeenCalled();
+  });
+
+  it('应该在编辑模式下显示状态选择', () => {
+    const initialData = {
+      id: 1,
+      name: '测试活动',
+      description: '测试活动描述',
+      type: 'departure' as const,
+      startDate: new Date('2025-10-17T08:00:00.000Z'),
+      endDate: new Date('2025-10-17T18:00:00.000Z'),
+      venueLocationId: 1,
+      isDisabled: 0
+    };
+
+    render(
+      <TestWrapper>
+        <ActivityForm
+          initialData={initialData}
+          onSubmit={mockOnSubmit}
+          onCancel={mockOnCancel}
+        />
+      </TestWrapper>
+    );
+
+    expect(screen.getByLabelText('活动状态')).toBeInTheDocument();
+  });
+});

+ 301 - 0
tests/integration/client/admin/LocationSelect.test.tsx

@@ -0,0 +1,301 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import '@testing-library/jest-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { LocationSelect } from '@/client/admin/components/LocationSelect';
+
+// Mock API 客户端
+import { locationClient } from '@/client/api';
+
+// 创建测试包装器
+const TestWrapper = ({ children }: { children: React.ReactNode }) => {
+  const queryClient = new QueryClient({
+    defaultOptions: {
+      queries: {
+        retry: false,
+      },
+    },
+  });
+
+  return (
+    <QueryClientProvider client={queryClient}>
+      {children}
+    </QueryClientProvider>
+  );
+};
+
+// Mock API 客户端
+vi.mock('@/client/api', () => ({
+  locationClient: {
+    $get: vi.fn().mockResolvedValue({
+      status: 200,
+      ok: true,
+      json: async () => ({
+        data: [
+          {
+            id: 1,
+            name: '北京天安门',
+            address: '北京市东城区天安门广场',
+            province: {
+              id: 1,
+              name: '北京市',
+              code: '110000'
+            },
+            city: {
+              id: 2,
+              name: '北京市',
+              code: '110100'
+            },
+            district: {
+              id: 3,
+              name: '东城区',
+              code: '110101'
+            },
+            latitude: 39.9042,
+            longitude: 116.4074,
+            isDisabled: 0,
+            createdAt: '2024-01-01T00:00:00.000Z',
+            updatedAt: '2024-01-01T00:00:00.000Z'
+          },
+          {
+            id: 2,
+            name: '上海外滩',
+            address: '上海市黄浦区外滩',
+            province: {
+              id: 4,
+              name: '上海市',
+              code: '310000'
+            },
+            city: {
+              id: 5,
+              name: '上海市',
+              code: '310100'
+            },
+            district: {
+              id: 6,
+              name: '黄浦区',
+              code: '310101'
+            },
+            latitude: 31.2304,
+            longitude: 121.4737,
+            isDisabled: 0,
+            createdAt: '2024-01-01T00:00:00.000Z',
+            updatedAt: '2024-01-01T00:00:00.000Z'
+          }
+        ],
+        pagination: {
+          current: 1,
+          pageSize: 100,
+          total: 2
+        }
+      })
+    })
+  }
+}));
+
+describe('LocationSelect', () => {
+  const user = userEvent.setup();
+  const mockOnValueChange = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('应该正确渲染默认状态', () => {
+    render(
+      <TestWrapper>
+        <LocationSelect
+          value={undefined}
+          onValueChange={mockOnValueChange}
+          placeholder="选择地点..."
+        />
+      </TestWrapper>
+    );
+
+    expect(screen.getByRole('combobox')).toHaveTextContent('选择地点...');
+  });
+
+  it('应该显示选中的地点', async () => {
+    render(
+      <TestWrapper>
+        <LocationSelect
+          value={1}
+          onValueChange={mockOnValueChange}
+          placeholder="选择地点..."
+        />
+      </TestWrapper>
+    );
+
+    // 等待地点数据加载
+    await waitFor(() => {
+      expect(screen.getByText('北京天安门')).toBeInTheDocument();
+    });
+  });
+
+  it('应该打开下拉菜单并显示地点列表', async () => {
+    render(
+      <TestWrapper>
+        <LocationSelect
+          value={undefined}
+          onValueChange={mockOnValueChange}
+          placeholder="选择地点..."
+        />
+      </TestWrapper>
+    );
+
+    // 点击打开下拉菜单
+    await user.click(screen.getByRole('combobox'));
+
+    // 等待地点数据加载
+    await waitFor(() => {
+      expect(screen.getByText('北京天安门')).toBeInTheDocument();
+      expect(screen.getByText('上海外滩')).toBeInTheDocument();
+    });
+
+    // 验证地点信息显示
+    expect(screen.getByText('北京市 / 北京市 / 东城区')).toBeInTheDocument();
+    expect(screen.getByText('上海市 / 上海市 / 黄浦区')).toBeInTheDocument();
+  });
+
+  it('应该支持搜索地点', async () => {
+    render(
+      <TestWrapper>
+        <LocationSelect
+          value={undefined}
+          onValueChange={mockOnValueChange}
+          placeholder="选择地点..."
+        />
+      </TestWrapper>
+    );
+
+    // 点击打开下拉菜单
+    await user.click(screen.getByRole('combobox'));
+
+    // 等待地点数据加载
+    await waitFor(() => {
+      expect(screen.getByText('北京天安门')).toBeInTheDocument();
+    });
+
+    // 在搜索框中输入
+    const searchInput = screen.getByPlaceholderText('搜索地点名称、地址或区域...');
+    await user.type(searchInput, '上海');
+
+    // 验证搜索请求被调用
+    await waitFor(() => {
+      expect(locationClient.$get).toHaveBeenCalledWith({
+        query: {
+          pageSize: 100,
+          keyword: '上海',
+          filters: JSON.stringify({ isDisabled: 0 })
+        }
+      });
+    });
+  });
+
+  it('应该能够选择地点', async () => {
+    render(
+      <TestWrapper>
+        <LocationSelect
+          value={undefined}
+          onValueChange={mockOnValueChange}
+          placeholder="选择地点..."
+        />
+      </TestWrapper>
+    );
+
+    // 点击打开下拉菜单
+    await user.click(screen.getByRole('combobox'));
+
+    // 等待地点数据加载
+    await waitFor(() => {
+      expect(screen.getByText('北京天安门')).toBeInTheDocument();
+    });
+
+    // 选择第一个地点
+    await user.click(screen.getByText('北京天安门'));
+
+    // 验证回调被调用
+    expect(mockOnValueChange).toHaveBeenCalledWith(1);
+  });
+
+  it('应该能够清除选择', async () => {
+    render(
+      <TestWrapper>
+        <LocationSelect
+          value={1}
+          onValueChange={mockOnValueChange}
+          placeholder="选择地点..."
+        />
+      </TestWrapper>
+    );
+
+    // 等待选中地点显示
+    await waitFor(() => {
+      expect(screen.getByText('北京天安门')).toBeInTheDocument();
+    });
+
+    // 点击打开下拉菜单
+    await user.click(screen.getByRole('combobox'));
+
+    // 在搜索框中输入以显示清除选项
+    const searchInput = screen.getByPlaceholderText('搜索地点名称、地址或区域...');
+    await user.type(searchInput, 'test');
+
+    // 点击清除选择
+    await waitFor(() => {
+      const clearOption = screen.getByText('清除选择');
+      expect(clearOption).toBeInTheDocument();
+    });
+
+    await user.click(screen.getByText('清除选择'));
+
+    // 验证回调被调用
+    expect(mockOnValueChange).toHaveBeenCalledWith(undefined);
+  });
+
+  it('应该显示加载状态', async () => {
+    // 模拟加载延迟
+    vi.mocked(locationClient.$get).mockImplementationOnce(() =>
+      new Promise(resolve => setTimeout(() => resolve({
+        status: 200,
+        ok: true,
+        json: async () => ({
+          data: [],
+          pagination: { current: 1, pageSize: 100, total: 0 }
+        })
+      }), 100))
+    );
+
+    render(
+      <TestWrapper>
+        <LocationSelect
+          value={undefined}
+          onValueChange={mockOnValueChange}
+          placeholder="选择地点..."
+        />
+      </TestWrapper>
+    );
+
+    // 点击打开下拉菜单
+    await user.click(screen.getByRole('combobox'));
+
+    // 验证加载中状态
+    expect(screen.getByText('加载中...')).toBeInTheDocument();
+  });
+
+  it('应该显示禁用状态', () => {
+    render(
+      <TestWrapper>
+        <LocationSelect
+          value={undefined}
+          onValueChange={mockOnValueChange}
+          placeholder="选择地点..."
+          disabled={true}
+        />
+      </TestWrapper>
+    );
+
+    expect(screen.getByRole('combobox')).toBeDisabled();
+  });
+});

+ 373 - 0
tests/integration/client/admin/RouteForm.test.tsx

@@ -0,0 +1,373 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import '@testing-library/jest-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { RouteForm } from '@/client/admin/components/RouteForm';
+
+// 创建测试包装器
+const TestWrapper = ({ children }: { children: React.ReactNode }) => {
+  const queryClient = new QueryClient({
+    defaultOptions: {
+      queries: {
+        retry: false,
+      },
+    },
+  });
+
+  return (
+    <QueryClientProvider client={queryClient}>
+      {children}
+    </QueryClientProvider>
+  );
+};
+
+// Mock API 客户端
+import { locationClient, activityClient } from '@/client/api';
+
+// Mock API 客户端
+vi.mock('@/client/api', () => ({
+  locationClient: {
+    $get: vi.fn().mockResolvedValue({
+      status: 200,
+      ok: true,
+      json: async () => ({
+        data: [
+          {
+            id: 1,
+            name: '北京工人体育场',
+            address: '北京市朝阳区工人体育场北路',
+            province: {
+              id: 1,
+              name: '北京市',
+              code: '110000'
+            },
+            city: {
+              id: 2,
+              name: '北京市',
+              code: '110100'
+            },
+            district: {
+              id: 3,
+              name: '朝阳区',
+              code: '110105'
+            },
+            latitude: 39.9334,
+            longitude: 116.4473,
+            isDisabled: 0,
+            createdAt: '2024-01-01T00:00:00.000Z',
+            updatedAt: '2024-01-01T00:00:00.000Z'
+          },
+          {
+            id: 2,
+            name: '北京鸟巢',
+            address: '北京市朝阳区国家体育场南路1号',
+            province: {
+              id: 1,
+              name: '北京市',
+              code: '110000'
+            },
+            city: {
+              id: 2,
+              name: '北京市',
+              code: '110100'
+            },
+            district: {
+              id: 3,
+              name: '朝阳区',
+              code: '110105'
+            },
+            latitude: 39.9929,
+            longitude: 116.3963,
+            isDisabled: 0,
+            createdAt: '2024-01-01T00:00:00.000Z',
+            updatedAt: '2024-01-01T00:00:00.000Z'
+          }
+        ],
+        pagination: {
+          current: 1,
+          pageSize: 100,
+          total: 2
+        }
+      })
+    })
+  },
+  activityClient: {
+    $get: vi.fn().mockResolvedValue({
+      status: 200,
+      ok: true,
+      json: async () => ({
+        data: [
+          {
+            id: 1,
+            name: '测试活动',
+            type: 'departure',
+            startDate: '2025-10-17T08:00:00.000Z',
+            endDate: '2025-10-17T18:00:00.000Z',
+            isDisabled: 0
+          }
+        ],
+        pagination: {
+          current: 1,
+          pageSize: 100,
+          total: 1
+        }
+      })
+    })
+  }
+}));
+
+// Mock ActivitySelect 组件,使其自动返回有效的 activityId
+vi.mock('@/client/admin/components/ActivitySelect', () => ({
+  ActivitySelect: ({ value, onValueChange, placeholder, 'data-testid': dataTestId }: any) => (
+    <button
+      data-testid={dataTestId}
+      onClick={() => onValueChange && onValueChange(1)}
+    >
+      {value ? `已选择活动 ${value}` : placeholder}
+    </button>
+  )
+}));
+
+describe('RouteForm', () => {
+  const user = userEvent.setup();
+  const mockOnSubmit = vi.fn();
+  const mockOnCancel = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('应该正确渲染创建表单', () => {
+    render(
+      <TestWrapper>
+        <RouteForm
+          onSubmit={mockOnSubmit}
+          onCancel={mockOnCancel}
+        />
+      </TestWrapper>
+    );
+
+    expect(screen.getByLabelText('路线名称 *')).toBeInTheDocument();
+    expect(screen.getByText('车型 *')).toBeInTheDocument();
+    expect(screen.getByText('出发地 *')).toBeInTheDocument();
+    expect(screen.getByTestId('start-location-select')).toBeInTheDocument();
+    expect(screen.getByText('目的地 *')).toBeInTheDocument();
+    expect(screen.getByTestId('end-location-select')).toBeInTheDocument();
+    expect(screen.getByText('上车点 *')).toBeInTheDocument();
+    expect(screen.getByText('下车点 *')).toBeInTheDocument();
+    expect(screen.getByText('出发时间 *')).toBeInTheDocument();
+    expect(screen.getByText('价格 *')).toBeInTheDocument();
+    expect(screen.getByText('总座位数 *')).toBeInTheDocument();
+    expect(screen.getByText('可用座位数 *')).toBeInTheDocument();
+    expect(screen.getByText('关联活动 *')).toBeInTheDocument();
+  });
+
+  it('应该正确渲染编辑表单', () => {
+    const initialData = {
+      id: 1,
+      name: '测试路线',
+      description: '测试路线描述',
+      startLocationId: 1,
+      endLocationId: 2,
+      pickupPoint: '测试上车点',
+      dropoffPoint: '测试下车点',
+      departureTime: new Date('2025-10-17T08:00:00.000Z'),
+      vehicleType: 'bus' as const,
+      price: 100,
+      seatCount: 50,
+      availableSeats: 45,
+      activityId: 1,
+      isDisabled: 0
+    };
+
+    render(
+      <TestWrapper>
+        <RouteForm
+          initialData={initialData}
+          onSubmit={mockOnSubmit}
+          onCancel={mockOnCancel}
+        />
+      </TestWrapper>
+    );
+
+    expect(screen.getByDisplayValue('测试路线')).toBeInTheDocument();
+    expect(screen.getByDisplayValue('测试路线描述')).toBeInTheDocument();
+    expect(screen.getByDisplayValue('测试上车点')).toBeInTheDocument();
+    expect(screen.getByDisplayValue('测试下车点')).toBeInTheDocument();
+    expect(screen.getByDisplayValue('100')).toBeInTheDocument();
+    expect(screen.getByDisplayValue('50')).toBeInTheDocument();
+    expect(screen.getByDisplayValue('45')).toBeInTheDocument();
+  });
+
+  it('应该能够选择出发地和目的地', async () => {
+    render(
+      <TestWrapper>
+        <RouteForm
+          onSubmit={mockOnSubmit}
+          onCancel={mockOnCancel}
+        />
+      </TestWrapper>
+    );
+
+    // 点击出发地选择器
+    const startLocationSelect = screen.getByTestId('start-location-select');
+    await user.click(startLocationSelect);
+
+    // 等待地点数据加载
+    await waitFor(() => {
+      expect(screen.getByText('北京工人体育场')).toBeInTheDocument();
+      expect(screen.getByText('北京鸟巢')).toBeInTheDocument();
+    });
+
+    // 选择出发地 - 使用更具体的选择器
+    const startLocationOptions = screen.getAllByRole('option');
+    const startLocationOption = startLocationOptions.find(option =>
+      option.textContent?.includes('北京工人体育场')
+    );
+    expect(startLocationOption).toBeDefined();
+    await user.click(startLocationOption!);
+
+    // 验证出发地被选中
+    await waitFor(() => {
+      expect(screen.getByTestId('start-location-select')).toHaveTextContent('北京工人体育场');
+    });
+
+    // 点击目的地选择器
+    const endLocationSelect = screen.getByTestId('end-location-select');
+    await user.click(endLocationSelect);
+
+    // 等待地点数据加载
+    await waitFor(() => {
+      expect(screen.getAllByRole('option').length).toBeGreaterThan(0);
+    });
+
+    // 选择目的地
+    const endLocationOptions = screen.getAllByRole('option');
+    const endLocationOption = endLocationOptions.find(option =>
+      option.textContent?.includes('北京鸟巢')
+    );
+    expect(endLocationOption).toBeDefined();
+    await user.click(endLocationOption!);
+
+    // 验证目的地被选中
+    await waitFor(() => {
+      expect(screen.getByTestId('end-location-select')).toHaveTextContent('北京鸟巢');
+    });
+  });
+
+  it('应该验证必填字段', async () => {
+    render(
+      <TestWrapper>
+        <RouteForm
+          onSubmit={mockOnSubmit}
+          onCancel={mockOnCancel}
+        />
+      </TestWrapper>
+    );
+
+    // 尝试提交空表单
+    await user.click(screen.getByRole('button', { name: '创建路线' }));
+
+    // 验证错误消息
+    await waitFor(() => {
+      expect(screen.getByText('路线名称不能为空')).toBeInTheDocument();
+    });
+  });
+
+  it('应该能够选择出发地和目的地', async () => {
+    render(
+      <TestWrapper>
+        <RouteForm
+          onSubmit={mockOnSubmit}
+          onCancel={mockOnCancel}
+        />
+      </TestWrapper>
+    );
+
+    // 选择出发地
+    const startLocationSelect = screen.getByTestId('start-location-select');
+    await user.click(startLocationSelect);
+    await waitFor(() => {
+      expect(screen.getByText('北京工人体育场')).toBeInTheDocument();
+    });
+
+    // 使用更可靠的方法选择地点
+    const startLocationOptions = screen.getAllByRole('option');
+    const startLocationOption = startLocationOptions.find(option =>
+      option.textContent?.includes('北京工人体育场')
+    );
+    expect(startLocationOption).toBeDefined();
+    await user.click(startLocationOption!);
+
+    // 验证出发地被选中
+    await waitFor(() => {
+      expect(screen.getByTestId('start-location-select')).toHaveTextContent('北京工人体育场');
+    });
+
+    // 选择目的地
+    const endLocationSelect = screen.getByTestId('end-location-select');
+    await user.click(endLocationSelect);
+    await waitFor(() => {
+      expect(screen.getAllByRole('option').length).toBeGreaterThan(0);
+    });
+
+    const endLocationOptions = screen.getAllByRole('option');
+    const endLocationOption = endLocationOptions.find(option =>
+      option.textContent?.includes('北京鸟巢')
+    );
+    expect(endLocationOption).toBeDefined();
+    await user.click(endLocationOption!);
+
+    // 验证目的地被选中
+    await waitFor(() => {
+      expect(screen.getByTestId('end-location-select')).toHaveTextContent('北京鸟巢');
+    });
+  });
+
+  it('应该能够取消表单', async () => {
+    render(
+      <TestWrapper>
+        <RouteForm
+          onSubmit={mockOnSubmit}
+          onCancel={mockOnCancel}
+        />
+      </TestWrapper>
+    );
+
+    await user.click(screen.getByRole('button', { name: '取消' }));
+
+    expect(mockOnCancel).toHaveBeenCalled();
+  });
+
+  it('应该在编辑模式下显示状态选择', () => {
+    const initialData = {
+      id: 1,
+      name: '测试路线',
+      description: '测试路线描述',
+      startLocationId: 1,
+      endLocationId: 2,
+      pickupPoint: '测试上车点',
+      dropoffPoint: '测试下车点',
+      departureTime: new Date('2025-10-17T08:00:00.000Z'),
+      vehicleType: 'bus' as const,
+      price: 100,
+      seatCount: 50,
+      availableSeats: 45,
+      activityId: 1,
+      isDisabled: 0
+    };
+
+    render(
+      <TestWrapper>
+        <RouteForm
+          initialData={initialData}
+          onSubmit={mockOnSubmit}
+          onCancel={mockOnCancel}
+        />
+      </TestWrapper>
+    );
+
+    expect(screen.getByLabelText('路线状态')).toBeInTheDocument();
+  });
+});