Files
martial-web/doc/schedule/archive/schedule-system-analysis.md
宅房 5b806e29b7
Some checks failed
continuous-integration/drone/push Build is failing
fix bugs
2025-12-11 16:56:19 +08:00

32 KiB
Raw Blame History

赛程编排系统功能分析文档

📋 文档概述

文档名称: 赛程编排页面系统逻辑设计与实现方案 创建日期: 2025-12-07 版本: v1.0 适用系统: 武术赛事管理系统 - 赛程编排模块


1. 功能概述

赛程编排页面是武术赛事管理系统的核心模块,负责将所有报名的参赛队伍/选手按照一定规则自动分组,并分配到不同的比赛时间段和场地,生成完整的比赛赛程。

1.1 核心目标

  • 自动生成比赛时间段
  • 智能分组(集体优先、个人在后)
  • 自动分配场地
  • 支持手动调整和优化
  • 可视化拖拽编排

2. 系统逻辑详细设计

2.1 时间段自动生成逻辑

2.1.1 需求描述

根据赛事的比赛开始时间和结束时间,系统自动生成时间段:

  • 上午场: 08:30 开始
  • 下午场: 13:30 开始

2.1.2 时间段生成规则

输入:
- competition_start_time: 比赛开始时间 (例如: 2026-01-05 09:00:00)
- competition_end_time: 比赛结束时间 (例如: 2026-01-10 18:00:00)

输出:
- 时间段列表 (按天拆分,每天2个时间段)

生成逻辑:
1. 计算比赛总天数 = 结束日期 - 开始日期 + 1
2. 对于每一天:
   - 生成上午时间段: YYYY-MM-DD 08:30:00 ~ 12:00:00
   - 生成下午时间段: YYYY-MM-DD 13:30:00 ~ 17:30:00
3. 过滤掉第一天8:30之前和最后一天结束时间之后的时间段

2.1.3 时间段数据结构

{
  id: 'slot_1',                    // 时间段唯一标识
  date: '2026-01-05',              // 日期
  label: '1月5日 上午',            // 显示标签
  startTime: '2026-01-05 08:30:00', // 开始时间
  endTime: '2026-01-05 12:00:00',   // 结束时间
  period: 'morning',                // 时段: morning/afternoon
  groups: []                        // 该时间段下的分组列表
}

2.1.4 算法实现

function generateTimeSlots(competitionStartTime, competitionEndTime) {
  const slots = [];
  const start = new Date(competitionStartTime);
  const end = new Date(competitionEndTime);

  // 计算天数(包含开始和结束当天)
  const days = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1;

  for (let i = 0; i < days; i++) {
    const currentDate = new Date(start);
    currentDate.setDate(start.getDate() + i);
    const dateStr = currentDate.toISOString().split('T')[0];

    // 上午时间段 08:30 - 12:00
    slots.push({
      id: `slot_${i * 2 + 1}`,
      date: dateStr,
      label: `${currentDate.getMonth() + 1}${currentDate.getDate()}日 上午`,
      startTime: `${dateStr} 08:30:00`,
      endTime: `${dateStr} 12:00:00`,
      period: 'morning',
      groups: []
    });

    // 下午时间段 13:30 - 17:30
    slots.push({
      id: `slot_${i * 2 + 2}`,
      date: dateStr,
      label: `${currentDate.getMonth() + 1}${currentDate.getDate()}日 下午`,
      startTime: `${dateStr} 13:30:00`,
      endTime: `${dateStr} 17:30:00`,
      period: 'afternoon',
      groups: []
    });
  }

  return slots;
}

2.2 参赛数据获取与分组逻辑

2.2.1 数据来源

martial_athlete 表获取所有已报名且状态为"已审核通过"的参赛数据:

SELECT
  a.*,
  p.project_name,
  p.category,
  p.type,  -- 项目类型: 1=个人项目, 2=集体项目
  o.organization
FROM martial_athlete a
LEFT JOIN martial_project p ON a.project_id = p.id
LEFT JOIN martial_registration_order o ON a.order_id = o.id
WHERE
  a.competition_id = ?
  AND a.registration_status = 1  -- 已审核通过
