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

17 KiB
Raw Permalink Blame History

编排页面移动按钮功能分析

📋 功能概述

编排页面的"移动"按钮允许用户将一个竞赛分组(包含多个参赛人员)从当前的场地和时间段迁移到另一个场地和时间段。

🎯 核心功能

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
}

逻辑说明

  1. 检查编排状态,已完成的编排不允许移动
  2. 找到分组在数组中的索引位置
  3. 将当前分组的场地和时间段作为默认值
  4. 打开移动对话框
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('移动分组失败,请稍后重试')
  }
}

逻辑说明

  1. 验证输入:确保选择了目标场地和时间段
  2. 获取数据:获取要移动的分组和目标场地信息
  3. 调用API:发送移动请求到后端
  4. 更新前端:成功后更新分组的场地和时间信息
  5. 用户反馈:显示成功或失败提示

后端实现

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: 要移动的分组ID
  • targetVenueId: 目标场地ID
  • targetTimeSlotIndex: 目标时间段索引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;
}

核心逻辑

  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

{
  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. 错误处理

  • 分组不存在
  • 场地不存在
  • 时间段无效
  • 网络请求失败

🚀 实现要点总结

前端关键点

  1. 分组数据管理

    • 使用 competitionGroups 数组存储所有分组
    • 使用 filteredCompetitionGroups 计算属性过滤显示
  2. 对话框状态管理

    • moveDialogVisible: 控制对话框显示
    • moveTargetVenueId: 目标场地ID
    • moveTargetTimeSlot: 目标时间段索引
    • moveGroupIndex: 要移动的分组索引
  3. 数据更新策略

    • 后端更新成功后,前端同步更新分组数据
    • 利用Vue的响应式特性自动刷新页面

后端关键点

  1. 批量更新

    • 一次移动操作更新整个分组的所有参赛人员
    • 使用循环遍历 details 列表批量更新
  2. 时间计算

    • 根据时间段索引计算天数偏移和上午/下午
    • 使用 LocalDate.plusDays() 计算目标日期
  3. 数据验证

    • 验证分组、场地、时间段的有效性
    • 抛出异常进行错误处理

📝 扩展建议

1. 功能增强

  • 批量移动:支持选择多个分组一次性移动
  • 拖拽移动:支持拖拽分组到目标位置
  • 冲突检测:检测目标场地和时间段是否已满
  • 历史记录:记录移动操作历史,支持撤销

2. 性能优化

  • 防抖处理:避免频繁点击导致重复请求
  • 乐观更新:先更新前端,后台异步同步
  • 缓存机制:缓存场地和时间段列表

3. 用户体验

  • 移动预览:显示移动后的效果预览
  • 快捷操作:右键菜单快速移动
  • 智能推荐:推荐合适的目标场地和时间段

🎯 总结

移动按钮功能的核心是将整个竞赛分组(包含多个参赛人员)从一个场地和时间段迁移到另一个场地和时间段

实现关键

  1. 前端提供友好的对话框选择目标位置
  2. 后端批量更新分组下所有参赛人员的场地和时间信息
  3. 前后端数据同步,确保页面实时更新

数据流转

用户点击移动 → 选择目标 → 调用API → 批量更新数据库 → 返回结果 → 更新前端 → 页面刷新

这个功能设计合理,实现清晰,用户体验良好!