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

✨ feat(admin): 添加旅行活动与路线管理功能

- 新增活动管理模块,包含活动实体、Schema和API路由
- 新增路线管理模块,包含路线实体、Schema和API路由
- 在管理后台添加旅行管理菜单,包含活动管理和路线管理页面
- 实现活动列表和路线列表的CRUD操作界面
- 添加种子数据脚本,包含示例活动和路线数据
- 更新数据库配置,添加活动和路线实体
- 扩展API路由,集成活动和路线管理接口
- 更新Claude设置,添加活动和路线API权限
yourname 4 месяцев назад
Родитель
Сommit
7c1486a6fc

+ 3 - 1
.claude/settings.local.json

@@ -39,7 +39,9 @@
       "Bash(tree:*)",
       "Bash(npx typeorm migration:generate:*)",
       "Bash(pnpm db:seed)",
-      "Bash(curl:*)"
+      "Bash(curl:*)",
+      "Bash(\"http://localhost:8080/api/v1/admin/activities\")",
+      "Bash(\"http://localhost:8080/api/v1/admin/routes\")"
     ],
     "deny": [],
     "ask": []

+ 231 - 0
scripts/seed.ts

@@ -0,0 +1,231 @@
+import { AppDataSource } from '../src/server/data-source.js';
+import { ActivityEntity, ActivityType } from '../src/server/modules/activities/activity.entity.js';
+import { RouteEntity } from '../src/server/modules/routes/route.entity.js';
+
+async function seed() {
+  console.log('开始创建种子数据...');
+
+  try {
+    // 初始化数据库连接
+    await AppDataSource.initialize();
+    console.log('数据库连接已建立');
+
+    // 获取Repository
+    const activityRepository = AppDataSource.getRepository(ActivityEntity);
+    const routeRepository = AppDataSource.getRepository(RouteEntity);
+
+    // 清空现有数据
+    await routeRepository.createQueryBuilder().delete().execute();
+    await activityRepository.createQueryBuilder().delete().execute();
+    console.log('已清空现有数据');
+
+    // 创建示例活动数据
+    const activities = [
+      {
+        name: '国庆节去程活动',
+        description: '国庆节期间的去程出行活动',
+        type: ActivityType.DEPARTURE,
+        startDate: new Date('2025-10-01T00:00:00Z'),
+        endDate: new Date('2025-10-07T23:59:59Z'),
+      },
+      {
+        name: '国庆节返程活动',
+        description: '国庆节期间的返程出行活动',
+        type: ActivityType.RETURN,
+        startDate: new Date('2025-10-05T00:00:00Z'),
+        endDate: new Date('2025-10-10T23:59:59Z'),
+      },
+      {
+        name: '元旦去程活动',
+        description: '元旦期间的去程出行活动',
+        type: ActivityType.DEPARTURE,
+        startDate: new Date('2026-01-01T00:00:00Z'),
+        endDate: new Date('2026-01-03T23:59:59Z'),
+      },
+      {
+        name: '元旦返程活动',
+        description: '元旦期间的返程出行活动',
+        type: ActivityType.RETURN,
+        startDate: new Date('2026-01-02T00:00:00Z'),
+        endDate: new Date('2026-01-04T23:59:59Z'),
+      },
+      {
+        name: '春节去程活动',
+        description: '春节期间的去程出行活动',
+        type: ActivityType.DEPARTURE,
+        startDate: new Date('2026-02-10T00:00:00Z'),
+        endDate: new Date('2026-02-16T23:59:59Z'),
+      },
+      {
+        name: '春节返程活动',
+        description: '春节期间的返程出行活动',
+        type: ActivityType.RETURN,
+        startDate: new Date('2026-02-14T00:00:00Z'),
+        endDate: new Date('2026-02-20T23:59:59Z'),
+      },
+    ];
+
+    // 保存活动数据
+    const savedActivities = await activityRepository.save(activities);
+    console.log(`已创建 ${savedActivities.length} 个活动`);
+
+    // 创建示例路线数据
+    const routes = [
+      // 国庆节去程路线
+      {
+        name: '北京-上海高铁',
+        description: '北京到上海的高铁专线',
+        startPoint: '北京',
+        endPoint: '上海',
+        pickupPoint: '北京南站',
+        dropoffPoint: '上海虹桥站',
+        departureTime: new Date('2025-10-01T08:00:00Z'),
+        vehicleType: '高铁',
+        price: 553.5,
+        seatCount: 500,
+        availableSeats: 450,
+        activityId: savedActivities[0].id,
+      },
+      {
+        name: '北京-广州飞机',
+        description: '北京到广州的航班',
+        startPoint: '北京',
+        endPoint: '广州',
+        pickupPoint: '北京首都机场',
+        dropoffPoint: '广州白云机场',
+        departureTime: new Date('2025-10-01T10:30:00Z'),
+        vehicleType: '飞机',
+        price: 1200,
+        seatCount: 200,
+        availableSeats: 180,
+        activityId: savedActivities[0].id,
+      },
+      {
+        name: '北京-深圳动车',
+        description: '北京到深圳的动车专线',
+        startPoint: '北京',
+        endPoint: '深圳',
+        pickupPoint: '北京西站',
+        dropoffPoint: '深圳北站',
+        departureTime: new Date('2025-10-01T09:15:00Z'),
+        vehicleType: '动车',
+        price: 756,
+        seatCount: 600,
+        availableSeats: 550,
+        activityId: savedActivities[0].id,
+      },
+
+      // 国庆节返程路线
+      {
+        name: '上海-北京高铁',
+        description: '上海到北京的高铁专线',
+        startPoint: '上海',
+        endPoint: '北京',
+        pickupPoint: '上海虹桥站',
+        dropoffPoint: '北京南站',
+        departureTime: new Date('2025-10-07T14:00:00Z'),
+        vehicleType: '高铁',
+        price: 553.5,
+        seatCount: 500,
+        availableSeats: 400,
+        activityId: savedActivities[1].id,
+      },
+      {
+        name: '广州-北京飞机',
+        description: '广州到北京的航班',
+        startPoint: '广州',
+        endPoint: '北京',
+        pickupPoint: '广州白云机场',
+        dropoffPoint: '北京首都机场',
+        departureTime: new Date('2025-10-07T16:30:00Z'),
+        vehicleType: '飞机',
+        price: 1100,
+        seatCount: 200,
+        availableSeats: 150,
+        activityId: savedActivities[1].id,
+      },
+
+      // 元旦去程路线
+      {
+        name: '北京-天津城际',
+        description: '北京到天津的城际列车',
+        startPoint: '北京',
+        endPoint: '天津',
+        pickupPoint: '北京南站',
+        dropoffPoint: '天津站',
+        departureTime: new Date('2026-01-01T09:00:00Z'),
+        vehicleType: '城际列车',
+        price: 54.5,
+        seatCount: 600,
+        availableSeats: 500,
+        activityId: savedActivities[2].id,
+      },
+
+      // 元旦返程路线
+      {
+        name: '天津-北京城际',
+        description: '天津到北京的城际列车',
+        startPoint: '天津',
+        endPoint: '北京',
+        pickupPoint: '天津站',
+        dropoffPoint: '北京南站',
+        departureTime: new Date('2026-01-03T18:00:00Z'),
+        vehicleType: '城际列车',
+        price: 54.5,
+        seatCount: 600,
+        availableSeats: 450,
+        activityId: savedActivities[3].id,
+      },
+
+      // 春节去程路线
+      {
+        name: '北京-哈尔滨高铁',
+        description: '北京到哈尔滨的高铁专线',
+        startPoint: '北京',
+        endPoint: '哈尔滨',
+        pickupPoint: '北京朝阳站',
+        dropoffPoint: '哈尔滨西站',
+        departureTime: new Date('2026-02-10T07:30:00Z'),
+        vehicleType: '高铁',
+        price: 623.5,
+        seatCount: 500,
+        availableSeats: 480,
+        activityId: savedActivities[4].id,
+      },
+
+      // 春节返程路线
+      {
+        name: '哈尔滨-北京高铁',
+        description: '哈尔滨到北京的高铁专线',
+        startPoint: '哈尔滨',
+        endPoint: '北京',
+        pickupPoint: '哈尔滨西站',
+        dropoffPoint: '北京朝阳站',
+        departureTime: new Date('2026-02-17T15:00:00Z'),
+        vehicleType: '高铁',
+        price: 623.5,
+        seatCount: 500,
+        availableSeats: 420,
+        activityId: savedActivities[5].id,
+      },
+    ];
+
+    // 保存路线数据
+    const savedRoutes = await routeRepository.save(routes);
+    console.log(`已创建 ${savedRoutes.length} 条路线`);
+
+    console.log('种子数据创建完成!');
+    console.log(`总计: ${savedActivities.length} 个活动, ${savedRoutes.length} 条路线`);
+
+  } catch (error) {
+    console.error('创建种子数据时出错:', error);
+    throw error;
+  } finally {
+    // 关闭数据库连接
+    await AppDataSource.destroy();
+    console.log('数据库连接已关闭');
+  }
+}
+
+// 运行种子脚本
+seed().catch(console.error);

