# 赛程编排系统功能分析文档 ## 📋 文档概述 **文档名称**: 赛程编排页面系统逻辑设计与实现方案 **创建日期**: 2025-12-07 **版本**: v1.0 **适用系统**: 武术赛事管理系统 - 赛程编排模块 --- ## 1. 功能概述 赛程编排页面是武术赛事管理系统的核心模块,负责将所有报名的参赛队伍/选手按照一定规则自动分组,并分配到不同的比赛时间段和场地,生成完整的比赛赛程。 ### 1.1 核心目标 - ✅ 自动生成比赛时间段 - ✅ 智能分组(集体优先、个人在后) - ✅ 自动分配场地 - ✅ 支持手动调整和优化 - ✅ 可视化拖拽编排 --- ## 2. 系统逻辑详细设计 ### 2.1 时间段自动生成逻辑 #### 2.1.1 需求描述 根据赛事的比赛开始时间和结束时间,系统自动生成时间段: - **上午场**: 08:30 开始 - **下午场**: 13:30 开始 #### 2.1.2 时间段生成规则 ``` 输入: - competition_start_time: 比赛开始时间 (例如: 2026-01-05 09:00:00) - competition_end_time: 比赛结束时间 (例如: 2026-01-10 18:00:00) 输出: - 时间段列表 (按天拆分,每天2个时间段) 生成逻辑: 1. 计算比赛总天数 = 结束日期 - 开始日期 + 1 2. 对于每一天: - 生成上午时间段: YYYY-MM-DD 08:30:00 ~ 12:00:00 - 生成下午时间段: YYYY-MM-DD 13:30:00 ~ 17:30:00 3. 过滤掉第一天8:30之前和最后一天结束时间之后的时间段 ``` #### 2.1.3 时间段数据结构 ```javascript { id: 'slot_1', // 时间段唯一标识 date: '2026-01-05', // 日期 label: '1月5日 上午', // 显示标签 startTime: '2026-01-05 08:30:00', // 开始时间 endTime: '2026-01-05 12:00:00', // 结束时间 period: 'morning', // 时段: morning/afternoon groups: [] // 该时间段下的分组列表 } ``` #### 2.1.4 算法实现 ```javascript function generateTimeSlots(competitionStartTime, competitionEndTime) { const slots = []; const start = new Date(competitionStartTime); const end = new Date(competitionEndTime); // 计算天数(包含开始和结束当天) const days = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1; for (let i = 0; i < days; i++) { const currentDate = new Date(start); currentDate.setDate(start.getDate() + i); const dateStr = currentDate.toISOString().split('T')[0]; // 上午时间段 08:30 - 12:00 slots.push({ id: `slot_${i * 2 + 1}`, date: dateStr, label: `${currentDate.getMonth() + 1}月${currentDate.getDate()}日 上午`, startTime: `${dateStr} 08:30:00`, endTime: `${dateStr} 12:00:00`, period: 'morning', groups: [] }); // 下午时间段 13:30 - 17:30 slots.push({ id: `slot_${i * 2 + 2}`, date: dateStr, label: `${currentDate.getMonth() + 1}月${currentDate.getDate()}日 下午`, startTime: `${dateStr} 13:30:00`, endTime: `${dateStr} 17:30:00`, period: 'afternoon', groups: [] }); } return slots; } ``` --- ### 2.2 参赛数据获取与分组逻辑 #### 2.2.1 数据来源 从 `martial_athlete` 表获取所有已报名且状态为"已审核通过"的参赛数据: ```sql SELECT a.*, p.project_name, p.category, p.type, -- 项目类型: 1=个人项目, 2=集体项目 o.organization FROM martial_athlete a LEFT JOIN martial_project p ON a.project_id = p.id LEFT JOIN martial_registration_order o ON a.order_id = o.id WHERE a.competition_id = ? AND a.registration_status = 1 -- 已审核通过 ORDER BY p.type DESC, a.organization, a.order_num; ``` **说明**: - `p.type = 2` 表示集体项目 - `p.type = 1` 表示个人项目 - `ORDER BY p.type DESC` 确保集体项目(2)排在个人项目(1)前面 #### 2.2.2 分组规则 **优先级规则**: 集体项目 > 个人项目 ``` 分组步骤: 1. 将所有参赛数据按项目类型分类 - 集体项目 (type = 2) - 个人项目 (type = 1) 2. 对集体项目分组 - 按单位(organization)分组 - 按项目(project_id)分组 - 生成分组名称: "{单位名称} - {项目名称}" - 例如: "少林寺武术学校 - 集体拳术表演" 3. 对个人项目分组 - 按项目(project_id)分组 - 按性别分组(可选) - 按年龄组分组(可选) - 生成分组名称: "{项目名称} - {组别}" - 例如: "成年男子太极拳 - A组" 4. 合并分组列表: [集体项目分组, 个人项目分组] ``` #### 2.2.3 分组数据结构 ```javascript { id: 'group_1', // 分组唯一标识 name: '少林寺武术学校 - 集体拳术', // 分组名称(可编辑) code: 'GROUP_001', // 分组编号 type: 'team', // 类型: team=集体, individual=个人 projectId: 208, // 项目ID projectName: '集体拳术表演', // 项目名称 category: '集体项目', // 组别 organization: '少林寺武术学校', // 单位 venueId: null, // 分配的场地ID venueName: null, // 场地名称 timeSlotId: null, // 分配的时间段ID participants: [ // 参赛人员列表 { id: 1000001, playerName: '张三', organization: '少林寺武术学校', projectName: '集体拳术表演', category: '集体项目', orderNum: 1 }, // ... 更多参赛人员 ], estimatedDuration: 8, // 预计时长(分钟) editingName: false, // 是否正在编辑名称 tempName: '' // 临时名称(编辑时使用) } ``` #### 2.2.4 自动分组算法 ```javascript function autoGroupParticipants(participants) { const groups = []; let groupId = 1; // 1. 分离集体和个人项目 const teamProjects = participants.filter(p => p.type === 2); const individualProjects = participants.filter(p => p.type === 1); // 2. 处理集体项目 - 按单位+项目分组 const teamGroupMap = new Map(); teamProjects.forEach(p => { const key = `${p.organization}_${p.projectId}`; if (!teamGroupMap.has(key)) { teamGroupMap.set(key, []); } teamGroupMap.get(key).push(p); }); teamGroupMap.forEach((members, key) => { const first = members[0]; groups.push({ id: `group_${groupId}`, name: `${first.organization} - ${first.projectName}`, code: `GROUP_${String(groupId).padStart(3, '0')}`, type: 'team', projectId: first.projectId, projectName: first.projectName, category: first.category, organization: first.organization, venueId: null, venueName: null, timeSlotId: null, participants: members, estimatedDuration: first.estimatedDuration || 8, editingName: false, tempName: '' }); groupId++; // 自增放在push后面 }); // 3. 处理个人项目 - 按项目+组别分组(每组最多30人) const individualGroupMap = new Map(); individualProjects.forEach(p => { const key = `${p.projectId}_${p.category}`; if (!individualGroupMap.has(key)) { individualGroupMap.set(key, []); } individualGroupMap.get(key).push(p); }); individualGroupMap.forEach((members, key) => { const first = members[0]; const maxPerGroup = 30; // 每组最多30人 const groupCount = Math.ceil(members.length / maxPerGroup); for (let i = 0; i < groupCount; i++) { const groupMembers = members.slice(i * maxPerGroup, (i + 1) * maxPerGroup); const groupLabel = groupCount > 1 ? ` - ${String.fromCharCode(65 + i)}组` : ''; groups.push({ id: `group_${groupId}`, name: `${first.projectName}${groupLabel}`, code: `GROUP_${String(groupId).padStart(3, '0')}`, type: 'individual', projectId: first.projectId, projectName: first.projectName, category: first.category, organization: null, venueId: null, venueName: null, timeSlotId: null, participants: groupMembers, // 个人项目的时长 = 人数 × 每人平均时长 // 注意: 如果是同时比赛则不应相乘,这里假设是依次出场 estimatedDuration: groupMembers.length * (first.estimatedDuration || 5), editingName: false, tempName: '' }); groupId++; // 自增放在push后面 } }); return groups; } ``` --- ### 2.3 分组名称编辑功能 #### 2.3.1 需求说明 用户可以自定义修改系统生成的分组名称,例如: - 系统生成: "少林寺武术学校 - 集体拳术表演" - 用户修改: "少林组集体拳" #### 2.3.2 交互流程 ``` 1. 用户双击分组名称 → 进入编辑模式 2. 显示输入框,回填当前名称 3. 用户修改名称 4. 按 Enter 或失焦 → 保存修改 5. 按 Esc → 取消修改 ``` #### 2.3.3 实现代码 ```javascript // 进入编辑模式 function editGroupName(group) { group.editingName = true; group.tempName = group.name; // 聚焦到输入框 nextTick(() => { const input = document.querySelector(`#group-${group.id} input`); if (input) { input.focus(); input.select(); } }); } // 保存分组名称 function saveGroupName(group) { if (group.tempName && group.tempName.trim()) { group.name = group.tempName.trim(); } group.editingName = false; // 调用API保存到后端 updateGroupName(group.id, group.name); } // 取消编辑 function cancelEditGroupName(group) { group.editingName = false; group.tempName = ''; } ``` --- ### 2.4 场地自动分配逻辑 #### 2.4.1 场地自动分配规则 ``` 目标: 均匀分配所有分组到各个场地,避免某个场地负载过重 分配算法: 1. 获取所有可用场地列表 2. 计算每个场地的总时长 = Σ(分配到该场地的分组的预计时长) 3. 采用"负载均衡"算法: - 将分组按预计时长降序排列 - 每次选择当前负载最小的场地 - 将分组分配到该场地 - 更新场地负载 伪代码: venues = getVenues() groups = getAllGroups() // 初始化场地负载 venueLoads = {} for venue in venues: venueLoads[venue.id] = 0 // 按时长降序排序分组 groups.sort(by: estimatedDuration, desc) // 贪心分配 for group in groups: // 找负载最小的场地 minVenue = findMinLoadVenue(venueLoads) // 分配 group.venueId = minVenue.id group.venueName = minVenue.name // 更新负载 venueLoads[minVenue.id] += group.estimatedDuration ``` #### 2.4.2 实现代码 ```javascript function autoAssignVenues(groups, venues) { if (!venues || venues.length === 0) { ElMessage.warning('没有可用的场地'); return; } // 初始化场地负载 const venueLoads = {}; venues.forEach(venue => { venueLoads[venue.id] = 0; }); // 按预计时长降序排序(先分配时间长的) const sortedGroups = [...groups].sort((a, b) => b.estimatedDuration - a.estimatedDuration ); // 贪心分配 sortedGroups.forEach(group => { // 找当前负载最小的场地 let minVenue = venues[0]; let minLoad = venueLoads[venues[0].id]; venues.forEach(venue => { if (venueLoads[venue.id] < minLoad) { minVenue = venue; minLoad = venueLoads[venue.id]; } }); // 分配到该场地 group.venueId = minVenue.id; group.venueName = minVenue.name; // 更新负载 venueLoads[minVenue.id] += group.estimatedDuration; }); ElMessage.success('场地分配完成'); // 保存到后端 saveVenueAssignments(groups); } ``` --- ### 2.5 手动移动分组功能 #### 2.5.1 场地间移动 **需求**: 通过右上角的按钮将分组移动到其他场地 **交互流程**: ``` 1. 用户点击分组右上角的"移动"按钮 2. 弹出场地选择下拉菜单 3. 用户选择目标场地 4. 系统将分组移动到目标场地 5. 更新UI显示 ``` **实现**: ```javascript function moveGroupToVenue(group, targetVenueId) { const targetVenue = venues.find(v => v.id === targetVenueId); if (!targetVenue) { ElMessage.error('目标场地不存在'); return; } // 更新分组的场地信息 group.venueId = targetVenue.id; group.venueName = targetVenue.name; ElMessage.success(`已移动到${targetVenue.name}`); // 保存到后端 updateGroupVenue(group.id, targetVenueId); } ``` #### 2.5.2 场地内拖拽排序 **需求**: 在同一场地内,可以通过拖拽调整分组的顺序 **实现**: 使用 `vuedraggable` 组件 ```vue ``` --- ### 2.6 时间段选择与分组显示 #### 2.6.1 时间段切换 **需求**: 用户可以选择不同的时间段,查看该时间段下的分组安排 **UI布局**: ``` [1月5日 上午] [1月5日 下午] [1月6日 上午] [1月6日 下午] ... ↓ (选中) 显示 "1月5日 下午" 时间段下的所有分组 ``` **实现**: ```javascript data() { return { timeSlots: [], // 所有时间段 currentTimeSlotId: null, // 当前选中的时间段ID allGroups: [] // 所有分组 }; }, computed: { // 当前时间段下的分组 currentTimeSlotGroups() { if (!this.currentTimeSlotId) return []; return this.allGroups.filter(group => group.timeSlotId === this.currentTimeSlotId ); } }, methods: { // 选择时间段 selectTimeSlot(timeSlotId) { this.currentTimeSlotId = timeSlotId; } } ``` #### 2.6.2 未分配分组池和未分组参赛者 系统中存在两个不同的"未分配"概念,需要明确区分: **1. 未分组的参赛者 (Ungrouped Participants)** - **含义**: 已报名成功但还没有被加入任何分组的参赛选手 - **来源**: 新增的报名数据,或从已有分组中移除的选手 - **显示位置**: 页面底部或侧边栏的"未分组参赛者"区域 - **操作**: 可以手动添加到已有分组,或通过"自动分组"按钮批量分组 **2. 未分配时间段的分组 (Unassigned Groups)** - **含义**: 已经分好组(包含参赛人员)但还没有分配到具体时间段的分组 - **来源**: 新创建的分组,或从时间段中移除的分组 - **显示位置**: 时间段选择器下方的"未分配分组池" - **操作**: 可以拖拽到任意时间段,或通过"自动编排"自动分配 **UI位置**: 在时间段按钮下方显示"未分配分组池",在页面底部显示"未分组参赛者" ```vue ``` --- ## 3. 完整的页面功能流程 ### 3.1 页面初始化流程 ``` 1. 用户进入编排页面 ↓ 2. 从URL获取 competitionId (赛事ID) ↓ 3. 加载赛事基本信息 - 赛事名称 - 比赛开始时间 - 比赛结束时间 ↓ 4. 自动生成时间段列表 - 调用 generateTimeSlots() - 默认选中第一个时间段 ↓ 5. 加载场地列表 - 调用 API: GET /api/martial/venue/list?competitionId={id} ↓ 6. 加载所有报名数据 - 调用 API: GET /api/martial/athlete/list?competitionId={id}&status=1 ↓ 7. 自动分组 - 调用 autoGroupParticipants() - 集体项目优先,个人项目在后 ↓ 8. 加载已保存的编排数据(如果存在) - 调用 API: GET /api/martial/schedule/list?competitionId={id} - 恢复分组的场地、时间段分配 ↓ 9. 渲染页面 - 显示时间段按钮 - 显示未分配分组 - 显示当前时间段的场地和分组 ``` ### 3.2 自动编排流程 ``` 1. 用户点击"自动编排"按钮 ↓ 2. 执行场地自动分配 - 调用 autoAssignVenues() - 使用负载均衡算法 ↓ 3. 执行时间段自动分配 - 按时间顺序填充时间段 - 考虑每个时间段的容量(4小时) ↓ 4. 保存编排结果 - 调用 API: POST /api/martial/schedule/save ↓ 5. 提示用户"自动编排完成" ↓ 6. 刷新页面显示 ``` ### 3.3 手动调整流程 ``` 1. 拖拽分组到不同场地 ↓ 2. 触发 @end 事件 ↓ 3. 更新分组的 venueId ↓ 4. 保存到后端 或 1. 点击分组的"移动"按钮 ↓ 2. 选择目标场地 ↓ 3. 更新分组的 venueId ↓ 4. 保存到后端 ``` --- ## 4. 数据库设计 ### 4.1 新增表: martial_schedule (赛程安排表) ```sql CREATE TABLE `martial_schedule` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `competition_id` BIGINT NOT NULL COMMENT '赛事ID', `group_id` VARCHAR(50) NOT NULL COMMENT '分组ID', `group_name` VARCHAR(200) NOT NULL COMMENT '分组名称', `group_code` VARCHAR(50) COMMENT '分组编号', `group_type` VARCHAR(20) COMMENT '分组类型: team=集体, individual=个人', `project_id` BIGINT COMMENT '项目ID', `venue_id` BIGINT COMMENT '场地ID', `time_slot_id` VARCHAR(50) COMMENT '时间段ID', `start_time` DATETIME COMMENT '开始时间', `end_time` DATETIME COMMENT '结束时间', `estimated_duration` INT COMMENT '预计时长(分钟)', `sort_order` INT DEFAULT 0 COMMENT '排序号', `status` TINYINT DEFAULT 0 COMMENT '状态: 0=草稿, 1=已发布', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `is_deleted` TINYINT DEFAULT 0, `tenant_id` VARCHAR(12) DEFAULT '000000', PRIMARY KEY (`id`), KEY `idx_competition` (`competition_id`), KEY `idx_venue` (`venue_id`), KEY `idx_time_slot` (`time_slot_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛程安排表'; ``` ### 4.2 新增表: martial_schedule_detail (赛程明细表) ```sql CREATE TABLE `martial_schedule_detail` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `schedule_id` BIGINT NOT NULL COMMENT '赛程ID', `athlete_id` BIGINT NOT NULL COMMENT '运动员ID', `player_name` VARCHAR(100) COMMENT '选手姓名', `organization` VARCHAR(200) COMMENT '所属单位', `project_name` VARCHAR(100) COMMENT '项目名称', `order_num` INT COMMENT '出场顺序', `actual_start_time` DATETIME COMMENT '实际开始时间', `actual_end_time` DATETIME COMMENT '实际结束时间', `status` TINYINT DEFAULT 0 COMMENT '状态: 0=未开始, 1=进行中, 2=已完成', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `is_deleted` TINYINT DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_schedule` (`schedule_id`), KEY `idx_athlete` (`athlete_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛程明细表'; ``` --- ## 5. API接口设计 ### 5.1 时间段相关接口 #### GET /api/martial/schedule/time-slots **功能**: 获取赛事的时间段列表 **请求参数**: ```json { "competitionId": 200 } ``` **响应**: ```json { "code": 200, "success": true, "data": [ { "id": "slot_1", "date": "2026-01-05", "label": "1月5日 上午", "startTime": "2026-01-05 08:30:00", "endTime": "2026-01-05 12:00:00", "period": "morning" }, { "id": "slot_2", "date": "2026-01-05", "label": "1月5日 下午", "startTime": "2026-01-05 13:30:00", "endTime": "2026-01-05 17:30:00", "period": "afternoon" } ] } ``` ### 5.2 分组相关接口 #### POST /api/martial/schedule/auto-group **功能**: 自动生成分组 **请求参数**: ```json { "competitionId": 200 } ``` **响应**: ```json { "code": 200, "success": true, "data": [ { "id": "group_1", "name": "少林寺武术学校 - 集体拳术表演", "code": "GROUP_001", "type": "team", "projectId": 208, "participants": [...], "estimatedDuration": 8 } ] } ``` #### PUT /api/martial/schedule/group/{groupId}/name **功能**: 更新分组名称 **请求参数**: ```json { "name": "少林组集体拳" } ``` **响应**: ```json { "code": 200, "success": true, "msg": "更新成功" } ``` ### 5.3 编排保存接口 #### POST /api/martial/schedule/save **功能**: 保存编排结果 **请求参数**: ```json { "competitionId": 200, "schedules": [ { "groupId": "group_1", "groupName": "少林组集体拳", "venueId": 200, "timeSlotId": "slot_1", "sortOrder": 1, "participants": [1000001, 1000002, ...] } ] } ``` **响应**: ```json { "code": 200, "success": true, "msg": "保存成功" } ``` ### 5.4 自动分配接口 #### POST /api/martial/schedule/auto-assign-venues **功能**: 自动分配场地 **请求参数**: ```json { "competitionId": 200, "groups": [...] } ``` **响应**: ```json { "code": 200, "success": true, "data": [ { "groupId": "group_1", "venueId": 200, "venueName": "主赛场A馆" } ] } ``` --- ## 6. 前端组件设计 ### 6.1 组件结构 ``` SchedulePage (编排主页面) ├── TimeSlotSelector (时间段选择器) ├── UnassignedGroupPool (未分配分组池) ├── VenueSection (场地区域) │ ├── VenueHeader (场地标题) │ └── GroupList (分组列表) │ └── GroupCard (分组卡片) │ ├── GroupHeader (分组头部) │ ├── ParticipantTable (参赛人员表格) │ └── GroupActions (操作按钮) └── ScheduleActions (页面操作按钮) ``` ### 6.2 核心组件: GroupCard ```vue ``` --- ## 7. 用户交互流程图 ### 7.1 完整操作流程 ```mermaid graph TD A[进入编排页面] --> B[加载赛事数据] B --> C[生成时间段] B --> D[加载报名数据] B --> E[加载场地列表] D --> F[自动分组] F --> G{是否有已保存的编排} G -->|有| H[加载已保存编排] G -->|无| I[显示未分配分组] H --> J[显示编排结果] I --> J J --> K{用户操作} K -->|自动编排| L[自动分配场地和时间段] K -->|编辑分组名称| M[双击编辑] K -->|拖拽分组| N[移动到目标场地/时间段] K -->|调整顺序| O[在场地内拖拽排序] K -->|删除分组| P[确认删除] L --> Q[保存编排结果] M --> Q N --> Q O --> Q P --> Q Q --> R[刷新页面显示] R --> J ``` --- ## 8. 技术实现要点 ### 8.1 关键技术栈 | 技术 | 用途 | |------|------| | Vue 3 | 前端框架 | | Element Plus | UI组件库 | | vuedraggable | 拖拽功能 | | axios | HTTP请求 | | dayjs | 时间处理 | ### 8.2 性能优化 1. **虚拟滚动**: 如果分组数量超过100个,使用虚拟滚动减少DOM渲染 2. **防抖**: 拖拽结束后延迟保存,避免频繁请求 3. **批量保存**: 收集多次修改,统一提交到后端 4. **懒加载**: 只加载当前时间段的分组数据 ### 8.3 异常处理 1. **网络异常**: 保存失败时,提示用户重试 2. **数据冲突**: 多人同时编辑时,显示冲突提示 3. **数据校验**: 保存前检查必填字段 4. **撤销/重做**: 支持编排操作的撤销和重做 --- ## 9. 测试用例 ### 9.1 功能测试 | 测试项 | 测试步骤 | 预期结果 | |--------|---------|---------| | 时间段生成 | 选择赛事,查看时间段 | 自动生成上午8:30和下午13:30的时间段 | | 自动分组 | 点击"自动分组" | 集体项目在前,个人项目在后 | | 编辑分组名称 | 双击分组名称 | 弹出输入框,可编辑 | | 拖拽分组 | 拖拽分组到其他场地 | 分组移动成功 | | 场地自动分配 | 点击"自动分配场地" | 分组均匀分配到各场地 | | 保存编排 | 修改后点击保存 | 数据保存成功 | ### 9.2 边界测试 | 测试项 | 测试条件 | 预期结果 | |--------|---------|---------| | 无报名数据 | 赛事没有报名 | 提示"暂无报名数据" | | 无场地 | 赛事没有场地 | 提示"请先添加场地" | | 时间段不足 | 分组太多,时间段不够 | 提示超出容量,建议增加时间 | | 分组名称为空 | 输入空名称 | 使用原名称,提示不能为空 | --- ## 10. 未来扩展 ### 10.1 智能推荐 - 根据历史数据,推荐最优的分组方案 - AI学习,自动优化编排结果 ### 10.2 冲突检测 - 检测同一选手是否报名多个项目 - 检测时间冲突,自动调整 ### 10.3 可视化增强 - 甘特图显示赛程时间线 - 热力图显示场地负载 ### 10.4 导出功能 - 导出Excel格式的赛程表 - 导出PDF格式的秩序册 - 生成二维码,选手扫码查看赛程 --- ## 11. 总结 本文档详细描述了赛程编排页面的完整系统逻辑,包括: ✅ 时间段自动生成算法 ✅ 智能分组规则(集体优先) ✅ 场地自动分配算法 ✅ 分组名称编辑功能 ✅ 拖拽移动和排序 ✅ 完整的数据库设计 ✅ API接口规范 ✅ 前端组件设计 **实施建议**: 1. 先实现核心功能(时间段生成、自动分组) 2. 再实现交互功能(拖拽、编辑) 3. 最后优化体验(动画、提示) **开发周期估算**: 3-5个工作日 --- **文档维护**: - 创建人: Claude Code - 创建日期: 2025-12-07 - 版本: v1.0