# 编排页面移动按钮功能分析
## 📋 功能概述
编排页面的"移动"按钮允许用户将一个竞赛分组(包含多个参赛人员)从当前的场地和时间段迁移到另一个场地和时间段。
## 🎯 核心功能
### 1. 用户操作流程
```
1. 用户在编排页面查看竞赛分组
↓
2. 点击某个分组的"移动"按钮
↓
3. 弹出对话框,选择目标场地和目标时间段
↓
4. 点击"确定"按钮
↓
5. 系统将整个分组迁移到新的场地和时间段
↓
6. 前端页面自动更新,分组显示在新位置
```
## 🏗️ 技术架构
### 前端实现
#### 1. 页面结构 ([index.vue:74-87](d:/workspace/31.比赛项目/project/martial-web/src/views/martial/schedule/index.vue#L74-L87))
```vue
```
**关键点**:
- 每个竞赛分组都有一个"移动"按钮
- 点击按钮触发 `handleMoveGroup(group)` 方法
- 传入整个分组对象作为参数
#### 2. 移动对话框 ([index.vue:198-231](d:/workspace/31.比赛项目/project/martial-web/src/views/martial/schedule/index.vue#L198-L231))
```vue
```
**关键点**:
- 提供两个下拉选择框:目标场地、目标时间段
- 场地列表来自 `venues` 数组(从后端加载)
- 时间段列表来自 `timeSlots` 数组(根据赛事时间动态生成)
#### 3. 数据状态 ([index.vue:299-303](d:/workspace/31.比赛项目/project/martial-web/src/views/martial/schedule/index.vue#L299-L303))
```javascript
// 移动分组相关
moveDialogVisible: false, // 对话框显示状态
moveTargetVenueId: null, // 目标场地ID
moveTargetTimeSlot: null, // 目标时间段索引
moveGroupIndex: null, // 要移动的分组在数组中的索引
```
#### 4. 核心方法
##### handleMoveGroup - 打开移动对话框 ([index.vue:551-560](d:/workspace/31.比赛项目/project/martial-web/src/views/martial/schedule/index.vue#L551-L560))
```javascript
handleMoveGroup(group) {
// 1. 检查是否已完成编排
if (this.isScheduleCompleted) {
this.$message.warning('编排已完成,无法移动')
return
}
// 2. 记录要移动的分组索引
this.moveGroupIndex = this.competitionGroups.findIndex(g => g.id === group.id)
// 3. 预填充当前场地和时间段
this.moveTargetVenueId = group.venueId || null
this.moveTargetTimeSlot = group.timeSlotIndex || 0
// 4. 显示对话框
this.moveDialogVisible = true
}
```
**逻辑说明**:
1. 检查编排状态,已完成的编排不允许移动
2. 找到分组在数组中的索引位置
3. 将当前分组的场地和时间段作为默认值
4. 打开移动对话框
##### confirmMoveGroup - 确认移动 ([index.vue:563-600](d:/workspace/31.比赛项目/project/martial-web/src/views/martial/schedule/index.vue#L563-L600))
```javascript
async confirmMoveGroup() {
// 1. 验证输入
if (!this.moveTargetVenueId) {
this.$message.warning('请选择目标场地')
return
}
if (this.moveTargetTimeSlot === null) {
this.$message.warning('请选择目标时间段')
return
}
// 2. 获取分组和目标场地信息
const group = this.competitionGroups[this.moveGroupIndex]
const targetVenue = this.venues.find(v => v.id === this.moveTargetVenueId)
try {
// 3. 调用后端API移动分组
const res = await moveScheduleGroup({
groupId: group.id,
targetVenueId: this.moveTargetVenueId,
targetTimeSlotIndex: this.moveTargetTimeSlot
})
if (res.data.success) {
// 4. 更新前端数据
group.venueId = this.moveTargetVenueId
group.venueName = targetVenue ? targetVenue.venueName : ''
group.timeSlotIndex = this.moveTargetTimeSlot
group.timeSlot = this.timeSlots[this.moveTargetTimeSlot]
// 5. 显示成功提示
this.$message.success(`已移动到 ${group.venueName} - ${group.timeSlot}`)
this.moveDialogVisible = false
} else {
this.$message.error(res.data.msg || '移动分组失败')
}
} catch (error) {
console.error('移动分组失败:', error)
this.$message.error('移动分组失败,请稍后重试')
}
}
```
**逻辑说明**:
1. **验证输入**:确保选择了目标场地和时间段
2. **获取数据**:获取要移动的分组和目标场地信息
3. **调用API**:发送移动请求到后端
4. **更新前端**:成功后更新分组的场地和时间信息
5. **用户反馈**:显示成功或失败提示
---
### 后端实现
#### 1. API接口 ([activitySchedule.js:124-136](d:/workspace/31.比赛项目/project/martial-web/src/api/martial/activitySchedule.js#L124-L136))
```javascript
/**
* 移动赛程分组到指定场地和时间段
* @param {Object} data - 移动请求数据
* @param {Number} data.groupId - 分组ID
* @param {Number} data.targetVenueId - 目标场地ID
* @param {Number} data.targetTimeSlotIndex - 目标时间段索引
*/
export const moveScheduleGroup = (data) => {
return request({
url: '/martial/schedule/move-group',
method: 'post',
data
})
}
```
#### 2. Controller层 ([MartialScheduleArrangeController.java:106-119](d:/workspace/31.比赛项目/project/martial-master/src/main/java/org/springblade/modules/martial/controller/MartialScheduleArrangeController.java#L106-L119))
```java
/**
* 移动赛程分组
*/
@PostMapping("/move-group")
@Operation(summary = "移动赛程分组", description = "将分组移动到指定场地和时间段")
public R moveGroup(@RequestBody MoveScheduleGroupDTO dto) {
try {
boolean success = scheduleService.moveScheduleGroup(dto);
return success ? R.success("分组移动成功") : R.fail("分组移动失败");
} catch (Exception e) {
log.error("移动分组失败", e);
return R.fail("移动分组失败: " + e.getMessage());
}
}
```
#### 3. DTO对象 ([MoveScheduleGroupDTO.java](d:/workspace/31.比赛项目/project/martial-master/src/main/java/org/springblade/modules/martial/pojo/dto/MoveScheduleGroupDTO.java))
```java
@Data
@Schema(description = "移动赛程分组DTO")
public class MoveScheduleGroupDTO {
/**
* 分组ID
*/
@Schema(description = "分组ID")
private Long groupId;
/**
* 目标场地ID
*/
@Schema(description = "目标场地ID")
private Long targetVenueId;
/**
* 目标时间段索引
*/
@Schema(description = "目标时间段索引(0=第1天上午,1=第1天下午,2=第2天上午...)")
private Integer targetTimeSlotIndex;
}
```
**关键点**:
- `groupId`: 要移动的分组ID
- `targetVenueId`: 目标场地ID
- `targetTimeSlotIndex`: 目标时间段索引(0=第1天上午,1=第1天下午,2=第2天上午...)
#### 4. Service层实现 ([MartialScheduleServiceImpl.java:394-452](d:/workspace/31.比赛项目/project/martial-master/src/main/java/org/springblade/modules/martial/service/impl/MartialScheduleServiceImpl.java#L394-L452))
```java
@Override
public boolean moveScheduleGroup(MoveScheduleGroupDTO dto) {
// 1. 查询分组信息
MartialScheduleGroup group = scheduleGroupMapper.selectById(dto.getGroupId());
if (group == null) {
throw new RuntimeException("分组不存在");
}
// 2. 查询该分组的详情记录(包含所有参赛人员)
List details = scheduleDetailMapper.selectList(
new QueryWrapper()
.eq("schedule_group_id", dto.getGroupId())
.eq("is_deleted", 0)
);
if (details.isEmpty()) {
throw new RuntimeException("分组详情不存在");
}
// 3. 查询目标场地信息
MartialVenue targetVenue = venueService.getById(dto.getTargetVenueId());
if (targetVenue == null) {
throw new RuntimeException("目标场地不存在");
}
// 4. 根据时间段索引计算日期和时间
// 假设: 0=第1天上午, 1=第1天下午, 2=第2天上午, 3=第2天下午...
int dayOffset = dto.getTargetTimeSlotIndex() / 2; // 每天2个时段
boolean isAfternoon = dto.getTargetTimeSlotIndex() % 2 == 1;
String timeSlot = isAfternoon ? "13:30" : "08:30";
// 获取赛事起始日期(从第一个detail中获取)
LocalDate baseDate = details.get(0).getScheduleDate();
if (baseDate == null) {
throw new RuntimeException("无法确定赛事起始日期");
}
// 计算目标日期
LocalDate minDate = details.stream()
.map(MartialScheduleDetail::getScheduleDate)
.filter(Objects::nonNull)
.min(LocalDate::compareTo)
.orElse(baseDate);
LocalDate targetDate = minDate.plusDays(dayOffset);
// 5. 更新所有detail记录
for (MartialScheduleDetail detail : details) {
detail.setVenueId(dto.getTargetVenueId());
detail.setVenueName(targetVenue.getVenueName());
detail.setScheduleDate(targetDate);
detail.setTimeSlot(timeSlot);
detail.setTimeSlotIndex(dto.getTargetTimeSlotIndex());
scheduleDetailMapper.updateById(detail);
}
return true;
}
```
**核心逻辑**:
1. **查询分组信息**
- 验证分组是否存在
2. **查询分组详情**
- 获取该分组下的所有参赛人员记录(`MartialScheduleDetail`)
- 这是关键:一个分组包含多个参赛人员
3. **查询目标场地**
- 验证目标场地是否存在
- 获取场地名称
4. **计算目标日期和时间**
- 根据时间段索引计算天数偏移:`dayOffset = targetTimeSlotIndex / 2`
- 判断上午/下午:`isAfternoon = targetTimeSlotIndex % 2 == 1`
- 设置时间:上午 08:30,下午 13:30
- 计算目标日期:`targetDate = minDate.plusDays(dayOffset)`
5. **批量更新所有详情记录**
- 遍历分组下的所有参赛人员
- 更新每个人的场地、日期、时间信息
- 这样整个分组就迁移到了新的场地和时间段
---
## 📊 数据流转图
```
前端用户操作
↓
handleMoveGroup(group)
↓
显示移动对话框
↓
用户选择目标场地和时间段
↓
confirmMoveGroup()
↓
调用API: moveScheduleGroup({
groupId,
targetVenueId,
targetTimeSlotIndex
})
↓
后端Controller: moveGroup()
↓
后端Service: moveScheduleGroup()
↓
1. 查询分组信息
2. 查询分组详情(所有参赛人员)
3. 查询目标场地信息
4. 计算目标日期和时间
5. 批量更新所有详情记录
↓
返回成功/失败
↓
前端更新分组数据
↓
页面自动刷新显示
```
---
## 🔑 关键数据结构
### 1. 竞赛分组(CompetitionGroup)
```javascript
{
id: 1, // 分组ID
title: "男子A组 长拳", // 分组标题
type: "个人项目", // 项目类型
count: "5人", // 参赛人数
code: "MA-001", // 分组编号
venueId: 1, // 当前场地ID
venueName: "主场地", // 当前场地名称
timeSlotIndex: 0, // 当前时间段索引
timeSlot: "2025年11月6日 上午8:30", // 当前时间段
items: [ // 参赛人员列表
{
id: 101,
schoolUnit: "北京体育大学",
status: "已签到"
},
// ... 更多参赛人员
]
}
```
### 2. 场地(Venue)
```javascript
{
id: 1,
venueName: "主场地",
venueLocation: "体育馆1层",
capacity: 100
}
```
### 3. 时间段(TimeSlot)
```javascript
timeSlots: [
"2025年11月6日 上午8:30", // index: 0
"2025年11月6日 下午13:30", // index: 1
"2025年11月7日 上午8:30", // index: 2
"2025年11月7日 下午13:30", // index: 3
// ...
]
```
**时间段索引规则**:
- `index = dayOffset * 2 + (isAfternoon ? 1 : 0)`
- 例如:第2天下午 = 1 * 2 + 1 = 3
---
## 🎨 UI交互流程
### 1. 初始状态
```
编排页面
├── 场地选择按钮(主场地、副场地1、副场地2)
├── 时间段选择按钮(上午8:30、下午13:30)
└── 竞赛分组列表
├── 分组1 [移动] 按钮
├── 分组2 [移动] 按钮
└── 分组3 [移动] 按钮
```
### 2. 点击移动按钮
```
弹出对话框
├── 标题:移动竞赛分组
├── 目标场地下拉框
│ ├── 主场地
│ ├── 副场地1
│ └── 副场地2
├── 目标时间段下拉框
│ ├── 2025年11月6日 上午8:30
│ ├── 2025年11月6日 下午13:30
│ └── ...
└── 按钮
├── [取消]
└── [确定]
```
### 3. 确认移动后
```
页面自动更新
├── 原场地/时间段:分组消失
└── 新场地/时间段:分组出现
```
---
## ⚠️ 注意事项
### 1. 权限控制
- ✅ 已完成编排的赛程不允许移动
- ✅ 检查:`if (this.isScheduleCompleted) { return }`
### 2. 数据一致性
- ✅ 移动时更新所有参赛人员的场地和时间信息
- ✅ 前端和后端数据同步更新
### 3. 用户体验
- ✅ 预填充当前场地和时间段
- ✅ 显示清晰的成功/失败提示
- ✅ 对话框关闭后自动刷新页面
### 4. 错误处理
- ✅ 分组不存在
- ✅ 场地不存在
- ✅ 时间段无效
- ✅ 网络请求失败
---
## 🚀 实现要点总结
### 前端关键点
1. **分组数据管理**
- 使用 `competitionGroups` 数组存储所有分组
- 使用 `filteredCompetitionGroups` 计算属性过滤显示
2. **对话框状态管理**
- `moveDialogVisible`: 控制对话框显示
- `moveTargetVenueId`: 目标场地ID
- `moveTargetTimeSlot`: 目标时间段索引
- `moveGroupIndex`: 要移动的分组索引
3. **数据更新策略**
- 后端更新成功后,前端同步更新分组数据
- 利用Vue的响应式特性自动刷新页面
### 后端关键点
1. **批量更新**
- 一次移动操作更新整个分组的所有参赛人员
- 使用循环遍历 `details` 列表批量更新
2. **时间计算**
- 根据时间段索引计算天数偏移和上午/下午
- 使用 `LocalDate.plusDays()` 计算目标日期
3. **数据验证**
- 验证分组、场地、时间段的有效性
- 抛出异常进行错误处理
---
## 📝 扩展建议
### 1. 功能增强
- **批量移动**:支持选择多个分组一次性移动
- **拖拽移动**:支持拖拽分组到目标位置
- **冲突检测**:检测目标场地和时间段是否已满
- **历史记录**:记录移动操作历史,支持撤销
### 2. 性能优化
- **防抖处理**:避免频繁点击导致重复请求
- **乐观更新**:先更新前端,后台异步同步
- **缓存机制**:缓存场地和时间段列表
### 3. 用户体验
- **移动预览**:显示移动后的效果预览
- **快捷操作**:右键菜单快速移动
- **智能推荐**:推荐合适的目标场地和时间段
---
## 🎯 总结
移动按钮功能的核心是**将整个竞赛分组(包含多个参赛人员)从一个场地和时间段迁移到另一个场地和时间段**。
**实现关键**:
1. 前端提供友好的对话框选择目标位置
2. 后端批量更新分组下所有参赛人员的场地和时间信息
3. 前后端数据同步,确保页面实时更新
**数据流转**:
```
用户点击移动 → 选择目标 → 调用API → 批量更新数据库 → 返回结果 → 更新前端 → 页面刷新
```
这个功能设计合理,实现清晰,用户体验良好!✨