+ 24 - 1
src/client/admin/menu.tsx

@@ -8,7 +8,9 @@ import {
   LogOut,
   BarChart3,
   LayoutDashboard,
-  File
+  File,
+  Calendar,
+  MapPin
 } from 'lucide-react';
 
 export interface MenuItem {
@@ -80,6 +82,27 @@ export const useMenu = () => {
       icon: <LayoutDashboard className="h-4 w-4" />,
       path: '/admin/dashboard'
     },
+    {
+      key: 'travel',
+      label: '旅行管理',
+      icon: <Calendar className="h-4 w-4" />,
+      children: [
+        {
+          key: 'activities',
+          label: '活动管理',
+          icon: <Calendar className="h-4 w-4" />,
+          path: '/admin/activities',
+          permission: 'activity:manage'
+        },
+        {
+          key: 'routes',
+          label: '路线管理',
+          icon: <MapPin className="h-4 w-4" />,
+          path: '/admin/routes',
+          permission: 'route:manage'
+        },
+      ]
+    },
     {
       key: 'users',
       label: '用户管理',

+ 215 - 0
src/client/admin/pages/Activities.tsx

@@ -0,0 +1,215 @@
+import React from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Button } from '@/client/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
+import { DataTablePagination } from '../components/DataTablePagination';
+import { Plus, Edit, Trash2, Calendar } from 'lucide-react';
+import { useState } from 'react';
+
+interface Activity {
+  id: number;
+  name: string;
+  description?: string;
+  type: 'departure' | 'return';
+  startDate: string;
+  endDate: string;
+  isDisabled: number;
+  isDeleted: number;
+  createdAt: string;
+  updatedAt: string;
+}
+
+interface ActivitiesResponse {
+  data: Activity[];
+  pagination: {
+    total: number;
+    current: number;
+    pageSize: number;
+  };
+}
+
+export const ActivitiesPage: React.FC = () => {
+  const queryClient = useQueryClient();
+  const [page, setPage] = useState(1);
+  const [pageSize, setPageSize] = useState(20);
+
+  // 获取活动列表
+  const { data, isLoading, error } = useQuery<ActivitiesResponse>({
+    queryKey: ['activities', page, pageSize],
+    queryFn: async () => {
+      const response = await fetch(`/api/v1/admin/activities?page=${page}&pageSize=${pageSize}`, {
+        headers: {
+          'Authorization': `Bearer ${localStorage.getItem('token')}`,
+          'Content-Type': 'application/json'
+        }
+      });
+      if (!response.ok) {
+        throw new Error('获取活动列表失败');
+      }
+      return response.json();
+    },
+  });
+
+  // 删除活动
+  const deleteMutation = useMutation({
+    mutationFn: async (id: number) => {
+      const response = await fetch(`/api/v1/admin/activities/${id}`, {
+        method: 'DELETE',
+        headers: {
+          'Authorization': `Bearer ${localStorage.getItem('token')}`,
+          'Content-Type': 'application/json'
+        }
+      });
+      if (!response.ok) {
+        throw new Error('删除活动失败');
+      }
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['activities'] });
+    },
+  });
+
+
+  if (error) {
+    return (
+      <div className="p-6">
+        <Card>
+          <CardContent className="pt-6">
+            <div className="text-center text-red-500">
+              加载活动数据失败: {error.message}
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+    );
+  }
+
+  return (
+    <div className="p-6">
+      <div className="flex items-center justify-between mb-6">
+        <div>
+          <h1 className="text-3xl font-bold tracking-tight">活动管理</h1>
+          <p className="text-muted-foreground">
+            管理旅行活动,包括去程和返程活动
+          </p>
+        </div>
+        <Button>
+          <Plus className="h-4 w-4 mr-2" />
+          新建活动
+        </Button>
+      </div>
+
+      <Card>
+        <CardHeader>
+          <CardTitle>活动列表</CardTitle>
+          <CardDescription>
+            当前共有 {data?.pagination.total || 0} 个活动
+          </CardDescription>
+        </CardHeader>
+        <CardContent>
+          <div className="rounded-md border">
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>活动名称</TableHead>
+                  <TableHead>类型</TableHead>
+                  <TableHead>开始时间</TableHead>
+                  <TableHead>结束时间</TableHead>
+                  <TableHead>状态</TableHead>
+                  <TableHead className="text-right">操作</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {isLoading ? (
+                  <TableRow>
+                    <TableCell colSpan={6} className="text-center py-4">
+                      加载中...
+                    </TableCell>
+                  </TableRow>
+                ) : data?.data && data.data.length > 0 ? (
+                  data.data.map((activity) => (
+                    <TableRow key={activity.id}>
+                      <TableCell>
+                        <div className="flex items-center gap-2">
+                          <Calendar className="h-4 w-4 text-blue-500" />
+                          <span>{activity.name}</span>
+                        </div>
+                      </TableCell>
+                      <TableCell>
+                        <span className={`px-2 py-1 rounded-full text-xs ${
+                          activity.type === 'departure'
+                            ? 'bg-blue-100 text-blue-800'
+                            : 'bg-green-100 text-green-800'
+                        }`}>
+                          {activity.type === 'departure' ? '去程' : '返程'}
+                        </span>
+                      </TableCell>
+                      <TableCell>
+                        {new Date(activity.startDate).toLocaleString('zh-CN')}
+                      </TableCell>
+                      <TableCell>
+                        {new Date(activity.endDate).toLocaleString('zh-CN')}
+                      </TableCell>
+                      <TableCell>
+                        <span className={`px-2 py-1 rounded-full text-xs ${
+                          activity.isDisabled === 0
+                            ? 'bg-green-100 text-green-800'
+                            : 'bg-red-100 text-red-800'
+                        }`}>
+                          {activity.isDisabled === 0 ? '启用' : '禁用'}
+                        </span>
+                      </TableCell>
+                      <TableCell className="text-right">
+                        <div className="flex justify-end gap-2">
+                          <Button
+                            variant="outline"
+                            size="sm"
+                            onClick={() => {
+                              // TODO: 编辑活动
+                              console.log('编辑活动:', activity.id);
+                            }}
+                          >
+                            <Edit className="h-4 w-4" />
+                          </Button>
+                          <Button
+                            variant="destructive"
+                            size="sm"
+                            onClick={() => {
+                              if (confirm('确定要删除这个活动吗?')) {
+                                deleteMutation.mutate(activity.id);
+                              }
+                            }}
+                          >
+                            <Trash2 className="h-4 w-4" />
+                          </Button>
+                        </div>
+                      </TableCell>
+                    </TableRow>
+                  ))
+                ) : (
+                  <TableRow>
+                    <TableCell colSpan={6} className="text-center py-4">
+                      暂无活动数据
+                    </TableCell>
+                  </TableRow>
+                )}
+              </TableBody>
+            </Table>
+          </div>
+          {data && (
+            <DataTablePagination
+              currentPage={data.pagination.current}
+              totalCount={data.pagination.total}
+              pageSize={data.pagination.pageSize}
+              onPageChange={(page, pageSize) => {
+                setPage(page);
+                setPageSize(pageSize);
+              }}
+            />
+          )}
+        </CardContent>
+      </Card>
+    </div>
+  );
+};