ORDER BY p.type DESC, a.organization, a.order_num;

说明:

  • p.type = 2 表示集体项目
  • p.type = 1 表示个人项目
  • ORDER BY p.type DESC 确保集体项目(2)排在个人项目(1)前面

2.2.2 分组规则

优先级规则: 集体项目 > 个人项目

分组步骤:
1. 将所有参赛数据按项目类型分类
   - 集体项目 (type = 2)
   - 个人项目 (type = 1)

2. 对集体项目分组
   - 按单位(organization)分组
   - 按项目(project_id)分组
   - 生成分组名称: "{单位名称} - {项目名称}"
   - 例如: "少林寺武术学校 - 集体拳术表演"

3. 对个人项目分组
   - 按项目(project_id)分组
   - 按性别分组(可选)
   - 按年龄组分组(可选)
   - 生成分组名称: "{项目名称} - {组别}"
   - 例如: "成年男子太极拳 - A组"

4. 合并分组列表: [集体项目分组, 个人项目分组]

2.2.3 分组数据结构

{
  id: 'group_1',                      // 分组唯一标识
  name: '少林寺武术学校 - 集体拳术',  // 分组名称(可编辑)
  code: 'GROUP_001',                  // 分组编号
  type: 'team',                       // 类型: team=集体, individual=个人
  projectId: 208,                     // 项目ID
  projectName: '集体拳术表演',        // 项目名称
  category: '集体项目',               // 组别
  organization: '少林寺武术学校',     // 单位
  venueId: null,                      // 分配的场地ID
  venueName: null,                    // 场地名称
  timeSlotId: null,                   // 分配的时间段ID
  participants: [                     // 参赛人员列表
    {
      id: 1000001,
      playerName: '张三',
      organization: '少林寺武术学校',
      projectName: '集体拳术表演',
      category: '集体项目',
      orderNum: 1
    },
    // ... 更多参赛人员
  ],
  estimatedDuration: 8,               // 预计时长(分钟)
  editingName: false,                 // 是否正在编辑名称
  tempName: ''                        // 临时名称(编辑时使用)
}

2.2.4 自动分组算法

function autoGroupParticipants(participants) {
  const groups = [];
  let groupId = 1;

  // 1. 分离集体和个人项目
  const teamProjects = participants.filter(p => p.type === 2);
  const individualProjects = participants.filter(p => p.type === 1);

  // 2. 处理集体项目 - 按单位+项目分组
  const teamGroupMap = new Map();
  teamProjects.forEach(p => {
    const key = `${p.organization}_${p.projectId}`;
    if (!teamGroupMap.has(key)) {
      teamGroupMap.set(key, []);
    }
    teamGroupMap.get(key).push(p);
  });

  teamGroupMap.forEach((members, key) => {
    const first = members[0];
    groups.push({
      id: `group_${groupId}`,
      name: `${first.organization} - ${first.projectName}`,
      code: `GROUP_${String(groupId).padStart(3, '0')}`,
      type: 'team',
      projectId: first.projectId,
      projectName: first.projectName,
      category: first.category,
      organization: first.organization,
      venueId: null,
      venueName: null,
      timeSlotId: null,
      participants: members,
      estimatedDuration: first.estimatedDuration || 8,
      editingName: false,
      tempName: ''
    });
    groupId++; // 自增放在push后面
  });

  // 3. 处理个人项目 - 按项目+组别分组(每组最多30人)
  const individualGroupMap = new Map();
  individualProjects.forEach(p => {
    const key = `${p.projectId}_${p.category}`;
    if (!individualGroupMap.has(key)) {
      individualGroupMap.set(key, []);
    }
    individualGroupMap.get(key).push(p);
  });

  individualGroupMap.forEach((members, key) => {
    const first = members[0];
    const maxPerGroup = 30; // 每组最多30人
    const groupCount = Math.ceil(members.length / maxPerGroup);

    for (let i = 0; i < groupCount; i++) {
      const groupMembers = members.slice(i * maxPerGroup, (i + 1) * maxPerGroup);
      const groupLabel = groupCount > 1 ? ` - ${String.fromCharCode(65 + i)}组` : '';

      groups.push({
        id: `group_${groupId}`,
        name: `${first.projectName}${groupLabel}`,
        code: `GROUP_${String(groupId).padStart(3, '0')}`,
        type: 'individual',
        projectId: first.projectId,
        projectName: first.projectName,
        category: first.category,
        organization: null,
        venueId: null,
        venueName: null,
        timeSlotId: null,
        participants: groupMembers,
        // 个人项目的时长 = 人数 × 每人平均时长
        // 注意: 如果是同时比赛则不应相乘,这里假设是依次出场
        estimatedDuration: groupMembers.length * (first.estimatedDuration || 5),
        editingName: false,
        tempName: ''
      });
      groupId++; // 自增放在push后面
    }
  });

  return groups;
}

