Просмотр исходного кода

✨ feat(route): implement route search functionality with activity filtering

- add new RouteService with search, detail and popular routes features
- update route API to support area-based search and complex filtering
- implement pagination and sorting for route results

✨ feat(activity): enhance activity data structure and selection logic

- add venueLocationId and type fields to Activity interface
- update venueLocation with provinceId, cityId and districtId
- separate departure and return activities using type field

♻️ refactor(activity-select): optimize component structure and data handling

- change component export from named to default export
- update route query to handle new response format with activities array
- improve type definitions for activity and route data structures

🔧 chore(areas): optimize area query ordering

- add id and level sorting to area service queries for consistent results
yourname 3 месяцев назад
Родитель
Сommit
af2015493e

+ 22 - 15
mini/src/pages/select-activity/ActivitySelectPage.tsx

@@ -9,15 +9,18 @@ interface Activity {
   id: number
   name: string
   description?: string
+  venueLocationId: number
   venueLocation?: {
+    id: number
     name: string
-    province?: string
-    city?: string
-    district?: string
-    address?: string
+    provinceId: number
+    cityId: number
+    districtId: number
+    address: string
   }
   startDate: string
   endDate: string
+  type: 'departure' | 'return'
   imageUrl?: string
 }
 
@@ -48,7 +51,7 @@ interface Location {
   address?: string
 }
 