+ 234 - 0
src/client/admin/pages/Routes.tsx

@@ -0,0 +1,234 @@
+import React from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Button } from '@/client/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
+import { DataTablePagination } from '../components/DataTablePagination';
+import { Plus, Edit, Trash2, MapPin, DollarSign, Users } from 'lucide-react';
+import { useState } from 'react';
+
+interface Route {
+  id: number;
+  name: string;
+  description?: string;
+  startPoint: string;
+  endPoint: string;
+  pickupPoint: string;
+  dropoffPoint: string;
+  departureTime: string;
+  vehicleType: string;
+  price: number;
+  seatCount: number;
+  availableSeats: number;
+  activityId: number;
+  isDisabled: number;
+  isDeleted: number;
+  createdAt: string;
+  updatedAt: string;
+}
+
+interface RoutesResponse {
+  data: Route[];
+  pagination: {
+    total: number;
+    current: number;
+    pageSize: number;
+  };
+}
+
+export const RoutesPage: React.FC = () => {
+  const queryClient = useQueryClient();
+  const [page, setPage] = useState(1);
+  const [pageSize, setPageSize] = useState(20);
+
+  // 获取路线列表
+  const { data, isLoading, error } = useQuery<RoutesResponse>({
+    queryKey: ['routes', page, pageSize],
+    queryFn: async () => {
+      const response = await fetch(`/api/v1/admin/routes?page=${page}&pageSize=${pageSize}`, {
+        headers: {
+          'Authorization': `Bearer ${localStorage.getItem('token')}`,
+          'Content-Type': 'application/json'
+        }
+      });
+      if (!response.ok) {
+        throw new Error('获取路线列表失败');
+      }
+      return response.json();
+    },
+  });
+
+  // 删除路线
+  const deleteMutation = useMutation({
+    mutationFn: async (id: number) => {
+      const response = await fetch(`/api/v1/admin/routes/${id}`, {
+        method: 'DELETE',
+        headers: {
+          'Authorization': `Bearer ${localStorage.getItem('token')}`,
+          'Content-Type': 'application/json'
+        }
+      });
+      if (!response.ok) {
+        throw new Error('删除路线失败');
+      }
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['routes'] });
+    },
+  });
+
+
+  if (error) {
+    return (
+      <div className="p-6">
+        <Card>
+          <CardContent className="pt-6">
+            <div className="text-center text-red-500">
+              加载路线数据失败: {error.message}
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+    );
+  }
+
+  return (
+    <div className="p-6">
+      <div className="flex items-center justify-between mb-6">
+        <div>
+          <h1 className="text-3xl font-bold tracking-tight">路线管理</h1>
+          <p className="text-muted-foreground">
+            管理旅行路线,包括出发地、目的地、车型和价格等信息
+          </p>
+        </div>
+        <Button>
+          <Plus className="h-4 w-4 mr-2" />
+          新建路线
+        </Button>
+      </div>
+
+      <Card>
+        <CardHeader>
+          <CardTitle>路线列表</CardTitle>
+          <CardDescription>
+            当前共有 {data?.pagination.total || 0} 条路线
+          </CardDescription>
+        </CardHeader>
+        <CardContent>
+          <div className="rounded-md border">
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>路线名称</TableHead>
+                  <TableHead>出发地</TableHead>
+                  <TableHead>目的地</TableHead>
+                  <TableHead>车型</TableHead>
+                  <TableHead>价格</TableHead>
+                  <TableHead>可用座位</TableHead>
+                  <TableHead>出发时间</TableHead>
+                  <TableHead>状态</TableHead>
+                  <TableHead className="text-right">操作</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {isLoading ? (
+                  <TableRow>
+                    <TableCell colSpan={9} className="text-center py-4">
+                      加载中...
+                    </TableCell>
+                  </TableRow>
+                ) : data?.data && data.data.length > 0 ? (
+                  data.data.map((route) => (
+                    <TableRow key={route.id}>
+                      <TableCell>
+                        <div className="flex items-center gap-2">
+                          <MapPin className="h-4 w-4 text-blue-500" />
+                          <span>{route.name}</span>
+                        </div>
+                      </TableCell>
+                      <TableCell>{route.startPoint}</TableCell>
+                      <TableCell>{route.endPoint}</TableCell>
+                      <TableCell>
+                        <span className="px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800">
+                          {route.vehicleType}
+                        </span>
+                      </TableCell>
+                      <TableCell>
+                        <div className="flex items-center gap-1">
+                          <DollarSign className="h-3 w-3 text-green-600" />
+                          <span>¥{route.price}</span>
+                        </div>
+                      </TableCell>
+                      <TableCell>
+                        <div className="flex items-center gap-1">
+                          <Users className="h-3 w-3 text-blue-600" />
+                          <span>
+                            {route.availableSeats}/{route.seatCount}
+                          </span>
+                        </div>
+                      </TableCell>
+                      <TableCell>
+                        {new Date(route.departureTime).toLocaleString('zh-CN')}
+                      </TableCell>
+                      <TableCell>
+                        <span className={`px-2 py-1 rounded-full text-xs ${
+                          route.isDisabled === 0
+                            ? 'bg-green-100 text-green-800'
+                            : 'bg-red-100 text-red-800'
+                        }`}>
+                          {route.isDisabled === 0 ? '启用' : '禁用'}
+                        </span>
+                      </TableCell>
+                      <TableCell className="text-right">
+                        <div className="flex justify-end gap-2">
+                          <Button
+                            variant="outline"
+                            size="sm"
+                            onClick={() => {
+                              // TODO: 编辑路线
+                              console.log('编辑路线:', route.id);
+                            }}
+                          >
+                            <Edit className="h-4 w-4" />
+                          </Button>
+                          <Button
+                            variant="destructive"
+                            size="sm"
+                            onClick={() => {
+                              if (confirm('确定要删除这条路线吗?')) {
+                                deleteMutation.mutate(route.id);
+                              }
+                            }}
+                          >
+                            <Trash2 className="h-4 w-4" />
+                          </Button>
+                        </div>
+                      </TableCell>
+                    </TableRow>
+                  ))
+                ) : (
+                  <TableRow>
+                    <TableCell colSpan={9} className="text-center py-4">
+                      暂无路线数据
+                    </TableCell>
+                  </TableRow>
+                )}
+              </TableBody>
+            </Table>
+          </div>
+          {data && (
+            <DataTablePagination
+              currentPage={data.pagination.current}
+              totalCount={data.pagination.total}
+              pageSize={data.pagination.pageSize}
+              onPageChange={(page, pageSize) => {
+                setPage(page);
+                setPageSize(pageSize);
+              }}
+            />
+          )}
+        </CardContent>
+      </Card>
+    </div>
+  );
+};