2.3 分组名称编辑功能

2.3.1 需求说明

用户可以自定义修改系统生成的分组名称,例如:

  • 系统生成: "少林寺武术学校 - 集体拳术表演"
  • 用户修改: "少林组集体拳"

2.3.2 交互流程

1. 用户双击分组名称 → 进入编辑模式
2. 显示输入框,回填当前名称
3. 用户修改名称
4. 按 Enter 或失焦 → 保存修改
5. 按 Esc → 取消修改

2.3.3 实现代码

// 进入编辑模式
function editGroupName(group) {
  group.editingName = true;
  group.tempName = group.name;

  // 聚焦到输入框
  nextTick(() => {
    const input = document.querySelector(`#group-${group.id} input`);
    if (input) {
      input.focus();
      input.select();
    }
  });
}

// 保存分组名称
function saveGroupName(group) {
  if (group.tempName && group.tempName.trim()) {
    group.name = group.tempName.trim();
  }
  group.editingName = false;

  // 调用API保存到后端
  updateGroupName(group.id, group.name);
}

// 取消编辑
function cancelEditGroupName(group) {
  group.editingName = false;
  group.tempName = '';
}

2.4 场地自动分配逻辑

2.4.1 场地自动分配规则

目标: 均匀分配所有分组到各个场地,避免某个场地负载过重

分配算法:
1. 获取所有可用场地列表
2. 计算每个场地的总时长 = Σ(分配到该场地的分组的预计时长)
3. 采用"负载均衡"算法:
   - 将分组按预计时长降序排列
   - 每次选择当前负载最小的场地
   - 将分组分配到该场地
   - 更新场地负载

伪代码:
venues = getVenues()
groups = getAllGroups()

// 初始化场地负载
venueLoads = {}
for venue in venues:
    venueLoads[venue.id] = 0

// 按时长降序排序分组
groups.sort(by: estimatedDuration, desc)

// 贪心分配
for group in groups:
    // 找负载最小的场地
    minVenue = findMinLoadVenue(venueLoads)

    // 分配
    group.venueId = minVenue.id
    group.venueName = minVenue.name

    // 更新负载
    venueLoads[minVenue.id] += group.estimatedDuration

2.4.2 实现代码

function autoAssignVenues(groups, venues) {
  if (!venues || venues.length === 0) {
    ElMessage.warning('没有可用的场地');
    return;
  }

  // 初始化场地负载
  const venueLoads = {};
  venues.forEach(venue => {
    venueLoads[venue.id] = 0;
  });

  // 按预计时长降序排序(先分配时间长的)
  const sortedGroups = [...groups].sort((a, b) =>
    b.estimatedDuration - a.estimatedDuration
  );

  // 贪心分配
  sortedGroups.forEach(group => {
    // 找当前负载最小的场地
    let minVenue = venues[0];
    let minLoad = venueLoads[venues[0].id];

    venues.forEach(venue => {
      if (venueLoads[venue.id] < minLoad) {
        minVenue = venue;
        minLoad = venueLoads[venue.id];
      }
    });

    // 分配到该场地
    group.venueId = minVenue.id;
    group.venueName = minVenue.name;

    // 更新负载
    venueLoads[minVenue.id] += group.estimatedDuration;
  });

  ElMessage.success('场地分配完成');

  // 保存到后端
  saveVenueAssignments(groups);
}

2.5 手动移动分组功能

