# 编排页面移动按钮功能分析 ## 📋 功能概述 编排页面的"移动"按钮允许用户将一个竞赛分组(包含多个参赛人员)从当前的场地和时间段迁移到另一个场地和时间段。 ## 🎯 核心功能 ### 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
{{ group.title }} {{ group.type }} {{ group.count }} {{ group.code }}
移动
``` **关键点**: - 每个竞赛分组都有一个"移动"按钮 - 点击按钮触发 `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 → 批量更新数据库 → 返回结果 → 更新前端 → 页面刷新 ``` 这个功能设计合理,实现清晰,用户体验良好!✨