+ 12 - 0
src/client/admin/routes.tsx

@@ -7,6 +7,8 @@ import { DashboardPage } from './pages/Dashboard';
 import { UsersPage } from './pages/Users';
 import { LoginPage } from './pages/Login';
 import { FilesPage } from './pages/Files';
+import { ActivitiesPage } from './pages/Activities';
+import { RoutesPage } from './pages/Routes';
 
 export const router = createBrowserRouter([
   {
@@ -44,6 +46,16 @@ export const router = createBrowserRouter([
         element: <FilesPage />,
         errorElement: <ErrorPage />
       },
+      {
+        path: 'activities',
+        element: <ActivitiesPage />,
+        errorElement: <ErrorPage />
+      },
+      {
+        path: 'routes',
+        element: <RoutesPage />,
+        errorElement: <ErrorPage />
+      },
       {
         path: '*',
         element: <NotFoundPage />,

+ 6 - 0
src/server/api.ts

@@ -5,6 +5,8 @@ import usersRouter from './api/users/index'
 import authRoute from './api/auth/index'
 import rolesRoute from './api/roles/index'
 import fileRoutes from './api/files/index'
+import { activitiesRoutes as adminActivitiesRoutes } from './api/admin/activities'
+import { routesRoutes as adminRoutesRoutes } from './api/admin/routes'
 import { AuthContext } from './types/context'
 import { AppDataSource } from './data-source'
 import { Hono } from 'hono'
@@ -109,11 +111,15 @@ export const userRoutes = api.route('/api/v1/users', usersRouter)
 export const authRoutes = api.route('/api/v1/auth', authRoute)
 export const roleRoutes = api.route('/api/v1/roles', rolesRoute)
 export const fileApiRoutes = api.route('/api/v1/files', fileRoutes)
+export const adminActivitiesRoutesExport = api.route('/api/v1/admin/activities', adminActivitiesRoutes)
+export const adminRoutesRoutesExport = api.route('/api/v1/admin/routes', adminRoutesRoutes)
 
 export type AuthRoutes = typeof authRoutes
 export type UserRoutes = typeof userRoutes
 export type RoleRoutes = typeof roleRoutes
 export type FileRoutes = typeof fileApiRoutes
+export type AdminActivitiesRoutes = typeof adminActivitiesRoutesExport
+export type AdminRoutesRoutes = typeof adminRoutesRoutesExport
 
 app.route('/', api)
 export default app

+ 20 - 0
src/server/api/admin/activities/index.ts

@@ -0,0 +1,20 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+import { ActivityEntity } from '@/server/modules/activities/activity.entity';
+import { createActivitySchema, updateActivitySchema, getActivitySchema, activityListResponseSchema } from '@/server/modules/activities/activity.schema';
+
+// 创建活动管理API路由
+export const activitiesRoutes = createCrudRoutes({
+  entity: ActivityEntity,
+  createSchema: createActivitySchema,
+  updateSchema: updateActivitySchema,
+  getSchema: getActivitySchema,
+  listSchema: activityListResponseSchema,
+  searchFields: ['name', 'description'],
+  relations: ['routes'],
+  middleware: [authMiddleware],
+  userTracking: {
+    createdByField: 'createdBy',
+    updatedByField: 'updatedBy'
+  }
+});

+ 20 - 0
src/server/api/admin/routes/index.ts

@@ -0,0 +1,20 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+import { RouteEntity } from '@/server/modules/routes/route.entity';
+import { createRouteSchema, updateRouteSchema, getRouteSchema, routeListResponseSchema } from '@/server/modules/routes/route.schema';
+
+// 创建路线管理API路由
+export const routesRoutes = createCrudRoutes({
+  entity: RouteEntity,
+  createSchema: createRouteSchema,
+  updateSchema: updateRouteSchema,
+  getSchema: getRouteSchema,
+  listSchema: routeListResponseSchema,
+  searchFields: ['name', 'startPoint', 'endPoint', 'vehicleType'],
+  relations: ['activity'],
+  middleware: [authMiddleware],
+  userTracking: {
+    createdByField: 'createdBy',
+    updatedByField: 'updatedBy'
+  }
+});

+ 4 - 2
src/server/data-source.ts

@@ -6,6 +6,8 @@ import process from 'node:process'
 import { UserEntity as User } from "./modules/users/user.entity"
 import { Role } from "./modules/users/role.entity"
 import { File } from "./modules/files/file.entity"
+import { ActivityEntity } from "./modules/activities/activity.entity"
+import { RouteEntity } from "./modules/routes/route.entity"
 
 // 在测试环境下使用测试数据库配置
 const isTestEnv = process.env.NODE_ENV === 'test';
@@ -16,7 +18,7 @@ const dataSource = isTestEnv && testDatabaseUrl
   ? new DataSource({
       type: "postgres",
       url: testDatabaseUrl,
-      entities: [User, Role, File],
+      entities: [User, Role, File, ActivityEntity, RouteEntity],
       migrations: [],
       synchronize: true, // 测试环境总是同步schema
       dropSchema: true,  // 测试环境每次重新创建schema
@@ -29,7 +31,7 @@ const dataSource = isTestEnv && testDatabaseUrl
       username: process.env.DB_USERNAME || "postgres",
       password: process.env.DB_PASSWORD || "",
       database: process.env.DB_DATABASE || "postgres",
-      entities: [User, Role, File],
+      entities: [User, Role, File, ActivityEntity, RouteEntity],
       migrations: [],
       synchronize: process.env.DB_SYNCHRONIZE !== "false",
       logging: process.env.DB_LOGGING === "true",

+ 53 - 0
src/server/modules/activities/activity.entity.ts

@@ -0,0 +1,53 @@
+import { Entity, PrimaryGeneratedColumn, Column, OneToMany, CreateDateColumn, UpdateDateColumn } from 'typeorm';
+import { RouteEntity } from '@/server/modules/routes/route.entity';
+import { DeleteStatus, DisabledStatus } from '@/share/types';
+
+export enum ActivityType {
+  DEPARTURE = 'departure', // 去程活动
+  RETURN = 'return'        // 返程活动
+}
+
+@Entity({ name: 'activities' })
+export class ActivityEntity {
+  @PrimaryGeneratedColumn({ unsigned: true, comment: '活动ID' })
+  id!: number;
+
+  @Column({ name: 'name', type: 'varchar', length: 255, comment: '活动名称' })
+  name!: string;
+
+  @Column({ name: 'description', type: 'text', nullable: true, comment: '活动描述' })
+  description!: string | null;
+
+  @Column({
+    name: 'type',
+    type: 'enum',
+    enum: ActivityType,
+    comment: '活动类型: departure(去程), return(返程)'
+  })
+  type!: ActivityType;
+
+  @Column({ name: 'start_date', type: 'timestamp', comment: '开始日期' })
+  startDate!: Date;
+
+  @Column({ name: 'end_date', type: 'timestamp', comment: '结束日期' })
+  endDate!: Date;
+
+  @OneToMany(() => RouteEntity, (route) => route.activity)
+  routes!: RouteEntity[];
+
+  @Column({ name: 'is_disabled', type: 'int', default: DisabledStatus.ENABLED, comment: '是否禁用(0:启用,1:禁用)' })
+  isDisabled!: DisabledStatus;
+
+  @Column({ name: 'is_deleted', type: 'int', default: DeleteStatus.NOT_DELETED, comment: '是否删除(0:未删除,1:已删除)' })
+  isDeleted!: DeleteStatus;
+
+  @CreateDateColumn({ name: 'created_at', type: 'timestamp' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
+  updatedAt!: Date;
+
+  constructor(partial?: Partial<ActivityEntity>) {
+    Object.assign(this, partial);
+  }
+}

+ 89 - 0
src/server/modules/activities/activity.schema.ts

@@ -0,0 +1,89 @@
+import { z } from 'zod';
+import { DisabledStatus } from '@/share/types';
+import { ActivityType } from './activity.entity';
+
+// 活动创建Schema
+export const createActivitySchema = z.object({
+  name: z.string().min(1, '活动名称不能为空').max(255, '活动名称不能超过255个字符'),
+  description: z.string().max(1000, '活动描述不能超过1000个字符').optional().nullable(),
+  type: z.nativeEnum(ActivityType, {
+    message: '活动类型必须是departure(去程)或return(返程)'
+  }),
+  startDate: z.string().datetime('开始日期格式不正确'),
+  endDate: z.string().datetime('结束日期格式不正确'),
+}).refine((data) => new Date(data.startDate) < new Date(data.endDate), {
+  message: '开始日期必须早于结束日期',
+  path: ['endDate'],
+});
+
+// 活动更新Schema
+export const updateActivitySchema = z.object({
+  name: z.string().min(1, '活动名称不能为空').max(255, '活动名称不能超过255个字符').optional(),
+  description: z.string().max(1000, '活动描述不能超过1000个字符').optional().nullable(),
+  type: z.nativeEnum(ActivityType, {
+    message: '活动类型必须是departure(去程)或return(返程)'
+  }).optional(),
+  startDate: z.string().datetime('开始日期格式不正确').optional(),
+  endDate: z.string().datetime('结束日期格式不正确').optional(),
+  isDisabled: z.nativeEnum(DisabledStatus).optional(),
+}).refine((data) => {
+  if (data.startDate && data.endDate) {
+    return new Date(data.startDate) < new Date(data.endDate);
+  }
+  return true;
+}, {
+  message: '开始日期必须早于结束日期',
+  path: ['endDate'],
+});
+
+// 活动获取Schema
+export const getActivitySchema = z.object({
+  id: z.number().int().positive('ID必须为正整数'),
+});
+
+// 活动列表查询Schema
+export const listActivitiesSchema = z.object({
+  keyword: z.string().optional(),
+  type: z.nativeEnum(ActivityType).optional(),
+  isDisabled: z.coerce.number().int().optional(),
+  page: z.coerce.number().int().min(1).default(1),
+  pageSize: z.coerce.number().int().min(1).max(100).default(20),
+  sortBy: z.enum(['name', 'startDate', 'endDate', 'createdAt']).default('createdAt'),
+  sortOrder: z.enum(['ASC', 'DESC']).default('DESC'),
+});
+
+// 活动列表返回Schema
+export const activityListResponseSchema = z.object({
+  id: z.number().int().positive('ID必须为正整数'),
+  name: z.string().min(1, '活动名称不能为空').max(255, '活动名称不能超过255个字符'),
+  description: z.string().max(1000, '活动描述不能超过1000个字符').optional().nullable(),
+  type: z.nativeEnum(ActivityType, {
+    message: '活动类型必须是departure(去程)或return(返程)'
+  }),
+  startDate: z.coerce.string(),
+  endDate: z.coerce.string(),
+  isDisabled: z.nativeEnum(DisabledStatus),
+  createdAt: z.coerce.string(),
+  updatedAt: z.coerce.string(),
+  createdBy: z.number().int().optional(),
+  updatedBy: z.number().int().optional(),
+});
+
+// 活动删除Schema
+export const deleteActivitySchema = z.object({
+  id: z.number().int().positive('ID必须为正整数'),
+});
+
+// 活动启用/禁用Schema
+export const toggleActivityStatusSchema = z.object({
+  id: z.number().int().positive('ID必须为正整数'),
+  isDisabled: z.nativeEnum(DisabledStatus),
+});
+
+// 导出类型
+export type CreateActivityInput = z.infer<typeof createActivitySchema>;
+export type UpdateActivityInput = z.infer<typeof updateActivitySchema>;
+export type GetActivityInput = z.infer<typeof getActivitySchema>;
+export type ListActivitiesInput = z.infer<typeof listActivitiesSchema>;
+export type DeleteActivityInput = z.infer<typeof deleteActivitySchema>;
+export type ToggleActivityStatusInput = z.infer<typeof toggleActivityStatusSchema>;

+ 65 - 0
src/server/modules/routes/route.entity.ts

@@ -0,0 +1,65 @@
+import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
+import { ActivityEntity } from '@/server/modules/activities/activity.entity';
+import { DeleteStatus, DisabledStatus } from '@/share/types';
+
+@Entity({ name: 'routes' })
+export class RouteEntity {
+  @PrimaryGeneratedColumn({ unsigned: true, comment: '路线ID' })
+  id!: number;
+
+  @Column({ name: 'name', type: 'varchar', length: 255, comment: '路线名称' })
+  name!: string;
+
+  @Column({ name: 'description', type: 'text', nullable: true, comment: '路线描述' })
+  description!: string | null;
+
+  @Column({ name: 'start_point', type: 'varchar', length: 255, comment: '出发地' })
+  startPoint!: string;
+
+  @Column({ name: 'end_point', type: 'varchar', length: 255, comment: '目的地' })
+  endPoint!: string;
+
+  @Column({ name: 'pickup_point', type: 'varchar', length: 255, comment: '上车点' })
+  pickupPoint!: string;
+
+  @Column({ name: 'dropoff_point', type: 'varchar', length: 255, comment: '下车点' })
+  dropoffPoint!: string;
+
+  @Column({ name: 'departure_time', type: 'timestamp', comment: '出发时间' })
+  departureTime!: Date;
+
+  @Column({ name: 'vehicle_type', type: 'varchar', length: 50, comment: '车型' })
+  vehicleType!: string;
+
+  @Column({ name: 'price', type: 'decimal', precision: 10, scale: 2, comment: '价格' })
+  price!: number;
+
+  @Column({ name: 'seat_count', type: 'int', unsigned: true, comment: '座位数' })
+  seatCount!: number;
+
+  @Column({ name: 'available_seats', type: 'int', unsigned: true, comment: '可用座位数' })
+  availableSeats!: number;
+
+  @Column({ name: 'activity_id', type: 'int', unsigned: true, comment: '关联活动ID' })
+  activityId!: number;
+
+  @ManyToOne(() => ActivityEntity, (activity) => activity.routes)
+  @JoinColumn({ name: 'activity_id', referencedColumnName: 'id' })
+  activity!: ActivityEntity;
+
+  @Column({ name: 'is_disabled', type: 'int', default: DisabledStatus.ENABLED, comment: '是否禁用(0:启用,1:禁用)' })
+  isDisabled!: DisabledStatus;
+
+  @Column({ name: 'is_deleted', type: 'int', default: DeleteStatus.NOT_DELETED, comment: '是否删除(0:未删除,1:已删除)' })
+  isDeleted!: DeleteStatus;
+
+  @CreateDateColumn({ name: 'created_at', type: 'timestamp' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
+  updatedAt!: Date;
+
+  constructor(partial?: Partial<RouteEntity>) {
+    Object.assign(this, partial);
+  }
+}

+ 103 - 0
src/server/modules/routes/route.schema.ts

@@ -0,0 +1,103 @@
+import { z } from 'zod';
+import { DisabledStatus } from '@/share/types';
+import { ActivityType } from '../activities/activity.entity';
+
+// 路线创建Schema
+export const createRouteSchema = z.object({
+  name: z.string().min(1, '路线名称不能为空').max(255, '路线名称不能超过255个字符'),
+  description: z.string().max(1000, '路线描述不能超过1000个字符').optional().nullable(),
+  startPoint: z.string().min(1, '出发地不能为空').max(255, '出发地不能超过255个字符'),
+  endPoint: z.string().min(1, '目的地不能为空').max(255, '目的地不能超过255个字符'),
+  pickupPoint: z.string().min(1, '上车点不能为空').max(255, '上车点不能超过255个字符'),
+  dropoffPoint: z.string().min(1, '下车点不能为空').max(255, '下车点不能超过255个字符'),
+  departureTime: z.string().datetime('出发时间格式不正确'),
+  vehicleType: z.string().min(1, '车型不能为空').max(50, '车型不能超过50个字符'),
+  price: z.number().min(0, '价格不能为负数').max(99999999.99, '价格不能超过99999999.99'),
+  seatCount: z.number().int().min(1, '座位数至少为1').max(1000, '座位数不能超过1000'),
+  availableSeats: z.number().int().min(0, '可用座位数不能为负数').max(1000, '可用座位数不能超过1000'),
+  activityId: z.number().int().positive('活动ID必须为正整数'),
+});
+
+// 路线更新Schema
+export const updateRouteSchema = z.object({
+  name: z.string().min(1, '路线名称不能为空').max(255, '路线名称不能超过255个字符').optional(),
+  description: z.string().max(1000, '路线描述不能超过1000个字符').optional().nullable(),
+  startPoint: z.string().min(1, '出发地不能为空').max(255, '出发地不能超过255个字符').optional(),
+  endPoint: z.string().min(1, '目的地不能为空').max(255, '目的地不能超过255个字符').optional(),
+  pickupPoint: z.string().min(1, '上车点不能为空').max(255, '上车点不能超过255个字符').optional(),
+  dropoffPoint: z.string().min(1, '下车点不能为空').max(255, '下车点不能超过255个字符').optional(),
+  departureTime: z.string().datetime('出发时间格式不正确').optional(),
+  vehicleType: z.string().min(1, '车型不能为空').max(50, '车型不能超过50个字符').optional(),
+  price: z.number().min(0, '价格不能为负数').max(99999999.99, '价格不能超过99999999.99').optional(),
+  seatCount: z.number().int().min(1, '座位数至少为1').max(1000, '座位数不能超过1000').optional(),
+  availableSeats: z.number().int().min(0, '可用座位数不能为负数').max(1000, '可用座位数不能超过1000').optional(),
+  activityId: z.number().int().positive('活动ID必须为正整数').optional(),
+  isDisabled: z.nativeEnum(DisabledStatus).optional(),
+});
+
+// 路线获取Schema
+export const getRouteSchema = z.object({
+  id: z.number().int().positive('ID必须为正整数'),
+});
+
+// 路线列表查询Schema
+export const listRoutesSchema = z.object({
+  keyword: z.string().optional(),
+  vehicleType: z.string().optional(),
+  minPrice: z.coerce.number().min(0).optional(),
+  maxPrice: z.coerce.number().min(0).optional(),
+  activityId: z.coerce.number().int().positive().optional(),
+  isDisabled: z.coerce.number().int().optional(),
+  page: z.coerce.number().int().min(1).default(1),
+  pageSize: z.coerce.number().int().min(1).max(100).default(20),
+  sortBy: z.enum(['name', 'price', 'departureTime', 'createdAt']).default('createdAt'),
+  sortOrder: z.enum(['ASC', 'DESC']).default('DESC'),
+});
+
+// 路线列表返回Schema
+export const routeListResponseSchema = z.object({
+  id: z.number().int().positive('ID必须为正整数'),
+  name: z.string().min(1, '路线名称不能为空').max(255, '路线名称不能超过255个字符'),
+  description: z.string().max(1000, '路线描述不能超过1000个字符').optional().nullable(),
+  startPoint: z.string().min(1, '出发地不能为空').max(255, '出发地不能超过255个字符'),
+  endPoint: z.string().min(1, '目的地不能为空').max(255, '目的地不能超过255个字符'),
+  pickupPoint: z.string().min(1, '上车点不能为空').max(255, '上车点不能超过255个字符'),
+  dropoffPoint: z.string().min(1, '下车点不能为空').max(255, '下车点不能超过255个字符'),
+  departureTime: z.coerce.string(),
+  vehicleType: z.string().min(1, '车型不能为空').max(50, '车型不能超过50个字符'),
+  price: z.coerce.number().min(0, '价格不能为负数').max(99999999.99, '价格不能超过99999999.99'),
+  seatCount: z.number().int().min(1, '座位数至少为1').max(1000, '座位数不能超过1000'),
+  availableSeats: z.number().int().min(0, '可用座位数不能为负数').max(1000, '可用座位数不能超过1000'),
+  activityId: z.number().int().positive('活动ID必须为正整数'),
+  isDisabled: z.nativeEnum(DisabledStatus),
+  createdAt: z.coerce.string(),
+  updatedAt: z.coerce.string(),
+  createdBy: z.number().int().optional(),
+  updatedBy: z.number().int().optional(),
+  activity: z.object({
+    id: z.number().int().positive('活动ID必须为正整数'),
+    name: z.string().min(1, '活动名称不能为空').max(255, '活动名称不能超过255个字符'),
+    type: z.nativeEnum(ActivityType, {
+      message: '活动类型必须是departure(去程)或return(返程)'
+    }),
+  }).optional(),
+});
+
+// 路线删除Schema
+export const deleteRouteSchema = z.object({
+  id: z.number().int().positive('ID必须为正整数'),
+});
+
+// 路线启用/禁用Schema
+export const toggleRouteStatusSchema = z.object({
+  id: z.number().int().positive('ID必须为正整数'),
+  isDisabled: z.nativeEnum(DisabledStatus),
+});
+
+// 导出类型
+export type CreateRouteInput = z.infer<typeof createRouteSchema>;
+export type UpdateRouteInput = z.infer<typeof updateRouteSchema>;
+export type GetRouteInput = z.infer<typeof getRouteSchema>;
+export type ListRoutesInput = z.infer<typeof listRoutesSchema>;
+export type DeleteRouteInput = z.infer<typeof deleteRouteSchema>;
+export type ToggleRouteStatusInput = z.infer<typeof toggleRouteStatusSchema>;

+ 58 - 0
src/share/activity.types.ts

@@ -0,0 +1,58 @@
+import { ActivityEntity, ActivityType } from '@/server/modules/activities/activity.entity';
+import { RouteEntity } from '@/server/modules/routes/route.entity';
+
+// 活动类型定义
+export type Activity = ActivityEntity;
+
+// 活动创建请求
+export interface CreateActivityRequest {
+  name: string;
+  description?: string | null;
+  type: ActivityType;
+  startDate: Date;
+  endDate: Date;
+}
+
+// 活动更新请求
+export interface UpdateActivityRequest {
+  name?: string;
+  description?: string | null;
+  type?: ActivityType;
+  startDate?: Date;
+  endDate?: Date;
+  isDisabled?: number;
+}
+
+// 活动响应
+export interface ActivityResponse {
+  id: number;
+  name: string;
+  description: string | null;
+  type: ActivityType;
+  startDate: Date;
+  endDate: Date;
+  routes?: RouteEntity[];
+  isDisabled: number;
+  isDeleted: number;
+  createdAt: Date;
+  updatedAt: Date;
+}
+
+// 活动列表响应
+export interface ActivityListResponse {
+  items: ActivityResponse[];
+  total: number;
+  page: number;
+  pageSize: number;
+}
+
+// 活动搜索参数
+export interface ActivitySearchParams {
+  keyword?: string;
+  type?: ActivityType;
+  isDisabled?: number;
+  page?: number;
+  pageSize?: number;
+  sortBy?: string;
+  sortOrder?: 'ASC' | 'DESC';
+}

+ 82 - 0
src/share/route.types.ts

@@ -0,0 +1,82 @@
+import { RouteEntity } from '@/server/modules/routes/route.entity';
+import { ActivityEntity } from '@/server/modules/activities/activity.entity';
+
+// 路线类型定义
+export type Route = RouteEntity;
+
+// 路线创建请求
+export interface CreateRouteRequest {
+  name: string;
+  description?: string | null;
+  startPoint: string;
+  endPoint: string;
+  pickupPoint: string;
+  dropoffPoint: string;
+  departureTime: Date;
+  vehicleType: string;
+  price: number;
+  seatCount: number;
+  availableSeats: number;
+  activityId: number;
+}
+
+// 路线更新请求
+export interface UpdateRouteRequest {
+  name?: string;
+  description?: string | null;
+  startPoint?: string;
+  endPoint?: string;
+  pickupPoint?: string;
+  dropoffPoint?: string;
+  departureTime?: Date;
+  vehicleType?: string;
+  price?: number;
+  seatCount?: number;
+  availableSeats?: number;
+  activityId?: number;
+  isDisabled?: number;
+}
+
+// 路线响应
+export interface RouteResponse {
+  id: number;
+  name: string;
+  description: string | null;
+  startPoint: string;
+  endPoint: string;
+  pickupPoint: string;
+  dropoffPoint: string;
+  departureTime: Date;
+  vehicleType: string;
+  price: number;
+  seatCount: number;
+  availableSeats: number;
+  activityId: number;
+  activity?: ActivityEntity;
+  isDisabled: number;
+  isDeleted: number;
+  createdAt: Date;
+  updatedAt: Date;
+}
+
+// 路线列表响应
+export interface RouteListResponse {
+  items: RouteResponse[];
+  total: number;
+  page: number;
+  pageSize: number;
+}
+
+// 路线搜索参数
+export interface RouteSearchParams {
+  keyword?: string;
+  vehicleType?: string;
+  minPrice?: number;
+  maxPrice?: number;
+  activityId?: number;
+  isDisabled?: number;
+  page?: number;
+  pageSize?: number;
+  sortBy?: string;
+  sortOrder?: 'ASC' | 'DESC';
+}