1857 lines
60 KiB
Markdown
1857 lines
60 KiB
Markdown
# 武术赛事编排系统 - 完整技术方案
|
||
|
||
> **文档版本**: 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<ScheduleResultDTO> 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<String, Object> params);
|
||
}
|
||
```
|
||
|
||
### 4.2 Service 层
|
||
|
||
#### 4.2.1 核心方法:getScheduleResult
|
||
|
||
**功能**: 获取赛程编排结果,返回前端展示数据
|
||
|
||
**实现逻辑**:
|
||
|
||
```java
|
||
@Override
|
||
public ScheduleResultDTO getScheduleResult(Long competitionId) {
|
||
ScheduleResultDTO result = new ScheduleResultDTO();
|
||
|
||
// 1. 使用优化的JOIN查询获取所有数据
|
||
List<ScheduleGroupDetailVO> details = scheduleGroupMapper
|
||
.selectScheduleGroupDetails(competitionId);
|
||
|
||
if (details.isEmpty()) {
|
||
// 没有数据,返回空结果
|
||
result.setIsDraft(true);
|
||
result.setIsCompleted(false);
|
||
result.setCompetitionGroups(new ArrayList<>());
|
||
return result;
|
||
}
|
||
|
||
// 2. 按分组ID分组数据
|
||
Map<Long, List<ScheduleGroupDetailVO>> 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<CompetitionGroupDTO> groupDTOs = new ArrayList<>();
|
||
for (Map.Entry<Long, List<ScheduleGroupDetailVO>> 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<CompetitionGroupDTO> 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
|
||
<select id="selectScheduleGroupDetails" resultType="ScheduleGroupDetailVO">
|
||
SELECT
|
||
sg.id AS group_id,
|
||
sg.group_name,
|
||
sg.category,
|
||
sg.project_type,
|
||
sg.total_participants,
|
||
sg.total_teams,
|
||
sg.display_order,
|
||
|
||
sd.id AS detail_id,
|
||
sd.venue_id,
|
||
sd.venue_name,
|
||
sd.time_slot,
|
||
sd.schedule_date,
|
||
|
||
sp.id AS participant_id,
|
||
sp.organization,
|
||
sp.player_name,
|
||
sp.performance_order,
|
||
sp.status AS check_in_status,
|
||
|
||
ss.schedule_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
|
||
LEFT JOIN martial_schedule_status ss ON sg.competition_id = ss.competition_id
|
||
WHERE sg.competition_id = #{competitionId}
|
||
AND sg.is_deleted = 0
|
||
ORDER BY sg.display_order, sp.performance_order
|
||
</select>
|
||
```
|
||
|
||
**优化说明**:
|
||
- 使用LEFT JOIN一次性查询所有关联数据
|
||
- 避免了N+1查询问题
|
||
- 在Service层进行内存分组,提高性能
|
||
|
||
---
|
||
|
||
## 5. 前端实现
|
||
|
||
### 5.1 页面结构
|
||
|
||
**文件位置**: `src/views/martial/schedule/index.vue`
|
||
|
||
#### 5.1.1 页面布局
|
||
|
||
```vue
|
||
<template>
|
||
<div class="martial-schedule-container">
|
||
<!-- 头部:返回按钮 + 标题 + 异常组按钮 -->
|
||
<div class="page-header">
|
||
<div class="header-left">
|
||
<el-button icon="el-icon-back" @click="goBack">返回</el-button>
|
||
<h2>编排</h2>
|
||
</div>
|
||
<div class="header-right">
|
||
<el-button type="danger" @click="showExceptionDialog">
|
||
异常组 <el-badge :value="exceptionList.length" />
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab切换:竞赛分组 / 场地 -->
|
||
<div class="tabs-section">
|
||
<el-button :type="activeTab === 'competition' ? 'primary' : ''"
|
||
@click="activeTab = 'competition'">
|
||
竞赛分组
|
||
</el-button>
|
||
<el-button :type="activeTab === 'venue' ? 'primary' : ''"
|
||
@click="activeTab = 'venue'">
|
||
场地
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 竞赛分组Tab -->
|
||
<div v-show="activeTab === 'competition'">
|
||
<!-- 场地选择器 -->
|
||
<div class="venue-list">
|
||
<el-button v-for="venue in venues"
|
||
:key="venue.id"
|
||
:type="selectedVenueId === venue.id ? 'primary' : ''"
|
||
@click="selectedVenueId = venue.id">
|
||
{{ venue.venueName }}
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 时间段选择器 -->
|
||
<div class="time-selector">
|
||
<el-button v-for="(time, index) in timeSlots"
|
||
:key="index"
|
||
:type="selectedTime === index ? 'primary' : ''"
|
||
@click="selectedTime = index">
|
||
{{ time }}
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 竞赛分组列表 -->
|
||
<div v-for="group in filteredCompetitionGroups" :key="group.id">
|
||
<div class="group-header">
|
||
<div class="group-info">
|
||
<span>{{ group.title }}</span>
|
||
<span>{{ group.type }}</span>
|
||
<span>{{ group.count }}</span>
|
||
</div>
|
||
<el-button @click="handleMoveGroup(group)">移动</el-button>
|
||
</div>
|
||
|
||
<!-- 参赛人员表格 -->
|
||
<el-table :data="group.items">
|
||
<el-table-column label="序号" type="index" />
|
||
<el-table-column prop="schoolUnit" label="学校/单位" />
|
||
<el-table-column prop="status" label="状态">
|
||
<template #default="scope">
|
||
<el-tag :type="getStatusType(scope.row.status)">
|
||
{{ scope.row.status || '未签到' }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作">
|
||
<template #default="scope">
|
||
<el-button @click="handleMoveUp(group, scope.$index)">
|
||
上移
|
||
</el-button>
|
||
<el-button @click="handleMoveDown(group, scope.$index)">
|
||
下移
|
||
</el-button>
|
||
<el-button @click="markAsException(group, scope.$index)">
|
||
异常
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 底部操作按钮 -->
|
||
<div class="footer-actions">
|
||
<el-button @click="handleSaveDraft" v-if="!isScheduleCompleted">
|
||
保存草稿
|
||
</el-button>
|
||
<el-button type="primary" @click="handleConfirm" v-if="!isScheduleCompleted">
|
||
完成编排
|
||
</el-button>
|
||
<el-button @click="handleExport" v-if="isScheduleCompleted">
|
||
导出
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
```
|
||
|
||
### 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
|
||
})
|
||
}
|
||
}
|
||
|
||
// 用户点击场地按钮
|
||
<el-button @click="selectedVenueId = venue.id">
|
||
{{ venue.venueName }}
|
||
</el-button>
|
||
|
||
// 用户点击时间按钮
|
||
<el-button @click="selectedTime = index">
|
||
{{ time }}
|
||
</el-button>
|
||
```
|
||
|
||
**数据存储**:
|
||
- `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<ScheduleGroupDetailVO> details = scheduleGroupMapper
|
||
.selectScheduleGroupDetails(competitionId);
|
||
|
||
// 2. 按分组ID分组
|
||
Map<Long, List<ScheduleGroupDetailVO>> groupMap = details.stream()
|
||
.collect(Collectors.groupingBy(ScheduleGroupDetailVO::getGroupId));
|
||
|
||
// 3. 遍历每个分组,构建DTO
|
||
List<CompetitionGroupDTO> groupDTOs = new ArrayList<>();
|
||
for (Map.Entry<Long, List<ScheduleGroupDetailVO>> entry : groupMap.entrySet()) {
|
||
List<ScheduleGroupDetailVO> 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<ParticipantDTO> 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. **使用指南**: 详细的操作步骤和常见问题解决
|
||
|
||
希望这份文档能帮助您全面理解编排系统的实现原理和使用方法。如有任何疑问,欢迎随时咨询!
|
||
|
||
---
|
||
|
||
**文档结束**
|