2.5.1 场地间移动

需求: 通过右上角的按钮将分组移动到其他场地

交互流程:

1. 用户点击分组右上角的"移动"按钮
2. 弹出场地选择下拉菜单
3. 用户选择目标场地
4. 系统将分组移动到目标场地
5. 更新UI显示

实现:

function moveGroupToVenue(group, targetVenueId) {
  const targetVenue = venues.find(v => v.id === targetVenueId);

  if (!targetVenue) {
    ElMessage.error('目标场地不存在');
    return;
  }

  // 更新分组的场地信息
  group.venueId = targetVenue.id;
  group.venueName = targetVenue.name;

  ElMessage.success(`已移动到${targetVenue.name}`);

  // 保存到后端
  updateGroupVenue(group.id, targetVenueId);
}

2.5.2 场地内拖拽排序

需求: 在同一场地内,可以通过拖拽调整分组的顺序

实现: 使用 vuedraggable 组件

<template>
  <div v-for="venue in venues" :key="venue.id" class="venue-container">
    <h3>{{ venue.name }}</h3>

    <draggable
      v-model="getGroupsByVenue(venue.id)"
      item-key="id"
      :options="{ group: 'groups', handle: '.drag-handle' }"
      @end="handleGroupDragEnd"
    >
      <template #item="{ element: group }">
        <div class="group-card">
          <div class="drag-handle"></div>
          <div>{{ group.name }}</div>
        </div>
      </template>
    </draggable>
  </div>
</template>

<script>
function handleGroupDragEnd(event) {
  // 拖拽结束后保存新的排序
  const venueId = event.to.dataset.venueId;
  const groups = getGroupsByVenue(venueId);

  // 更新每个分组的排序号
  groups.forEach((group, index) => {
    group.sortOrder = index + 1;
  });

  // 保存到后端
  saveGroupOrders(groups);
}
</script>

2.6 时间段选择与分组显示

2.6.1 时间段切换

需求: 用户可以选择不同的时间段,查看该时间段下的分组安排

UI布局:

[1月5日 上午] [1月5日 下午] [1月6日 上午] [1月6日 下午] ...
                    ↓ (选中)
      显示 "1月5日 下午" 时间段下的所有分组

实现:

data() {
  return {
    timeSlots: [],           // 所有时间段
    currentTimeSlotId: null, // 当前选中的时间段ID
    allGroups: []            // 所有分组
  };
},

computed: {
  // 当前时间段下的分组
  currentTimeSlotGroups() {
    if (!this.currentTimeSlotId) return [];

    return this.allGroups.filter(group =>
      group.timeSlotId === this.currentTimeSlotId
    );
  }
},

methods: {
  // 选择时间段
  selectTimeSlot(timeSlotId) {
    this.currentTimeSlotId = timeSlotId;
  }
}

2.6.2 未分配分组池和未分组参赛者

系统中存在两个不同的"未分配"概念,需要明确区分:

1. 未分组的参赛者 (Ungrouped Participants)

  • 含义: 已报名成功但还没有被加入任何分组的参赛选手
  • 来源: 新增的报名数据,或从已有分组中移除的选手
  • 显示位置: 页面底部或侧边栏的"未分组参赛者"区域
  • 操作: 可以手动添加到已有分组,或通过"自动分组"按钮批量分组

2. 未分配时间段的分组 (Unassigned Groups)

  • 含义: 已经分好组(包含参赛人员)但还没有分配到具体时间段的分组
  • 来源: 新创建的分组,或从时间段中移除的分组
  • 显示位置: 时间段选择器下方的"未分配分组池"
  • 操作: 可以拖拽到任意时间段,或通过"自动编排"自动分配

UI位置: 在时间段按钮下方显示"未分配分组池",在页面底部显示"未分组参赛者"

