585 lines
17 KiB
Markdown
585 lines
17 KiB
Markdown
# 编排页面移动按钮功能分析
|
||
|
||
## 📋 功能概述
|
||
|
||
编排页面的"移动"按钮允许用户将一个竞赛分组(包含多个参赛人员)从当前的场地和时间段迁移到另一个场地和时间段。
|
||
|
||
## 🎯 核心功能
|
||
|
||
### 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 → 批量更新数据库 → 返回结果 → 更新前端 → 页面刷新
|
||
```
|
||
|
||
这个功能设计合理,实现清晰,用户体验良好!✨
|