# 武术赛事编排系统 - 完整技术方案 > **文档版本**: v1.0 > **创建日期**: 2025-12-10 > **文档作者**: Claude Code > **项目名称**: 武术赛事管理系统 - 赛程编排模块 --- ## 📋 目录 1. [系统概述](#系统概述) 2. [架构设计](#架构设计) 3. [数据库设计](#数据库设计) 4. [后端实现](#后端实现) 5. [前端实现](#前端实现) 6. [数据流转](#数据流转) 7. [核心功能](#核心功能) 8. [API接口文档](#API接口文档) 9. [关键代码解析](#关键代码解析) 10. [使用指南](#使用指南) --- ## 1. 系统概述 ### 1.1 功能简介 武术赛事编排系统是一个智能化的赛程编排管理工具,主要功能包括: - **自动编排**: 根据参赛人员和项目自动生成赛程分组 - **手动调整**: 支持拖拽上下移动、分组移动、异常标记 - **场地管理**: 多场地、多时间段的赛程安排 - **草稿保存**: 支持保存编排草稿,随时恢复 - **锁定发布**: 完成编排后锁定,防止误操作 - **数据导出**: 导出赛程表格供打印使用 ### 1.2 技术栈 **前端技术栈**: - Vue 2.x - Element UI - Axios - Vue Router **后端技术栈**: - Spring Boot 2.x - MyBatis Plus - MySQL 8.0 - Swagger 3.0 --- ## 2. 架构设计 ### 2.1 系统架构图 ``` ┌─────────────────────────────────────────────────────────────┐ │ 前端层 (Vue.js) │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ 编排页面 │ │ 场地管理 │ │ 参赛人员管理 │ │ │ │ schedule/ │ │ venue/ │ │ participant/ │ │ │ │ index.vue │ │ index.vue │ │ index.vue │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────────────┘ ↓ HTTP/HTTPS ┌─────────────────────────────────────────────────────────────┐ │ 后端层 (Spring Boot) │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ Controller 控制器层 │ │ │ │ - MartialScheduleArrangeController (编排控制器) │ │ │ │ - MartialScheduleController (赛程控制器) │ │ │ │ - MartialVenueController (场地控制器) │ │ │ └──────────────────────────────────────────────────────┘ │ │ ↓ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ Service 业务逻辑层 │ │ │ │ - IMartialScheduleService (赛程服务) │ │ │ │ - IMartialScheduleArrangeService (编排服务) │ │ │ │ - IMartialVenueService (场地服务) │ │ │ └──────────────────────────────────────────────────────┘ │ │ ↓ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ Mapper 数据访问层 │ │ │ │ - MartialScheduleMapper │ │ │ │ - MartialScheduleGroupMapper │ │ │ │ - MartialScheduleDetailMapper │ │ │ │ - MartialScheduleParticipantMapper │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ↓ JDBC ┌─────────────────────────────────────────────────────────────┐ │ 数据库层 (MySQL 8.0) │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ 核心表: │ │ │ │ - martial_schedule_group (分组表) │ │ │ │ - martial_schedule_detail (明细表) │ │ │ │ - martial_schedule_participant (参赛者关联表) │ │ │ │ - martial_schedule_status (状态表) │ │ │ │ │ │ │ │ 关联表: │ │ │ │ - martial_competition (赛事表) │ │ │ │ - martial_athlete (参赛选手表) │ │ │ │ - martial_venue (场地表) │ │ │ │ - martial_project (项目表) │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` ### 2.2 模块划分 #### 2.2.1 前端模块 ``` src/views/martial/schedule/ ├── index.vue # 编排主页面 └── components/ ├── CompetitionGroupCard.vue # 竞赛分组卡片 (未实现) ├── VenueSelector.vue # 场地选择器 (未实现) └── ExceptionDialog.vue # 异常组对话框 (未实现) src/api/martial/ ├── activitySchedule.js # 编排API接口 ├── venue.js # 场地API接口 └── competition.js # 赛事API接口 ``` #### 2.2.2 后端模块 ``` org.springblade.modules.martial/ ├── controller/ │ ├── MartialScheduleArrangeController.java # 编排控制器 │ ├── MartialScheduleController.java # 赛程控制器 │ └── MartialVenueController.java # 场地控制器 ├── service/ │ ├── IMartialScheduleService.java # 赛程服务接口 │ ├── IMartialScheduleArrangeService.java # 编排服务接口 │ └── impl/ │ ├── MartialScheduleServiceImpl.java # 赛程服务实现 │ └── MartialScheduleArrangeServiceImpl.java # 编排服务实现 ├── mapper/ │ ├── MartialScheduleGroupMapper.java # 分组Mapper │ ├── MartialScheduleDetailMapper.java # 明细Mapper │ └── MartialScheduleParticipantMapper.java # 参赛者Mapper └── pojo/ ├── dto/ │ ├── ScheduleResultDTO.java # 编排结果DTO │ ├── CompetitionGroupDTO.java # 竞赛分组DTO │ ├── ParticipantDTO.java # 参赛者DTO │ └── SaveScheduleDraftDTO.java # 保存草稿DTO └── entity/ ├── MartialScheduleGroup.java # 分组实体 ├── MartialScheduleDetail.java # 明细实体 ├── MartialScheduleParticipant.java # 参赛者实体 └── MartialScheduleStatus.java # 状态实体 ``` --- ## 3. 数据库设计 ### 3.1 核心表设计 #### 3.1.1 赛程编排分组表 (martial_schedule_group) **用途**: 存储赛程的分组信息(按项目和组别划分) ```sql CREATE TABLE `martial_schedule_group` ( `id` bigint(0) NOT NULL COMMENT '主键ID', `competition_id` bigint(0) NOT NULL COMMENT '赛事ID', `group_name` varchar(200) NOT NULL COMMENT '分组名称(如:太极拳男组)', `project_id` bigint(0) NOT NULL COMMENT '项目ID', `project_name` varchar(100) DEFAULT NULL COMMENT '项目名称', `category` varchar(50) DEFAULT NULL COMMENT '组别(成年组、少年组等)', `project_type` tinyint(1) NOT NULL DEFAULT 1 COMMENT '项目类型(1=个人 2=集体)', `display_order` int(0) NOT NULL DEFAULT 0 COMMENT '显示顺序', `total_participants` int(0) DEFAULT 0 COMMENT '总参赛人数', `total_teams` int(0) DEFAULT 0 COMMENT '总队伍数(仅集体项目)', `estimated_duration` int(0) DEFAULT 0 COMMENT '预计时长(分钟)', PRIMARY KEY (`id`), INDEX `idx_competition` (`competition_id`), INDEX `idx_project` (`project_id`) ) COMMENT '赛程编排分组表'; ``` **关键字段说明**: - `group_name`: 分组的显示名称,如"太极拳-成年男子组" - `project_type`: 区分个人项目(1)和集体项目(2) - `display_order`: 控制分组的显示顺序,集体项目优先 - `total_teams`: 集体项目按队伍计数,个人项目此字段为0 #### 3.1.2 赛程编排明细表 (martial_schedule_detail) **用途**: 存储分组与场地、时间段的关联关系 ```sql CREATE TABLE `martial_schedule_detail` ( `id` bigint(0) NOT NULL COMMENT '主键ID', `schedule_group_id` bigint(0) NOT NULL COMMENT '分组ID', `competition_id` bigint(0) NOT NULL COMMENT '赛事ID', `venue_id` bigint(0) NOT NULL COMMENT '场地ID', `venue_name` varchar(100) DEFAULT NULL COMMENT '场地名称', `schedule_date` date NOT NULL COMMENT '比赛日期', `time_period` varchar(20) NOT NULL COMMENT '时间段(morning/afternoon)', `time_slot` varchar(20) NOT NULL COMMENT '时间点(08:30/13:30)', `estimated_start_time` datetime DEFAULT NULL COMMENT '预计开始时间', `estimated_end_time` datetime DEFAULT NULL COMMENT '预计结束时间', `participant_count` int(0) DEFAULT 0 COMMENT '参赛人数', `sort_order` int(0) DEFAULT 0 COMMENT '场内顺序', PRIMARY KEY (`id`), INDEX `idx_group` (`schedule_group_id`), INDEX `idx_venue_time` (`venue_id`, `schedule_date`, `time_slot`) ) COMMENT '赛程编排明细表'; ``` **关键字段说明**: - `schedule_group_id`: 关联到分组表 - `venue_id`: 指定该分组在哪个场地比赛 - `time_slot`: 时间点,如"08:30"、"13:30" - `sort_order`: 同一场地同一时间段内的顺序 #### 3.1.3 赛程编排参赛者关联表 (martial_schedule_participant) **用途**: 存储参赛者与赛程明细的关联,以及出场顺序 ```sql CREATE TABLE `martial_schedule_participant` ( `id` bigint(0) NOT NULL COMMENT '主键ID', `schedule_detail_id` bigint(0) NOT NULL COMMENT '编排明细ID', `schedule_group_id` bigint(0) NOT NULL COMMENT '分组ID', `participant_id` bigint(0) NOT NULL COMMENT '参赛者ID(关联martial_athlete表)', `organization` varchar(200) DEFAULT NULL COMMENT '单位名称', `player_name` varchar(100) DEFAULT NULL COMMENT '选手姓名', `project_name` varchar(100) DEFAULT NULL COMMENT '项目名称', `category` varchar(50) DEFAULT NULL COMMENT '组别', `performance_order` int(0) DEFAULT 0 COMMENT '出场顺序', PRIMARY KEY (`id`), INDEX `idx_detail` (`schedule_detail_id`), INDEX `idx_group` (`schedule_group_id`), INDEX `idx_participant` (`participant_id`) ) COMMENT '赛程编排参赛者关联表'; ``` **关键字段说明**: - `participant_id`: 关联到 martial_athlete 表 - `organization`: 冗余存储单位名称,提高查询效率 - `performance_order`: 出场顺序,前端可以调整 #### 3.1.4 赛程编排状态表 (martial_schedule_status) **用途**: 记录每个赛事的编排状态和锁定信息 ```sql CREATE TABLE `martial_schedule_status` ( `id` bigint(0) NOT NULL COMMENT '主键ID', `competition_id` bigint(0) NOT NULL UNIQUE COMMENT '赛事ID(唯一)', `schedule_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '编排状态(0=未编排 1=编排中 2=已保存锁定)', `last_auto_schedule_time` datetime DEFAULT NULL COMMENT '最后自动编排时间', `locked_time` datetime DEFAULT NULL COMMENT '锁定时间', `locked_by` varchar(100) DEFAULT NULL COMMENT '锁定人', `total_groups` int(0) DEFAULT 0 COMMENT '总分组数', `total_participants` int(0) DEFAULT 0 COMMENT '总参赛人数', PRIMARY KEY (`id`), UNIQUE INDEX `uk_competition` (`competition_id`), INDEX `idx_schedule_status` (`schedule_status`) ) COMMENT '赛程编排状态表'; ``` **关键字段说明**: - `schedule_status`: 0=未编排, 1=有草稿, 2=已锁定发布 - `locked_by`: 记录谁锁定了编排 - `locked_time`: 锁定时间,用于审计 ### 3.2 表关系图 ``` martial_competition (赛事表) ↓ 1:1 martial_schedule_status (状态表) ↓ 1:N martial_schedule_group (分组表) ↓ 1:N martial_schedule_detail (明细表) ↓ 1:N martial_schedule_participant (参赛者表) ↓ N:1 martial_athlete (选手表) ``` ### 3.3 关联表 #### martial_athlete (参赛选手表) - 节选 ```sql CREATE TABLE `martial_athlete` ( `id` bigint(0) NOT NULL COMMENT '主键ID', `order_id` bigint(0) NOT NULL COMMENT '订单ID', `competition_id` bigint(0) NOT NULL COMMENT '赛事ID', `project_id` bigint(0) COMMENT '项目ID', `player_name` varchar(50) NOT NULL COMMENT '选手姓名', `organization` varchar(200) COMMENT '所属单位', `category` varchar(50) COMMENT '组别', `team_name` varchar(100) COMMENT '队伍名称', PRIMARY KEY (`id`) ) COMMENT '参赛选手表'; ``` #### martial_venue (场地表) - 节选 ```sql CREATE TABLE `martial_venue` ( `id` bigint(0) NOT NULL COMMENT '主键ID', `competition_id` bigint(0) NOT NULL COMMENT '赛事ID', `venue_name` varchar(100) NOT NULL COMMENT '场地名称', `capacity` int(0) COMMENT '容纳人数', `location` varchar(200) COMMENT '位置', PRIMARY KEY (`id`) ) COMMENT '场地表'; ``` --- ## 4. 后端实现 ### 4.1 Controller 层 #### 4.1.1 MartialScheduleArrangeController **位置**: `org.springblade.modules.martial.controller.MartialScheduleArrangeController` **主要接口**: ```java @RestController @RequestMapping("/martial/schedule") public class MartialScheduleArrangeController { /** * 获取编排结果 * GET /api/martial/schedule/result?competitionId=1 */ @GetMapping("/result") public R getScheduleResult(@RequestParam Long competitionId); /** * 保存编排草稿 * POST /api/martial/schedule/save-draft */ @PostMapping("/save-draft") public R saveDraftSchedule(@RequestBody SaveScheduleDraftDTO dto); /** * 完成编排并锁定 * POST /api/martial/schedule/save-and-lock */ @PostMapping("/save-and-lock") public R saveAndLock(@RequestBody SaveScheduleDraftDTO dto); /** * 手动触发自动编排(测试用) * POST /api/martial/schedule/auto-arrange */ @PostMapping("/auto-arrange") public R autoArrange(@RequestBody Map params); } ``` ### 4.2 Service 层 #### 4.2.1 核心方法:getScheduleResult **功能**: 获取赛程编排结果,返回前端展示数据 **实现逻辑**: ```java @Override public ScheduleResultDTO getScheduleResult(Long competitionId) { ScheduleResultDTO result = new ScheduleResultDTO(); // 1. 使用优化的JOIN查询获取所有数据 List details = scheduleGroupMapper .selectScheduleGroupDetails(competitionId); if (details.isEmpty()) { // 没有数据,返回空结果 result.setIsDraft(true); result.setIsCompleted(false); result.setCompetitionGroups(new ArrayList<>()); return result; } // 2. 按分组ID分组数据 Map> groupMap = details.stream() .collect(Collectors.groupingBy(ScheduleGroupDetailVO::getGroupId)); // 3. 检查编排状态 boolean isCompleted = details.stream() .anyMatch(d -> "completed".equals(d.getScheduleStatus())); result.setIsCompleted(isCompleted); result.setIsDraft(!isCompleted); // 4. 组装数据 List groupDTOs = new ArrayList<>(); for (Map.Entry> entry : groupMap.entrySet()) { CompetitionGroupDTO groupDTO = buildCompetitionGroupDTO(entry.getValue()); groupDTOs.add(groupDTO); } result.setCompetitionGroups(groupDTOs); return result; } ``` **数据流程**: 1. 从数据库一次性JOIN查询所有相关数据 2. 在内存中按分组ID进行分组 3. 检查编排状态(草稿 or 已完成) 4. 构建DTO对象返回给前端 #### 4.2.2 核心方法:saveDraftSchedule **功能**: 保存编排草稿,支持用户调整后保存 **实现逻辑**: ```java @Override @Transactional public boolean saveDraftSchedule(SaveScheduleDraftDTO dto) { Long competitionId = dto.getCompetitionId(); // 1. 更新或插入状态表 MartialScheduleStatus status = getOrCreateStatus(competitionId); status.setScheduleStatus(1); // 1 = 草稿状态 updateScheduleStatus(status); // 2. 删除旧的编排数据(如果存在) deleteOldScheduleData(competitionId); // 3. 保存新的编排数据 List groups = dto.getCompetitionGroups(); for (CompetitionGroupDTO group : groups) { // 保存分组 MartialScheduleGroup scheduleGroup = convertToEntity(group); scheduleGroupMapper.insert(scheduleGroup); // 保存明细 MartialScheduleDetail detail = buildDetail(group, scheduleGroup.getId()); scheduleDetailMapper.insert(detail); // 保存参赛者 for (ParticipantDTO participant : group.getParticipants()) { MartialScheduleParticipant sp = buildParticipant( participant, detail.getId(), scheduleGroup.getId() ); scheduleParticipantMapper.insert(sp); } } return true; } ``` ### 4.3 Mapper 层 #### 4.3.1 关键SQL查询 **位置**: `MartialScheduleGroupMapper.xml` ```xml ``` **优化说明**: - 使用LEFT JOIN一次性查询所有关联数据 - 避免了N+1查询问题 - 在Service层进行内存分组,提高性能 --- ## 5. 前端实现 ### 5.1 页面结构 **文件位置**: `src/views/martial/schedule/index.vue` #### 5.1.1 页面布局 ```vue ``` ### 5.2 核心数据结构 ```javascript export default { data() { return { // 基础信息 competitionId: null, // 赛事ID orderId: null, // 订单ID // UI状态 activeTab: 'competition', // 当前Tab selectedTime: 0, // 选中的时间段索引 selectedVenueId: null, // 选中的场地ID isScheduleCompleted: false, // 是否已完成编排 loading: false, // 加载状态 // 场地和时间 venues: [], // 场地列表 timeSlots: [], // 时间段列表 // 编排数据 competitionGroups: [], // 所有竞赛分组 exceptionList: [], // 异常组列表 // 赛事信息 competitionInfo: { competitionName: '', competitionStartTime: '', competitionEndTime: '' } } }, computed: { // 根据选中的场地和时间段过滤分组 filteredCompetitionGroups() { if (!this.selectedVenueId || this.selectedTime === null) { return [] } return this.competitionGroups.filter(group => { return group.venueId === this.selectedVenueId && group.timeSlotIndex === this.selectedTime }) } } } ``` ### 5.3 核心方法 #### 5.3.1 加载编排数据 ```javascript async loadScheduleData() { try { this.loading = true const res = await getScheduleResult(this.competitionId) const data = res.data?.data if (data) { this.isScheduleCompleted = data.isCompleted || false // 加载竞赛分组数据 if (data.competitionGroups && data.competitionGroups.length > 0) { this.competitionGroups = data.competitionGroups.map(group => ({ id: group.id, title: group.title, type: group.type, count: group.count, code: group.code, venueId: group.venueId, venueName: group.venueName, timeSlot: group.timeSlot, timeSlotIndex: group.timeSlotIndex, items: (group.participants || []).map(p => ({ id: p.id, schoolUnit: p.schoolUnit, status: p.status || '未签到', sortOrder: p.sortOrder })) })) // 加载异常组数据 this.loadExceptionList() this.$message.success(data.isDraft ? '已加载草稿数据' : '已加载编排数据') } else { this.competitionGroups = [] } } } catch (err) { console.error('加载编排数据失败', err) this.$message.error('加载编排数据失败') } finally { this.loading = false } } ``` #### 5.3.2 保存草稿 ```javascript async handleSaveDraft() { try { this.loading = true // 构建保存数据 const saveData = { competitionId: this.competitionId, isDraft: true, competitionGroups: this.competitionGroups.map(group => ({ id: group.id, title: group.title, type: group.type, count: group.count, code: group.code, venueId: group.venueId, venueName: group.venueName, timeSlot: group.timeSlot, timeSlotIndex: group.timeSlotIndex, participants: group.items.map((item, index) => ({ id: item.id, schoolUnit: item.schoolUnit, status: item.status, sortOrder: index + 1 })) })) } // 调用保存草稿接口 await saveDraftSchedule(saveData) this.$message.success('草稿保存成功') } catch (err) { console.error('保存草稿失败', err) this.$message.error('保存草稿失败') } finally { this.loading = false } } ``` #### 5.3.3 上移/下移操作 ```javascript handleMoveUp(group, itemIndex) { if (itemIndex === 0 || this.isScheduleCompleted) return // 交换位置 const temp = group.items[itemIndex] group.items.splice(itemIndex, 1) group.items.splice(itemIndex - 1, 0, temp) this.$message.success('上移成功') } handleMoveDown(group, itemIndex) { if (itemIndex === group.items.length - 1 || this.isScheduleCompleted) return // 交换位置 const temp = group.items[itemIndex] group.items.splice(itemIndex, 1) group.items.splice(itemIndex + 1, 0, temp) this.$message.success('下移成功') } ``` #### 5.3.4 标记异常 ```javascript markAsException(group, itemIndex) { if (this.isScheduleCompleted) { this.$message.warning('编排已完成,无法标记异常') return } const item = group.items[itemIndex] // 修改状态为异常 item.status = '异常' // 添加到异常组列表 this.exceptionList.push({ groupId: group.id, groupTitle: group.title, participantId: item.id, schoolUnit: item.schoolUnit, status: '异常' }) this.$message.success(`已将 ${item.schoolUnit} 标记为异常`) } ``` ### 5.4 API调用 **文件位置**: `src/api/martial/activitySchedule.js` ```javascript import request from '@/axios' /** * 获取赛程编排结果 */ export const getScheduleResult = (competitionId) => { return request({ url: '/api/martial/schedule/result', method: 'get', params: { competitionId }, timeout: 30000 }) } /** * 保存编排草稿 */ export const saveDraftSchedule = (data) => { return request({ url: '/api/martial/schedule/save-draft', method: 'post', data }) } /** * 保存并锁定赛程编排 */ export const saveAndLockSchedule = (competitionId) => { return request({ url: '/api/martial/schedule/save-and-lock', method: 'post', data: { competitionId } }) } ``` --- ## 6. 数据流转 ### 6.1 完整流程图 ``` ┌─────────────────────────────────────────────────────────────┐ │ 第1步:用户进入编排页面 │ │ /schedule/index?competitionId=1&orderId=123 │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 第2步:前端mounted钩子执行 │ │ - loadCompetitionInfo() 加载赛事信息 │ │ - loadVenues() 加载场地列表 │ │ - loadScheduleData() 加载编排数据 │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 第3步:后端查询编排数据 │ │ GET /api/martial/schedule/result?competitionId=1 │ │ │ │ MartialScheduleServiceImpl.getScheduleResult() │ │ ├─ 查询 martial_schedule_group │ │ ├─ LEFT JOIN martial_schedule_detail │ │ ├─ LEFT JOIN martial_schedule_participant │ │ ├─ LEFT JOIN martial_schedule_status │ │ └─ 组装 ScheduleResultDTO │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 第4步:返回数据格式 │ │ { │ │ "isCompleted": false, │ │ "isDraft": true, │ │ "competitionGroups": [ │ │ { │ │ "id": 1001, │ │ "title": "太极拳-成年男子组", │ │ "type": "个人", │ │ "count": "20人", │ │ "code": "TJQ-M-A", │ │ "venueId": 1, │ │ "venueName": "一号场地", │ │ "timeSlot": "2025年06月25日 上午8:30", │ │ "timeSlotIndex": 0, │ │ "participants": [ │ │ { │ │ "id": 1000001, │ │ "schoolUnit": "北京体育大学武术学院", │ │ "status": "未签到", │ │ "sortOrder": 1 │ │ } │ │ ] │ │ } │ │ ] │ │ } │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 第5步:前端渲染 │ │ - 渲染场地按钮列表 │ │ - 渲染时间段按钮列表 │ │ - 根据选中的场地和时间段过滤并渲染分组 │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 第6步:用户操作 │ │ - 选择场地:点击场地按钮 → 更新selectedVenueId │ │ - 选择时间:点击时间按钮 → 更新selectedTime │ │ - 上移/下移:调整参赛者顺序 │ │ - 标记异常:添加到异常组 │ │ - 移动分组:更改分组的场地和时间 │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 第7步:保存草稿 │ │ POST /api/martial/schedule/save-draft │ │ { │ │ "competitionId": 1, │ │ "isDraft": true, │ │ "competitionGroups": [...] // 包含所有调整后的数据 │ │ } │ │ │ │ MartialScheduleServiceImpl.saveDraftSchedule() │ │ ├─ 更新 martial_schedule_status (status=1) │ │ ├─ 删除旧的编排数据 │ │ ├─ 插入新的 martial_schedule_group │ │ ├─ 插入新的 martial_schedule_detail │ │ └─ 插入新的 martial_schedule_participant │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 第8步:完成编排(可选) │ │ POST /api/martial/schedule/save-and-lock │ │ { │ │ "competitionId": 1 │ │ } │ │ │ │ MartialScheduleServiceImpl.saveAndLockSchedule() │ │ ├─ 更新 martial_schedule_status (status=2, locked_time) │ │ └─ 禁止后续修改 │ └─────────────────────────────────────────────────────────────┘ ``` ### 6.2 数据库操作流程 #### 6.2.1 查询编排数据 ```sql -- 一次性查询所有相关数据 SELECT sg.id AS group_id, sg.group_name, sg.category, sg.project_type, sd.venue_id, sd.venue_name, sd.time_slot, sp.id AS participant_id, sp.organization, sp.performance_order, sp.status AS check_in_status FROM martial_schedule_group sg LEFT JOIN martial_schedule_detail sd ON sg.id = sd.schedule_group_id LEFT JOIN martial_schedule_participant sp ON sd.id = sp.schedule_detail_id WHERE sg.competition_id = 1 AND sg.is_deleted = 0 ORDER BY sg.display_order, sp.performance_order ``` #### 6.2.2 保存草稿数据 ```sql -- Step 1: 更新状态表 UPDATE martial_schedule_status SET schedule_status = 1, last_auto_schedule_time = NOW() WHERE competition_id = 1; -- Step 2: 删除旧数据(级联删除) DELETE FROM martial_schedule_participant WHERE schedule_detail_id IN ( SELECT id FROM martial_schedule_detail WHERE competition_id = 1 ); DELETE FROM martial_schedule_detail WHERE schedule_group_id IN ( SELECT id FROM martial_schedule_group WHERE competition_id = 1 ); DELETE FROM martial_schedule_group WHERE competition_id = 1; -- Step 3: 插入新数据 INSERT INTO martial_schedule_group (...) VALUES (...); INSERT INTO martial_schedule_detail (...) VALUES (...); INSERT INTO martial_schedule_participant (...) VALUES (...); ``` --- ## 7. 核心功能 ### 7.1 场地和时间段过滤 **功能描述**: 用户可以选择不同的场地和时间段,页面自动过滤显示对应的竞赛分组。 **实现方式**: ```javascript // 计算属性:根据选中的场地和时间段过滤 computed: { filteredCompetitionGroups() { if (!this.selectedVenueId || this.selectedTime === null) { return [] } return this.competitionGroups.filter(group => { return group.venueId === this.selectedVenueId && group.timeSlotIndex === this.selectedTime }) } } // 用户点击场地按钮 {{ venue.venueName }} // 用户点击时间按钮 {{ time }} ``` **数据存储**: - `venueId`: 存储在 `martial_schedule_detail` 表的 `venue_id` 字段 - `timeSlotIndex`: 根据 `time_slot` 字段计算得出(如"08:30" → 0, "13:30" → 1) ### 7.2 参赛者顺序调整 **功能描述**: 用户可以上移或下移参赛者的出场顺序。 **实现方式**: ```javascript handleMoveUp(group, itemIndex) { // 边界检查 if (itemIndex === 0 || this.isScheduleCompleted) return // 数组元素交换 const items = group.items const temp = items[itemIndex] items.splice(itemIndex, 1) // 删除当前位置 items.splice(itemIndex - 1, 0, temp) // 插入到前一个位置 this.$message.success('上移成功') } ``` **数据存储**: - 保存草稿时,遍历 `group.items` 数组 - 将数组索引+1作为 `performance_order` 字段存入数据库 - 下次加载时按 `performance_order` 排序 ### 7.3 分组移动 **功能描述**: 用户可以将整个竞赛分组移动到其他场地或时间段。 **实现流程**: ```javascript // 1. 点击"移动"按钮,打开对话框 handleMoveGroup(group) { this.moveGroupIndex = this.competitionGroups.findIndex(g => g.id === group.id) this.moveTargetVenueId = group.venueId this.moveTargetTimeSlot = group.timeSlotIndex this.moveDialogVisible = true } // 2. 用户选择目标场地和时间段,点击确定 confirmMoveGroup() { const group = this.competitionGroups[this.moveGroupIndex] const targetVenue = this.venues.find(v => v.id === this.moveTargetVenueId) // 更新分组的场地和时间信息 group.venueId = this.moveTargetVenueId group.venueName = targetVenue.venueName group.timeSlotIndex = this.moveTargetTimeSlot group.timeSlot = this.timeSlots[this.moveTargetTimeSlot] this.$message.success(`已移动到 ${group.venueName} - ${group.timeSlot}`) this.moveDialogVisible = false } ``` **数据存储**: - 更新 `martial_schedule_detail` 表的 `venue_id` 和 `time_slot` 字段 ### 7.4 异常标记 **功能描述**: 对于未签到或有问题的参赛者,可以标记为异常,移到异常组统一管理。 **实现流程**: ```javascript // 1. 标记为异常 markAsException(group, itemIndex) { const item = group.items[itemIndex] // 修改状态 item.status = '异常' // 添加到异常组列表 this.exceptionList.push({ groupId: group.id, groupTitle: group.title, participantId: item.id, schoolUnit: item.schoolUnit, status: '异常' }) this.$message.success(`已将 ${item.schoolUnit} 标记为异常`) } // 2. 从异常组移除 removeFromException(index) { const exceptionItem = this.exceptionList[index] // 在分组中找到对应的参赛者,恢复状态 for (let group of this.competitionGroups) { if (group.id === exceptionItem.groupId) { for (let item of group.items) { if (item.id === exceptionItem.participantId) { item.status = '未签到' break } } break } } // 从异常列表移除 this.exceptionList.splice(index, 1) } ``` **数据存储**: - `martial_schedule_participant` 表的 `status` 字段 - 前端显示时根据 `status` 值渲染不同颜色的标签 ### 7.5 草稿保存 **功能描述**: 用户调整后可以随时保存草稿,下次进入继续编辑。 **实现流程**: ```javascript async handleSaveDraft() { // 1. 构建保存数据 const saveData = { competitionId: this.competitionId, isDraft: true, competitionGroups: this.competitionGroups.map(group => ({ id: group.id, title: group.title, type: group.type, count: group.count, code: group.code, venueId: group.venueId, venueName: group.venueName, timeSlot: group.timeSlot, timeSlotIndex: group.timeSlotIndex, participants: group.items.map((item, index) => ({ id: item.id, schoolUnit: item.schoolUnit, status: item.status, sortOrder: index + 1 // 重新计算顺序 })) })) } // 2. 调用API保存 await saveDraftSchedule(saveData) this.$message.success('草稿保存成功') } ``` **后端处理**: ```java @Transactional public boolean saveDraftSchedule(SaveScheduleDraftDTO dto) { // 1. 更新状态为"草稿" updateScheduleStatus(dto.getCompetitionId(), 1); // 2. 删除旧数据 deleteOldScheduleData(dto.getCompetitionId()); // 3. 保存新数据 for (CompetitionGroupDTO group : dto.getCompetitionGroups()) { saveScheduleGroup(group); saveScheduleDetail(group); saveScheduleParticipants(group); } return true; } ``` ### 7.6 完成编排 **功能描述**: 确认编排无误后,锁定编排,禁止后续修改。 **实现流程**: ```javascript // 1. 点击"完成编排"按钮,弹出确认对话框 handleConfirm() { this.confirmDialogVisible = true } // 2. 用户确认 async confirmComplete() { try { // 先保存当前状态 await this.handleSaveDraft() // 再锁定 await saveAndLockSchedule(this.competitionId) this.isScheduleCompleted = true this.confirmDialogVisible = false this.$message.success('编排已完成并锁定') } catch (err) { this.$message.error('完成编排失败') } } ``` **后端处理**: ```java @Transactional public boolean saveAndLockSchedule(Long competitionId) { // 更新状态为"已锁定" MartialScheduleStatus status = getScheduleStatus(competitionId); status.setScheduleStatus(2); // 2 = 已锁定 status.setLockedTime(LocalDateTime.now()); status.setLockedBy(currentUser); updateScheduleStatus(status); return true; } ``` **锁定后的限制**: - 前端:所有操作按钮变为禁用状态 (`v-if="!isScheduleCompleted"`) - 后端:保存接口检查状态,如果已锁定则拒绝保存 --- ## 8. API接口文档 ### 8.1 获取编排结果 **接口地址**: `GET /api/martial/schedule/result` **请求参数**: | 参数名 | 类型 | 必填 | 说明 | |--------|------|------|------| | competitionId | Long | 是 | 赛事ID | **响应示例**: ```json { "code": 200, "success": true, "data": { "isCompleted": false, "isDraft": true, "competitionGroups": [ { "id": 1001, "title": "太极拳-成年男子组", "type": "个人", "count": "20人", "code": "TJQ-M-A", "venueId": 1, "venueName": "一号场地", "timeSlot": "2025年06月25日 上午8:30", "timeSlotIndex": 0, "participants": [ { "id": 1000001, "schoolUnit": "北京体育大学武术学院", "status": "未签到", "sortOrder": 1 }, { "id": 1000002, "schoolUnit": "上海体育学院武术系", "status": "已签到", "sortOrder": 2 } ] } ] }, "msg": "操作成功" } ``` ### 8.2 保存编排草稿 **接口地址**: `POST /api/martial/schedule/save-draft` **请求体**: ```json { "competitionId": 1, "isDraft": true, "competitionGroups": [ { "id": 1001, "title": "太极拳-成年男子组", "type": "个人", "count": "20人", "code": "TJQ-M-A", "venueId": 1, "venueName": "一号场地", "timeSlot": "2025年06月25日 上午8:30", "timeSlotIndex": 0, "participants": [ { "id": 1000001, "schoolUnit": "北京体育大学武术学院", "status": "未签到", "sortOrder": 1 } ] } ] } ``` **响应示例**: ```json { "code": 200, "success": true, "data": null, "msg": "草稿保存成功" } ``` ### 8.3 完成编排并锁定 **接口地址**: `POST /api/martial/schedule/save-and-lock` **请求体**: ```json { "competitionId": 1 } ``` **响应示例**: ```json { "code": 200, "success": true, "data": null, "msg": "编排已完成并锁定" } ``` ### 8.4 获取场地列表 **接口地址**: `GET /api/martial/venue/list-by-competition` **请求参数**: | 参数名 | 类型 | 必填 | 说明 | |--------|------|------|------| | competitionId | Long | 是 | 赛事ID | **响应示例**: ```json { "code": 200, "success": true, "data": { "records": [ { "id": 1, "venueName": "一号场地", "capacity": 500, "location": "体育馆1F" }, { "id": 2, "venueName": "二号场地", "capacity": 300, "location": "体育馆2F" } ] }, "msg": "操作成功" } ``` ### 8.5 获取赛事详情 **接口地址**: `GET /api/martial/competition/detail` **请求参数**: | 参数名 | 类型 | 必填 | 说明 | |--------|------|------|------| | id | Long | 是 | 赛事ID | **响应示例**: ```json { "code": 200, "success": true, "data": { "id": 1, "competitionName": "2025年全国武术散打锦标赛", "competitionStartTime": "2025-06-25 08:00:00", "competitionEndTime": "2025-06-27 18:00:00", "organizer": "国家体育总局武术运动管理中心", "location": "北京市", "venue": "国家奥林匹克体育中心" }, "msg": "操作成功" } ``` --- ## 9. 关键代码解析 ### 9.1 计算属性:filteredCompetitionGroups **作用**: 根据用户选择的场地和时间段,动态过滤竞赛分组。 ```javascript computed: { filteredCompetitionGroups() { // 如果没有选择场地或时间,返回空数组 if (!this.selectedVenueId || this.selectedTime === null) { return [] } // 过滤出匹配的分组 return this.competitionGroups.filter(group => { return group.venueId === this.selectedVenueId && group.timeSlotIndex === this.selectedTime }) } } ``` **优点**: - 数据驱动:当 `selectedVenueId` 或 `selectedTime` 改变时,自动重新计算 - 性能优化:Vue的计算属性有缓存机制 - 代码简洁:模板直接使用 `filteredCompetitionGroups` ### 9.2 生成时间段列表 **作用**: 根据赛事的开始和结束时间,自动生成时间段列表。 ```javascript generateTimeSlots() { const startTime = this.competitionInfo.competitionStartTime const endTime = this.competitionInfo.competitionEndTime const slots = [] const start = new Date(startTime) const end = new Date(endTime) // 遍历每一天 let currentDate = new Date(start) while (currentDate <= end) { const year = currentDate.getFullYear() const month = currentDate.getMonth() + 1 const day = currentDate.getDate() const dateStr = `${year}年${month}月${day}日` // 添加上午时段 8:30 slots.push(`${dateStr} 上午8:30`) // 添加下午时段 13:30 slots.push(`${dateStr} 下午13:30`) // 下一天 currentDate.setDate(currentDate.getDate() + 1) } this.timeSlots = slots } ``` **示例输出**: ``` [ "2025年6月25日 上午8:30", "2025年6月25日 下午13:30", "2025年6月26日 上午8:30", "2025年6月26日 下午13:30", "2025年6月27日 上午8:30", "2025年6月27日 下午13:30" ] ``` ### 9.3 保存草稿的数据转换 **作用**: 将前端的数据结构转换为后端需要的格式。 ```javascript // 前端数据结构 this.competitionGroups = [ { id: 1001, title: "太极拳-成年男子组", items: [ { id: 1000001, schoolUnit: "北京体育大学", status: "未签到" }, { id: 1000002, schoolUnit: "上海体育学院", status: "已签到" } ] } ] // 转换为后端格式 const saveData = { competitionId: this.competitionId, isDraft: true, competitionGroups: this.competitionGroups.map(group => ({ id: group.id, title: group.title, type: group.type, count: group.count, code: group.code, venueId: group.venueId, venueName: group.venueName, timeSlot: group.timeSlot, timeSlotIndex: group.timeSlotIndex, participants: group.items.map((item, index) => ({ id: item.id, schoolUnit: item.schoolUnit, status: item.status, sortOrder: index + 1 // 根据数组顺序重新计算 })) })) } ``` **关键点**: - `items` 数组 → `participants` 数组 - 数组索引 → `sortOrder` 字段 - 保持其他字段不变 ### 9.4 后端数据组装 **作用**: 将数据库查询结果组装为前端需要的DTO格式。 ```java public ScheduleResultDTO getScheduleResult(Long competitionId) { // 1. 一次性查询所有数据 List details = scheduleGroupMapper .selectScheduleGroupDetails(competitionId); // 2. 按分组ID分组 Map> groupMap = details.stream() .collect(Collectors.groupingBy(ScheduleGroupDetailVO::getGroupId)); // 3. 遍历每个分组,构建DTO List groupDTOs = new ArrayList<>(); for (Map.Entry> entry : groupMap.entrySet()) { List groupDetails = entry.getValue(); // 取第一条记录的分组信息 ScheduleGroupDetailVO firstDetail = groupDetails.get(0); // 构建分组DTO CompetitionGroupDTO groupDTO = new CompetitionGroupDTO(); groupDTO.setId(firstDetail.getGroupId()); groupDTO.setTitle(firstDetail.getGroupName()); groupDTO.setVenueId(firstDetail.getVenueId()); groupDTO.setTimeSlot(firstDetail.getTimeSlot()); // 构建参赛者列表 List participantDTOs = groupDetails.stream() .filter(d -> d.getParticipantId() != null) .map(d -> { ParticipantDTO dto = new ParticipantDTO(); dto.setId(d.getParticipantId()); dto.setSchoolUnit(d.getOrganization()); dto.setStatus(d.getCheckInStatus()); dto.setSortOrder(d.getPerformanceOrder()); return dto; }) .collect(Collectors.toList()); groupDTO.setParticipants(participantDTOs); groupDTOs.add(groupDTO); } return new ScheduleResultDTO(groupDTOs); } ``` **性能优化**: - 使用 JOIN 查询,一次性获取所有数据,避免 N+1 问题 - 使用 Stream API 进行分组和映射,代码简洁 - 在内存中完成数据组装,减少数据库访问 --- ## 10. 使用指南 ### 10.1 管理员操作流程 #### 10.1.1 进入编排页面 1. 登录系统 2. 进入"赛事管理"模块 3. 选择一个赛事,点击"编排"按钮 4. 系统自动跳转到编排页面,URL格式:`/schedule/index?competitionId=1&orderId=123` #### 10.1.2 查看编排数据 1. 页面加载后,自动显示编排数据 2. 如果是首次编排,后端会自动生成初始编排(通过定时任务) 3. 如果之前保存过草稿,会加载草稿数据 #### 10.1.3 调整编排 **选择场地和时间**: 1. 点击顶部的场地按钮(如"一号场地") 2. 点击时间段按钮(如"2025年6月25日 上午8:30") 3. 下方表格自动显示该场地+时间段的分组 **调整参赛者顺序**: 1. 在分组表格中,点击"上移"或"下移"按钮 2. 参赛者的出场顺序会立即改变 **移动分组**: 1. 点击分组右侧的"移动"按钮 2. 在弹出的对话框中选择目标场地和时间段 3. 点击"确定",分组会被移动到新的场地和时间 **标记异常**: 1. 对于未签到的参赛者,点击"异常"按钮 2. 该参赛者会被标记为异常状态 3. 点击右上角的"异常组"按钮,可以查看所有异常参赛者 #### 10.1.4 保存草稿 1. 调整完成后,点击底部的"保存草稿"按钮 2. 系统会保存当前的编排状态 3. 下次进入时,会自动加载草稿 #### 10.1.5 完成编排 1. 确认编排无误后,点击"完成编排"按钮 2. 在确认对话框中点击"确定" 3. 系统会锁定编排,禁止后续修改 4. 页面所有操作按钮变为禁用状态 5. 底部显示"导出"按钮,可以导出赛程表 ### 10.2 常见问题 #### 10.2.1 为什么编排数据为空? **可能原因**: 1. 后端还没有执行自动编排 2. 该赛事没有参赛人员 3. 该赛事没有配置场地 **解决方法**: 1. 检查赛事是否有参赛人员(进入"参赛人员"页面) 2. 检查赛事是否有场地(进入"场地管理"页面) 3. 手动触发自动编排(调用 `/api/martial/schedule/auto-arrange` 接口) #### 10.2.2 为什么无法编辑? **可能原因**: 1. 编排已被锁定(`isScheduleCompleted = true`) **解决方法**: 1. 联系管理员解锁编排(需要在数据库中修改 `martial_schedule_status` 表的 `schedule_status` 字段为 0 或 1) #### 10.2.3 保存草稿失败怎么办? **可能原因**: 1. 网络问题 2. 后端服务异常 3. 数据格式错误 **解决方法**: 1. 查看浏览器控制台的错误信息 2. 查看后端日志 3. 联系技术支持 ### 10.3 开发调试 #### 10.3.1 前端调试 ```javascript // 在浏览器控制台执行 console.log('当前选中的场地ID:', this.selectedVenueId) console.log('当前选中的时间索引:', this.selectedTime) console.log('所有竞赛分组:', this.competitionGroups) console.log('过滤后的分组:', this.filteredCompetitionGroups) ``` #### 10.3.2 后端调试 ```java // 在 MartialScheduleServiceImpl 中添加日志 log.info("查询编排结果, competitionId: {}", competitionId); log.info("查询到 {} 条记录", details.size()); log.info("分组数量: {}", groupMap.size()); ``` #### 10.3.3 数据库调试 ```sql -- 查看编排状态 SELECT * FROM martial_schedule_status WHERE competition_id = 1; -- 查看分组数据 SELECT * FROM martial_schedule_group WHERE competition_id = 1; -- 查看明细数据 SELECT * FROM martial_schedule_detail WHERE competition_id = 1; -- 查看参赛者关联 SELECT * FROM martial_schedule_participant WHERE schedule_group_id IN ( SELECT id FROM martial_schedule_group WHERE competition_id = 1 ); -- 完整查询(与后端SQL一致) SELECT sg.id AS group_id, sg.group_name, sd.venue_id, sd.time_slot, sp.organization, sp.performance_order FROM martial_schedule_group sg LEFT JOIN martial_schedule_detail sd ON sg.id = sd.schedule_group_id LEFT JOIN martial_schedule_participant sp ON sd.id = sp.schedule_detail_id WHERE sg.competition_id = 1 AND sg.is_deleted = 0 ORDER BY sg.display_order, sp.performance_order; ``` --- ## 11. 附录 ### 11.1 数据字典 #### 11.1.1 编排状态枚举 | 状态值 | 状态名称 | 说明 | |--------|----------|------| | 0 | 未编排 | 尚未执行自动编排 | | 1 | 有草稿 | 已执行自动编排或用户保存过草稿 | | 2 | 已锁定 | 编排已完成并锁定,不可修改 | #### 11.1.2 项目类型枚举 | 类型值 | 类型名称 | 说明 | |--------|----------|------| | 1 | 个人 | 单人项目 | | 2 | 集体 | 团体项目 | #### 11.1.3 参赛者状态枚举 | 状态值 | 状态名称 | 标签颜色 | |--------|----------|----------| | 未签到 | 未签到 | info (灰色) | | 已签到 | 已签到 | success (绿色) | | 异常 | 异常 | danger (红色) | ### 11.2 相关文档链接 - [赛事管理系统整体设计文档](./system-design.md) - [自动编排算法文档](./auto-arrange-algorithm.md) - [数据库设计文档](./database-design.md) - [API接口文档](./api-documentation.md) - [前端开发规范](./frontend-standards.md) ### 11.3 更新日志 | 版本 | 日期 | 更新内容 | 作者 | |------|------|----------|------| | v1.0 | 2025-12-10 | 创建完整技术方案文档 | Claude Code | --- ## 总结 本文档详细介绍了武术赛事编排系统的完整技术实现,包括: 1. **架构设计**: 前后端分离,清晰的模块划分 2. **数据库设计**: 4张核心表,支持灵活的编排调整 3. **后端实现**: Spring Boot + MyBatis Plus,优化的SQL查询 4. **前端实现**: Vue2 + Element UI,响应式的数据驱动 5. **核心功能**: 场地过滤、顺序调整、分组移动、异常标记、草稿保存、锁定发布 6. **数据流转**: 完整的请求-响应流程 7. **使用指南**: 详细的操作步骤和常见问题解决 希望这份文档能帮助您全面理解编排系统的实现原理和使用方法。如有任何疑问,欢迎随时咨询! --- **文档结束**