<template>
  <div class="schedule-container">
    <!-- 时间段选择 -->
    <div class="time-slots">
      <button
        v-for="slot in timeSlots"
        :key="slot.id"
        :class="{ active: currentTimeSlotId === slot.id }"
        @click="selectTimeSlot(slot.id)"
      >
        {{ slot.label }}
      </button>
    </div>

    <!-- 未分配分组池 (已分组但未分配时间段) -->
    <div class="unassigned-groups" v-if="unassignedGroups.length > 0">
      <h4>未分配分组 ({{ unassignedGroups.length }})</h4>
      <draggable
        v-model="unassignedGroups"
        :group="{ name: 'groups', pull: true, put: true }"
        item-key="id"
      >
        <template #item="{ element: group }">
          <div class="group-card">{{ group.name }}</div>
        </template>
      </draggable>
    </div>

    <!-- 当前时间段的分组 -->
    <div class="time-slot-groups">
      <h3>{{ currentTimeSlot.label }}</h3>

      <div v-for="venue in venues" :key="venue.id" class="venue-section">
        <h4>{{ venue.name }}</h4>
        <draggable
          v-model="getGroupsByVenueAndTimeSlot(venue.id, currentTimeSlotId)"
          :group="{ name: 'groups' }"
          item-key="id"
          @end="handleGroupDragEnd"
        >
          <template #item="{ element: group }">
            <GroupCard :group="group" />
          </template>
        </draggable>
      </div>
    </div>

    <!-- 未分组的参赛者 (在页面底部) -->
    <div class="ungrouped-participants" v-if="ungroupedParticipants.length > 0">
      <h4>未分组参赛者 ({{ ungroupedParticipants.length }})</h4>
      <el-table :data="ungroupedParticipants" size="small">
        <el-table-column label="姓名" prop="playerName" />
        <el-table-column label="单位" prop="organization" />
        <el-table-column label="项目" prop="projectName" />
        <el-table-column label="操作">
          <template #default="{ row }">
            <el-button size="small" @click="addParticipantToGroup(row)">
              添加到分组
            </el-button>
          </template>
        </el-table-column>
      </el-table>
    </div>
  </div>
</template>

<script>
computed: {
  // 未分配到任何时间段的分组
  unassignedGroups() {
    return this.allGroups.filter(group => !group.timeSlotId);
  },

  // 未加入任何分组的参赛者
  ungroupedParticipants() {
    const groupedParticipantIds = new Set();
    this.allGroups.forEach(group => {
      group.participants.forEach(p => groupedParticipantIds.add(p.id));
    });
    return this.allParticipants.filter(p => !groupedParticipantIds.has(p.id));
  },

  // 当前选中的时间段对象
  currentTimeSlot() {
    return this.timeSlots.find(s => s.id === this.currentTimeSlotId) || {};
  }
}
</script>

3. 完整的页面功能流程

3.1 页面初始化流程

1. 用户进入编排页面
   ↓
2. 从URL获取 competitionId (赛事ID)
   ↓
3. 加载赛事基本信息
   - 赛事名称
   - 比赛开始时间
   - 比赛结束时间
   ↓
4. 自动生成时间段列表
   - 调用 generateTimeSlots()
   - 默认选中第一个时间段
   ↓
5. 加载场地列表
   - 调用 API: GET /api/martial/venue/list?competitionId={id}
   ↓
6. 加载所有报名数据
   - 调用 API: GET /api/martial/athlete/list?competitionId={id}&status=1
   ↓
7. 自动分组
   - 调用 autoGroupParticipants()
   - 集体项目优先,个人项目在后
   ↓
8. 加载已保存的编排数据(如果存在)
   - 调用 API: GET /api/martial/schedule/list?competitionId={id}
   - 恢复分组的场地、时间段分配
   ↓
9. 渲染页面
   - 显示时间段按钮
   - 显示未分配分组
   - 显示当前时间段的场地和分组

3.2 自动编排流程

1. 用户点击"自动编排"按钮
   ↓
2. 执行场地自动分配
   - 调用 autoAssignVenues()
   - 使用负载均衡算法
   ↓
3. 执行时间段自动分配
   - 按时间顺序填充时间段
   - 考虑每个时间段的容量(4小时)
   ↓
4. 保存编排结果
   - 调用 API: POST /api/martial/schedule/save
   ↓
5. 提示用户"自动编排完成"
   ↓
6. 刷新页面显示

3.3 手动调整流程

1. 拖拽分组到不同场地
   ↓