-export const ActivitySelectPage: React.FC = () => {
+const ActivitySelectPage: React.FC = () => {
   const router = useRouter()
   const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null)
   const [startLocation, setStartLocation] = useState<Location | null>(null)
@@ -63,11 +66,11 @@ export const ActivitySelectPage: React.FC = () => {
   }
 
   // 查询路线和关联活动
-  const { data: routes = [], isLoading } = useQuery({
+  const { data: routeData, isLoading } = useQuery({
     queryKey: ['routes', 'search', searchParams, startLocation, endLocation],
     queryFn: async () => {
       if (!startLocation?.id || !endLocation?.id) {
-        return []
+        return { routes: [], activities: [] }
       }
 
       const res = await routeClient.search.$get({
@@ -81,22 +84,24 @@ export const ActivitySelectPage: React.FC = () => {
         }
       })
       if (res.status !== 200) throw new Error('查询路线失败')
-      return await res.json()
+      const data = await res.json()
+      return data.data || { routes: [], activities: [] }
     },
     enabled: !!startLocation?.id && !!endLocation?.id
   })
 
+  const routes = routeData?.routes || []
+  const activities = routeData?.activities || []
+
   // 分离去程和返程活动
-  const departureActivities = routes
-    .filter((route: Route) => route.routeType === 'departure')
-    .flatMap((route: Route) => route.activities)
+  const departureActivities = activities
+    .filter((activity: Activity) => activity.type === 'departure')
     .filter((activity, index, self) =>
       index === self.findIndex(a => a.id === activity.id)
     )
 
-  const returnActivities = routes
-    .filter((route: Route) => route.routeType === 'return')
-    .flatMap((route: Route) => route.activities)
+  const returnActivities = activities
+    .filter((activity: Activity) => activity.type === 'return')
     .filter((activity, index, self) =>
       index === self.findIndex(a => a.id === activity.id)
     )
@@ -364,4 +369,6 @@ export const ActivitySelectPage: React.FC = () => {
       </ScrollView>
     </View>
   )
-}
+}
+
+export default ActivitySelectPage

+ 69 - 96
src/server/api/routes/index.ts

@@ -1,5 +1,8 @@
 import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
 import { z } from '@hono/zod-openapi';
+import { AppDataSource } from '@/server/data-source';
+import { RouteService } from '@/server/modules/routes/route.service';
+import { VehicleType } from '@/server/modules/routes/route.schema';
 
 // 路线搜索参数Schema
 const searchRoutesSchema = z.object({
@@ -11,12 +14,20 @@ const searchRoutesSchema = z.object({
     example: 2,
     description: '目的地ID'
   }),
+  startAreaIds: z.string().optional().openapi({
+    example: '[1,34,1001]',
+    description: '出发地区ID数组 [provinceId,cityId,districtId]'
+  }),
+  endAreaIds: z.string().optional().openapi({
+    example: '[2,35,1002]',
+    description: '目的地区ID数组 [provinceId,cityId,districtId]'
+  }),
   date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, '日期格式必须为YYYY-MM-DD').optional().openapi({
     example: '2025-10-18',
     description: '出发日期'
   }),
-  routeType: z.enum(['去程', '返程']).optional().openapi({
-    example: '去程',
+  routeType: z.enum(['departure', 'return']).optional().openapi({
+    example: 'departure',
     description: '路线类型'
   }),
   minPrice: z.coerce.number().int().min(0, '最低价格不能为负数').optional().openapi({
@@ -51,41 +62,69 @@ const routeSearchResultSchema = z.object({
   data: z.object({
     routes: z.array(z.object({
       id: z.number(),
+      name: z.string(),
+      description: z.string().nullable(),
+      startLocationId: z.number(),
+      endLocationId: z.number(),
       startLocation: z.object({
         id: z.number(),
         name: z.string(),
+        provinceId: z.number(),
+        cityId: z.number(),
+        districtId: z.number(),
         address: z.string()
       }),
       endLocation: z.object({
         id: z.number(),
         name: z.string(),
+        provinceId: z.number(),
+        cityId: z.number(),
+        districtId: z.number(),
         address: z.string()
       }),
+      pickupPoint: z.string(),
+      dropoffPoint: z.string(),
       departureTime: z.string(),
-      vehicleType: z.string(),
+      vehicleType: z.nativeEnum(VehicleType),
       price: z.number(),
       seatCount: z.number(),
-      routeType: z.string(),
+      availableSeats: z.number(),
+      activityId: z.number(),
       activity: z.object({
         id: z.number(),
         name: z.string(),
-        description: z.string(),
+        description: z.string().nullable(),
+        venueLocationId: z.number(),
         venueLocation: z.object({
           id: z.number(),
           name: z.string(),
+          provinceId: z.number(),
+          cityId: z.number(),
+          districtId: z.number(),
           address: z.string()
         }),
         startDate: z.string(),
         endDate: z.string()
-      })
+      }),
+      routeType: z.enum(['departure', 'return']),
+      isDisabled: z.number(),
+      isDeleted: z.number(),
+      createdBy: z.number().nullable(),
+      updatedBy: z.number().nullable(),
+      createdAt: z.string(),
+      updatedAt: z.string()
     })),
     activities: z.array(z.object({
       id: z.number(),
       name: z.string(),
-      description: z.string(),
+      description: z.string().nullable(),
+      venueLocationId: z.number(),
       venueLocation: z.object({
         id: z.number(),
         name: z.string(),
+        provinceId: z.number(),
+        cityId: z.number(),
+        districtId: z.number(),
         address: z.string()
       }),
       startDate: z.string(),
@@ -142,6 +181,8 @@ const app = new OpenAPIHono()
       const {
         startLocationId,
         endLocationId,
+        startAreaIds,
+        endAreaIds,
         routeType,
         minPrice,
         maxPrice,
@@ -151,102 +192,34 @@ const app = new OpenAPIHono()
         pageSize
       } = c.req.valid('query');
 
-      // 这里需要调用路线服务进行查询
-      // 由于路线服务尚未实现,这里返回模拟数据
-      const mockRoutes = [
-        {
-          id: 1,
-          startLocation: { id: 1, name: '北京南站', address: '北京市丰台区北京南站' },
-          endLocation: { id: 2, name: '上海虹桥站', address: '上海市闵行区虹桥站' },
-          departureTime: '2025-10-18T08:00:00Z',
-          vehicleType: '大巴',
-          price: 200,
-          seatCount: 40,
-          routeType: '去程',
-          activity: {
-            id: 1,
-            name: '北京到上海商务会议',
-            description: '前往上海参加商务会议',
-            venueLocation: { id: 2, name: '上海虹桥站', address: '上海市闵行区虹桥站' },
-            startDate: '2025-10-18T09:00:00Z',
-            endDate: '2025-10-20T18:00:00Z'
-          }
-        },
-        {
-          id: 2,
-          startLocation: { id: 2, name: '上海虹桥站', address: '上海市闵行区虹桥站' },
-          endLocation: { id: 1, name: '北京南站', address: '北京市丰台区北京南站' },
-          departureTime: '2025-10-20T18:00:00Z',
-          vehicleType: '大巴',
-          price: 200,
-          seatCount: 40,
-          routeType: '返程',
-          activity: {
-            id: 1,
-            name: '北京到上海商务会议',
-            description: '前往上海参加商务会议',
-            venueLocation: { id: 2, name: '上海虹桥站', address: '上海市闵行区虹桥站' },
-            startDate: '2025-10-18T09:00:00Z',
-            endDate: '2025-10-20T18:00:00Z'
-          }
-        }
-      ];
-
-      // 简单的筛选逻辑(在实际实现中应该在数据库层面进行)
-      let filteredRoutes = mockRoutes;
+      // 初始化路线服务
+      const routeService = new RouteService(AppDataSource);
 
-      if (startLocationId) {
-        filteredRoutes = filteredRoutes.filter(route => route.startLocation.id === startLocationId);
-      }
-
-      if (endLocationId) {
-        filteredRoutes = filteredRoutes.filter(route => route.endLocation.id === endLocationId);
-      }
+      // 解析省市区ID数组
+      const parsedStartAreaIds = startAreaIds ? JSON.parse(startAreaIds) : undefined;
+      const parsedEndAreaIds = endAreaIds ? JSON.parse(endAreaIds) : undefined;
 
-      if (routeType) {
-        filteredRoutes = filteredRoutes.filter(route => route.routeType === routeType);
-      }
-
-      if (minPrice !== undefined) {
-        filteredRoutes = filteredRoutes.filter(route => route.price >= minPrice);
-      }
-
-      if (maxPrice !== undefined) {
-        filteredRoutes = filteredRoutes.filter(route => route.price <= maxPrice);
-      }
-
-      // 排序
-      filteredRoutes.sort((a, b) => {
-        if (sortBy === 'price') {
-          return sortOrder === 'ASC' ? a.price - b.price : b.price - a.price;
-        } else {
-          return sortOrder === 'ASC'
-            ? new Date(a.departureTime).getTime() - new Date(b.departureTime).getTime()
-            : new Date(b.departureTime).getTime() - new Date(a.departureTime).getTime();
-        }
+      // 调用路线服务进行查询
+      const result = await routeService.searchRoutes({
+        startLocationId,
+        endLocationId,
+        startAreaIds: parsedStartAreaIds,
+        endAreaIds: parsedEndAreaIds,
+        routeType: routeType as 'departure' | 'return',
+        minPrice,
+        maxPrice,
+        sortBy: sortBy as 'price' | 'departureTime',
+        sortOrder: sortOrder as 'ASC' | 'DESC',
+        page,
+        pageSize
       });
 
-      // 分页
-      const startIndex = (page - 1) * pageSize;
-      const endIndex = startIndex + pageSize;
-      const paginatedRoutes = filteredRoutes.slice(startIndex, endIndex);
-
-      // 去重后的活动列表
-      const uniqueActivities = Array.from(
-        new Map(paginatedRoutes.map(route => [route.activity.id, route.activity])).values()
-      );
-
       return c.json({
         success: true,
         data: {
-          routes: paginatedRoutes,
-          activities: uniqueActivities,
-          pagination: {
-            page,
-            pageSize,
-            total: filteredRoutes.length,
-            totalPages: Math.ceil(filteredRoutes.length / pageSize)
-          }
+          routes: result.routes,
+          activities: result.activities,
+          pagination: result.pagination
         },
         message: '搜索路线成功'
       }, 200);

+ 2 - 0
src/server/modules/areas/area.service.ts

@@ -33,6 +33,8 @@ export class AreaService {
       },
       relations: ['children'],
       order: {
+        id: 'ASC',
+        level: 'ASC',
         name: 'ASC'
       }
     });

+ 241 - 0
src/server/modules/routes/route.service.ts

@@ -0,0 +1,241 @@
+import { DataSource, In, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { RouteEntity } from './route.entity';
+import { VehicleType } from './route.schema';
+import { ActivityEntity } from '@/server/modules/activities/activity.entity';
+import { LocationEntity } from '@/server/modules/locations/location.entity';
+
+export interface RouteSearchParams {
+  startLocationId?: number;
+  endLocationId?: number;
+  startAreaIds?: number[]; // 省市区ID数组 [provinceId, cityId, districtId]
+  endAreaIds?: number[];   // 省市区ID数组 [provinceId, cityId, districtId]
+  date?: string;
+  routeType?: 'departure' | 'return';
+  minPrice?: number;
+  maxPrice?: number;
+  vehicleType?: VehicleType;
+  sortBy?: 'price' | 'departureTime';
+  sortOrder?: 'ASC' | 'DESC';
+  page?: number;
+  pageSize?: number;
+}
+
+export interface RouteSearchResult {
+  routes: RouteEntity[];
+  activities: ActivityEntity[];
+  pagination: {
+    page: number;
+    pageSize: number;
+    total: number;
+    totalPages: number;
+  };
+}
+
+export class RouteService extends GenericCrudService<RouteEntity> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, RouteEntity, {
+      userTracking: {
+        createdByField: 'createdBy',
+        updatedByField: 'updatedBy'
+      }
+    });
+  }
+
+  /**
+   * 搜索路线
+   */
+  async searchRoutes(params: RouteSearchParams): Promise<RouteSearchResult> {
+    const {
+      startLocationId,
+      endLocationId,
+      startAreaIds,
+      endAreaIds,
+      date,
+      routeType,
+      minPrice,
+      maxPrice,
+      vehicleType,
+      sortBy = 'departureTime',
+      sortOrder = 'ASC',
+      page = 1,
+      pageSize = 20
+    } = params;
+
+    // 构建查询条件
+    const where: any = {
+      isDeleted: 0,
+      isDisabled: 0
+    };
+
+    // 精确地点查询(优先)
+    if (startLocationId) {
+      where.startLocationId = startLocationId;
+    }
+    if (endLocationId) {
+      where.endLocationId = endLocationId;
+    }
+
+    // 日期筛选
+    if (date) {
+      const targetDate = new Date(date);
+      const nextDate = new Date(targetDate);
+      nextDate.setDate(targetDate.getDate() + 1);
+
+      where.departureTime = MoreThanOrEqual(targetDate);
+      where.departureTime = LessThanOrEqual(nextDate);
+    }
+
+    // 价格筛选
+    if (minPrice !== undefined) {
+      where.price = MoreThanOrEqual(minPrice);
+    }
+    if (maxPrice !== undefined) {
+      where.price = LessThanOrEqual(maxPrice);
+    }
+
+    // 车型筛选
+    if (vehicleType) {
+      where.vehicleType = vehicleType;
+    }
+
+    // 构建排序
+    const order: any = {};
+    if (sortBy === 'price') {
+      order.price = sortOrder;
+    } else {
+      order.departureTime = sortOrder;
+    }
+
+    // 获取路线列表
+    const [routes, total] = await this.getList(
+      page,
+      pageSize,
+      undefined,
+      undefined,
+      where,
+      ['startLocation', 'endLocation', 'activity'],
+      order
+    );
+
+    // 根据省市区筛选(如果提供了省市区ID)
+    let filteredRoutes = routes;
+
+    if (startAreaIds && startAreaIds.length > 0) {
+      filteredRoutes = filteredRoutes.filter(route => {
+        const location = route.startLocation;
+        return this.matchAreaIds(location, startAreaIds);
+      });
+    }
+
+    if (endAreaIds && endAreaIds.length > 0) {
+      filteredRoutes = filteredRoutes.filter(route => {
+        const location = route.endLocation;
+        return this.matchAreaIds(location, endAreaIds);
+      });
+    }
+
+    // 根据路线类型筛选
+    if (routeType) {
+      filteredRoutes = filteredRoutes.filter(route => route.routeType === routeType);
+    }
+
+    // 获取去重后的活动列表
+    const activityIds = Array.from(new Set(filteredRoutes.map(route => route.activityId)));
+    const activities = activityIds.length > 0
+      ? await this.dataSource.getRepository(ActivityEntity).find({
+          where: { id: In(activityIds) }
+        })
+      : [];
+
+    return {
+      routes: filteredRoutes,
+      activities,
+      pagination: {
+        page,
+        pageSize,
+        total: filteredRoutes.length,
+        totalPages: Math.ceil(filteredRoutes.length / pageSize)
+      }
+    };
+  }
+
+  /**
+   * 检查地点是否匹配省市区ID
+   */
+  private matchAreaIds(location: any, areaIds: number[]): boolean {
+    // 这里需要根据实际的Location实体结构来匹配
+    // 假设Location实体有provinceId, cityId, districtId字段
+    if (!location) return false;
+
+    const [provinceId, cityId, districtId] = areaIds;
+
+    // 匹配省份
+    if (provinceId && location.provinceId !== provinceId) {
+      return false;
+    }
+
+    // 匹配城市
+    if (cityId && location.cityId !== cityId) {
+      return false;
+    }
+
+    // 匹配区县
+    if (districtId && location.districtId !== districtId) {
+      return false;
+    }
+
+    return true;
+  }
+
+  /**
+   * 根据ID获取路线详情
+   */
+  async getRouteById(id: number): Promise<RouteEntity | null> {
+    return this.getById(id, ['startLocation', 'endLocation', 'activity', 'activity.venueLocation']);
+  }
+
+  /**
+   * 获取热门路线(按预订次数排序)
+   */
+  async getPopularRoutes(limit: number = 10): Promise<RouteEntity[]> {
+    // 这里可以添加更复杂的逻辑,比如按预订次数排序
+    // 目前先返回最新的路线
+    const [routes] = await this.getList(
+      1,
+      limit,
+      undefined,
+      undefined,
+      { isDeleted: 0, isDisabled: 0 },
+      ['startLocation', 'endLocation', 'activity'],
+      { createdAt: 'DESC' }
+    );
+    return routes;
+  }
+
+  /**
+   * 获取可用座位数
+   */
+  async getAvailableSeats(routeId: number): Promise<number> {
+    const route = await this.getById(routeId);
+    return route?.availableSeats || 0;
+  }
+
+  /**
+   * 更新座位数
+   */
+  async updateSeats(routeId: number, bookedSeats: number): Promise<boolean> {
+    const route = await this.getById(routeId);
+    if (!route) {
+      return false;
+    }
+
+    const newAvailableSeats = route.availableSeats - bookedSeats;
+    if (newAvailableSeats < 0) {
+      return false;
+    }
+
+    await this.repository.update(routeId, { availableSeats: newAvailableSeats });
+    return true;
+  }
+}