Files
martial-master/docs/schedule-move-group-analysis.md
宅房 7aa6545cbb
All checks were successful
continuous-integration/drone/push Build is passing
fix bugs
2025-12-12 05:13:10 +08:00

585 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 编排页面移动按钮功能分析
## 📋 功能概述
编排页面的"移动"按钮允许用户将一个竞赛分组(包含多个参赛人员)从当前的场地和时间段迁移到另一个场地和时间段。
## 🎯 核心功能
### 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
<div v-for="(group, index) in filteredCompetitionGroups" :key="group.id" class="competition-group">
<div class="group-header">
<div class="group-info">
<span class="group-title">{{ group.title }}</span>
<span class="group-meta">{{ group.type }}</span>
<span class="group-meta">{{ group.count }}</span>
<span class="group-meta">{{ group.code }}</span>
</div>
<div class="group-actions">
<el-button size="small" type="warning" @click="handleMoveGroup(group)">
移动
</el-button>
</div>
</div>
<!-- 分组内的参赛人员表格 -->
</div>
```
**关键点**
- 每个竞赛分组都有一个"移动"按钮
- 点击按钮触发 `handleMoveGroup(group)` 方法
- 传入整个分组对象作为参数
#### 2. 移动对话框 ([index.vue:198-231](d:/workspace/31.比赛项目/project/martial-web/src/views/martial/schedule/index.vue#L198-L231))
```vue
<el-dialog
title="移动竞赛分组"
:visible.sync="moveDialogVisible"
width="500px"
center
>
<el-form label-width="100px">
<!-- 目标场地选择 -->
<el-form-item label="目标场地">
<el-select v-model="moveTargetVenueId" placeholder="请选择场地" style="width: 100%;">
<el-option
v-for="venue in venues"
:key="venue.id"
:label="venue.venueName"
:value="venue.id"
></el-option>
</el-select>
</el-form-item>
<!-- 目标时间段选择 -->
<el-form-item label="目标时间段">
<el-select v-model="moveTargetTimeSlot" placeholder="请选择时间段" style="width: 100%;">
<el-option
v-for="(time, index) in timeSlots"
:key="index"
:label="time"
:value="index"
></el-option>
</el-select>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="moveDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmMoveGroup">确定</el-button>
</span>
</el-dialog>
```
**关键点**
- 提供两个下拉选择框:目标场地、目标时间段
- 场地列表来自 `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<MartialScheduleDetail> details = scheduleDetailMapper.selectList(
new QueryWrapper<MartialScheduleDetail>()
.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 → 批量更新数据库 → 返回结果 → 更新前端 → 页面刷新
```
这个功能设计合理,实现清晰,用户体验良好!✨