2. 触发 @end 事件
   ↓
3. 更新分组的 venueId
   ↓
4. 保存到后端

或

1. 点击分组的"移动"按钮
   ↓
2. 选择目标场地
   ↓
3. 更新分组的 venueId
   ↓
4. 保存到后端

4. 数据库设计

4.1 新增表: martial_schedule (赛程安排表)

CREATE TABLE `martial_schedule` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `competition_id` BIGINT NOT NULL COMMENT '赛事ID',
  `group_id` VARCHAR(50) NOT NULL COMMENT '分组ID',
  `group_name` VARCHAR(200) NOT NULL COMMENT '分组名称',
  `group_code` VARCHAR(50) COMMENT '分组编号',
  `group_type` VARCHAR(20) COMMENT '分组类型: team=集体, individual=个人',
  `project_id` BIGINT COMMENT '项目ID',
  `venue_id` BIGINT COMMENT '场地ID',
  `time_slot_id` VARCHAR(50) COMMENT '时间段ID',
  `start_time` DATETIME COMMENT '开始时间',
  `end_time` DATETIME COMMENT '结束时间',
  `estimated_duration` INT COMMENT '预计时长(分钟)',
  `sort_order` INT DEFAULT 0 COMMENT '排序号',
  `status` TINYINT DEFAULT 0 COMMENT '状态: 0=草稿, 1=已发布',
  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `is_deleted` TINYINT DEFAULT 0,
  `tenant_id` VARCHAR(12) DEFAULT '000000',
  PRIMARY KEY (`id`),
  KEY `idx_competition` (`competition_id`),
  KEY `idx_venue` (`venue_id`),
  KEY `idx_time_slot` (`time_slot_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛程安排表';

4.2 新增表: martial_schedule_detail (赛程明细表)

CREATE TABLE `martial_schedule_detail` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `schedule_id` BIGINT NOT NULL COMMENT '赛程ID',
  `athlete_id` BIGINT NOT NULL COMMENT '运动员ID',
  `player_name` VARCHAR(100) COMMENT '选手姓名',
  `organization` VARCHAR(200) COMMENT '所属单位',
  `project_name` VARCHAR(100) COMMENT '项目名称',
  `order_num` INT COMMENT '出场顺序',
  `actual_start_time` DATETIME COMMENT '实际开始时间',
  `actual_end_time` DATETIME COMMENT '实际结束时间',
  `status` TINYINT DEFAULT 0 COMMENT '状态: 0=未开始, 1=进行中, 2=已完成',
  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `is_deleted` TINYINT DEFAULT 0,
  PRIMARY KEY (`id`),
  KEY `idx_schedule` (`schedule_id`),
  KEY `idx_athlete` (`athlete_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛程明细表';

5. API接口设计

5.1 时间段相关接口

GET /api/martial/schedule/time-slots

功能: 获取赛事的时间段列表

请求参数:

{
  "competitionId": 200
}

响应:

{
  "code": 200,
  "success": true,
  "data": [
    {
      "id": "slot_1",
      "date": "2026-01-05",
      "label": "1月5日 上午",
      "startTime": "2026-01-05 08:30:00",
      "endTime": "2026-01-05 12:00:00",
      "period": "morning"
    },
    {
      "id": "slot_2",
      "date": "2026-01-05",
      "label": "1月5日 下午",
      "startTime": "2026-01-05 13:30:00",
      "endTime": "2026-01-05 17:30:00",
      "period": "afternoon"
    }
  ]
}

5.2 分组相关接口

POST /api/martial/schedule/auto-group

功能: 自动生成分组

请求参数:

{
  "competitionId": 200
}

响应:

{
  "code": 200,
  "success": true,
  "data": [
    {
      "id": "group_1",
      "name": "少林寺武术学校 - 集体拳术表演",
      "code": "GROUP_001",
      "type": "team",
      "projectId": 208,
      "participants": [...],
      "estimatedDuration": 8
    }
  ]
}

PUT /api/martial/schedule/group/{groupId}/name

功能: 更新分组名称

请求参数:

{
  "name": "少林组集体拳"
}

响应:

{
  "code": 200,
  "success": true,
  "msg": "更新成功"
}

5.3 编排保存接口

POST /api/martial/schedule/save

功能: 保存编排结果

请求参数:

{
  "competitionId": 200,
  "schedules": [
    {
      "groupId": "group_1",
      "groupName": "少林组集体拳",
      "venueId": 200,
      "timeSlotId": "slot_1",
      "sortOrder": 1,
      "participants": [1000001, 1000002, ...]
    }
  ]
}

响应:

{
  "code": 200,
  "success": true,
  "msg": "保存成功"
}

5.4 自动分配接口

POST /api/martial/schedule/auto-assign-venues

功能: 自动分配场地

请求参数:

{
  "competitionId": 200,
  "groups": [...]
}

响应:

{
  "code": 200,
  "success": true,
  "data": [
    {
      "groupId": "group_1",
      "venueId": 200,
      "venueName": "主赛场A馆"
    }
  ]
}

6. 前端组件设计

6.1 组件结构

SchedulePage (编排主页面)
├── TimeSlotSelector (时间段选择器)
├── UnassignedGroupPool (未分配分组池)
├── VenueSection (场地区域)
│   ├── VenueHeader (场地标题)
│   └── GroupList (分组列表)
│       └── GroupCard (分组卡片)
│           ├── GroupHeader (分组头部)
│           ├── ParticipantTable (参赛人员表格)
│           └── GroupActions (操作按钮)
└── ScheduleActions (页面操作按钮)

6.2 核心组件: GroupCard

<template>
  <div class="group-card">
    <!-- 拖拽手柄 -->
    <div class="drag-handle" title="拖拽移动">
      <i class="el-icon-rank"></i>
    </div>

    <!-- 分组头部 -->
    <div class="group-header">
      <!-- 分组名称(可编辑) -->
      <div class="group-info">
        <el-input
          v-if="group.editingName"
          v-model="group.tempName"
          size="small"
          @blur="saveGroupName(group)"
          @keyup.enter="saveGroupName(group)"
        />
        <span
          v-else
          class="group-title"
          @dblclick="editGroupName(group)"
        >
          {{ group.name }}
          <i class="el-icon-edit"></i>
        </span>

        <span class="group-meta">
          {{ group.type === 'team' ? '集体' : '个人' }}
        </span>
        <span class="group-meta">
          {{ group.participants.length }}
        </span>
        <span class="group-meta">
          编号:{{ group.code }}
        </span>
      </div>

      <!-- 操作按钮 -->
      <div class="group-actions">
        <!-- 场地选择 -->
        <el-dropdown @command="(cmd) => moveGroupToVenue(group, cmd)">
          <el-button size="small" type="primary">
            {{ group.venueName || '选择场地' }}
            <i class="el-icon-arrow-down"></i>
          </el-button>
          <el-dropdown-menu slot="dropdown">
            <el-dropdown-item
              v-for="venue in venues"
              :key="venue.id"
              :command="venue.id"
            >
              {{ venue.name }}
            </el-dropdown-item>
          </el-dropdown-menu>
        </el-dropdown>

        <!-- 删除分组 -->
        <el-button
          size="small"
          type="danger"
          @click="deleteGroup(group)"
        >
          删除组
        </el-button>
      </div>
    </div>

    <!-- 参赛人员表格 -->
    <el-table :data="group.participants" size="small">
      <el-table-column label="序号" width="60" align="center">
        <template #default="scope">
          {{ scope.$index + 1 }}
        </template>
      </el-table-column>
      <el-table-column
        prop="organization"
        label="学校/单位"
        min-width="200"
      />
      <el-table-column
        prop="projectName"
        label="项目"
        min-width="150"
      />
      <el-table-column
        prop="playerName"
        label="姓名"
        width="100"
      />
      <el-table-column
        prop="category"
        label="组别"
        width="120"
      />
      <el-table-column label="操作" width="150" align="center">
        <template #default="scope">
          <el-button
            type="text"
            size="small"
            @click="moveParticipantUp(group, scope.$index)"
            :disabled="scope.$index === 0"
            title="上移"
          >
            <i class="el-icon-top"></i>
          </el-button>
          <el-button
            type="text"
            size="small"
            @click="moveParticipantDown(group, scope.$index)"
            :disabled="scope.$index === group.participants.length - 1"
            title="下移"
          >
            <i class="el-icon-bottom"></i>
          </el-button>
          <el-button
            type="text"
            size="small"
            @click="removeParticipant(group, scope.$index)"
            title="移除"
          >
            <i class="el-icon-delete"></i>
          </el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

7. 用户交互流程图

7.1 完整操作流程

graph TD
    A[进入编排页面] --> B[加载赛事数据]
    B --> C[生成时间段]
    B --> D[加载报名数据]
    B --> E[加载场地列表]

    D --> F[自动分组]
    F --> G{是否有已保存的编排}

    G -->|有| H[加载已保存编排]
    G -->|无| I[显示未分配分组]

    H --> J[显示编排结果]
    I --> J

    J --> K{用户操作}

    K -->|自动编排| L[自动分配场地和时间段]
    K -->|编辑分组名称| M[双击编辑]
    K -->|拖拽分组| N[移动到目标场地/时间段]
    K -->|调整顺序| O[在场地内拖拽排序]
    K -->|删除分组| P[确认删除]

    L --> Q[保存编排结果]
    M --> Q
    N --> Q
    O --> Q
    P --> Q

    Q --> R[刷新页面显示]
    R --> J

8. 技术实现要点

8.1 关键技术栈

技术 用途
Vue 3 前端框架
Element Plus UI组件库
vuedraggable 拖拽功能
axios HTTP请求
dayjs 时间处理

8.2 性能优化

  1. 虚拟滚动: 如果分组数量超过100个,使用虚拟滚动减少DOM渲染
  2. 防抖: 拖拽结束后延迟保存,避免频繁请求
  3. 批量保存: 收集多次修改,统一提交到后端
  4. 懒加载: 只加载当前时间段的分组数据

8.3 异常处理

  1. 网络异常: 保存失败时,提示用户重试
  2. 数据冲突: 多人同时编辑时,显示冲突提示
  3. 数据校验: 保存前检查必填字段
  4. 撤销/重做: 支持编排操作的撤销和重做

9. 测试用例

9.1 功能测试

测试项 测试步骤 预期结果
时间段生成 选择赛事,查看时间段 自动生成上午8:30和下午13:30的时间段
自动分组 点击"自动分组" 集体项目在前,个人项目在后
编辑分组名称 双击分组名称 弹出输入框,可编辑
拖拽分组 拖拽分组到其他场地 分组移动成功
场地自动分配 点击"自动分配场地" 分组均匀分配到各场地
保存编排 修改后点击保存 数据保存成功

9.2 边界测试

测试项 测试条件 预期结果
无报名数据 赛事没有报名 提示"暂无报名数据"
无场地 赛事没有场地 提示"请先添加场地"
时间段不足 分组太多,时间段不够 提示超出容量,建议增加时间
分组名称为空 输入空名称 使用原名称,提示不能为空

10. 未来扩展

10.1 智能推荐

  • 根据历史数据,推荐最优的分组方案
  • AI学习,自动优化编排结果

10.2 冲突检测

  • 检测同一选手是否报名多个项目
  • 检测时间冲突,自动调整

10.3 可视化增强

  • 甘特图显示赛程时间线
  • 热力图显示场地负载

10.4 导出功能

  • 导出Excel格式的赛程表
  • 导出PDF格式的秩序册
  • 生成二维码,选手扫码查看赛程

11. 总结

本文档详细描述了赛程编排页面的完整系统逻辑,包括:

时间段自动生成算法 智能分组规则(集体优先) 场地自动分配算法 分组名称编辑功能 拖拽移动和排序 完整的数据库设计 API接口规范 前端组件设计

实施建议:

  1. 先实现核心功能(时间段生成、自动分组)
  2. 再实现交互功能(拖拽、编辑)
  3. 最后优化体验(动画、提示)

开发周期估算: 3-5个工作日


文档维护:

  • 创建人: Claude Code
  • 创建日期: 2025-12-07
  • 版本: v1.0