17 KiB
17 KiB
编排页面移动按钮功能分析
📋 功能概述
编排页面的"移动"按钮允许用户将一个竞赛分组(包含多个参赛人员)从当前的场地和时间段迁移到另一个场地和时间段。
🎯 核心功能
1. 用户操作流程
1. 用户在编排页面查看竞赛分组
↓
2. 点击某个分组的"移动"按钮
↓
3. 弹出对话框,选择目标场地和目标时间段
↓
4. 点击"确定"按钮
↓
5. 系统将整个分组迁移到新的场地和时间段
↓
6. 前端页面自动更新,分组显示在新位置
🏗️ 技术架构
前端实现
1. 页面结构 (index.vue:74-87)
<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)
<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)
// 移动分组相关
moveDialogVisible: false, // 对话框显示状态
moveTargetVenueId: null, // 目标场地ID
moveTargetTimeSlot: null, // 目标时间段索引
moveGroupIndex: null, // 要移动的分组在数组中的索引
4. 核心方法
handleMoveGroup - 打开移动对话框 (index.vue:551-560)
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
}
逻辑说明:
- 检查编排状态,已完成的编排不允许移动
- 找到分组在数组中的索引位置
- 将当前分组的场地和时间段作为默认值
- 打开移动对话框
confirmMoveGroup - 确认移动 (index.vue:563-600)
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('移动分组失败,请稍后重试')
}
}
逻辑说明:
- 验证输入:确保选择了目标场地和时间段
- 获取数据:获取要移动的分组和目标场地信息
- 调用API:发送移动请求到后端
- 更新前端:成功后更新分组的场地和时间信息
- 用户反馈:显示成功或失败提示
后端实现
1. API接口 (activitySchedule.js:124-136)
/**
* 移动赛程分组到指定场地和时间段
* @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)
/**
* 移动赛程分组
*/
@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)
@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: 要移动的分组IDtargetVenueId: 目标场地IDtargetTimeSlotIndex: 目标时间段索引(0=第1天上午,1=第1天下午,2=第2天上午...)
4. Service层实现 (MartialScheduleServiceImpl.java:394-452)
@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;
}
核心逻辑:
-
查询分组信息
- 验证分组是否存在
-
查询分组详情
- 获取该分组下的所有参赛人员记录(
MartialScheduleDetail) - 这是关键:一个分组包含多个参赛人员
- 获取该分组下的所有参赛人员记录(
-
查询目标场地
- 验证目标场地是否存在
- 获取场地名称
-
计算目标日期和时间
- 根据时间段索引计算天数偏移:
dayOffset = targetTimeSlotIndex / 2 - 判断上午/下午:
isAfternoon = targetTimeSlotIndex % 2 == 1 - 设置时间:上午 08:30,下午 13:30
- 计算目标日期:
targetDate = minDate.plusDays(dayOffset)
- 根据时间段索引计算天数偏移:
-
批量更新所有详情记录
- 遍历分组下的所有参赛人员
- 更新每个人的场地、日期、时间信息
- 这样整个分组就迁移到了新的场地和时间段
📊 数据流转图
前端用户操作
↓
handleMoveGroup(group)
↓
显示移动对话框
↓
用户选择目标场地和时间段
↓
confirmMoveGroup()
↓
调用API: moveScheduleGroup({
groupId,
targetVenueId,
targetTimeSlotIndex
})
↓
后端Controller: moveGroup()
↓
后端Service: moveScheduleGroup()
↓
1. 查询分组信息
2. 查询分组详情(所有参赛人员)
3. 查询目标场地信息
4. 计算目标日期和时间
5. 批量更新所有详情记录
↓
返回成功/失败
↓
前端更新分组数据
↓
页面自动刷新显示
🔑 关键数据结构
1. 竞赛分组(CompetitionGroup)
{
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)
{
id: 1,
venueName: "主场地",
venueLocation: "体育馆1层",
capacity: 100
}
3. 时间段(TimeSlot)
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. 错误处理
- ✅ 分组不存在
- ✅ 场地不存在
- ✅ 时间段无效
- ✅ 网络请求失败
🚀 实现要点总结
前端关键点
-
分组数据管理
- 使用
competitionGroups数组存储所有分组 - 使用
filteredCompetitionGroups计算属性过滤显示
- 使用
-
对话框状态管理
moveDialogVisible: 控制对话框显示moveTargetVenueId: 目标场地IDmoveTargetTimeSlot: 目标时间段索引moveGroupIndex: 要移动的分组索引
-
数据更新策略
- 后端更新成功后,前端同步更新分组数据
- 利用Vue的响应式特性自动刷新页面
后端关键点
-
批量更新
- 一次移动操作更新整个分组的所有参赛人员
- 使用循环遍历
details列表批量更新
-
时间计算
- 根据时间段索引计算天数偏移和上午/下午
- 使用
LocalDate.plusDays()计算目标日期
-
数据验证
- 验证分组、场地、时间段的有效性
- 抛出异常进行错误处理
📝 扩展建议
1. 功能增强
- 批量移动:支持选择多个分组一次性移动
- 拖拽移动:支持拖拽分组到目标位置
- 冲突检测:检测目标场地和时间段是否已满
- 历史记录:记录移动操作历史,支持撤销
2. 性能优化
- 防抖处理:避免频繁点击导致重复请求
- 乐观更新:先更新前端,后台异步同步
- 缓存机制:缓存场地和时间段列表
3. 用户体验
- 移动预览:显示移动后的效果预览
- 快捷操作:右键菜单快速移动
- 智能推荐:推荐合适的目标场地和时间段
🎯 总结
移动按钮功能的核心是将整个竞赛分组(包含多个参赛人员)从一个场地和时间段迁移到另一个场地和时间段。
实现关键:
- 前端提供友好的对话框选择目标位置
- 后端批量更新分组下所有参赛人员的场地和时间信息
- 前后端数据同步,确保页面实时更新
数据流转:
用户点击移动 → 选择目标 → 调用API → 批量更新数据库 → 返回结果 → 更新前端 → 页面刷新
这个功能设计合理,实现清晰,用户体验良好!✨