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

## 📋 文档概述

**文档名称**: 赛程编排页面系统逻辑设计与实现方案
**创建日期**: 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 时间段数据结构

```javascript
{
  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 算法实现

```javascript
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` 表获取所有已报名且状态为"已审核通过"的参赛数据:

```sql
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 分组数据结构

```javascript
{
  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 自动分组算法

```javascript
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 实现代码

```javascript
// 进入编辑模式
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 实现代码

```javascript
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显示
```

**实现**:
```javascript
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` 组件

```vue
<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日 下午" 时间段下的所有分组
```

**实现**:
```javascript
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位置**: 在时间段按钮下方显示"未分配分组池",在页面底部显示"未分组参赛者"

```vue
<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 (赛程安排表)

```sql
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 (赛程明细表)

```sql
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
**功能**: 获取赛事的时间段列表

**请求参数**:
```json
{
  "competitionId": 200
}
```

**响应**:
```json
{
  "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
**功能**: 自动生成分组

**请求参数**:
```json
{
  "competitionId": 200
}
```

**响应**:
```json
{
  "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
**功能**: 更新分组名称

**请求参数**:
```json
{
  "name": "少林组集体拳"
}
```

**响应**:
```json
{
  "code": 200,
  "success": true,
  "msg": "更新成功"
}
```

### 5.3 编排保存接口

#### POST /api/martial/schedule/save
**功能**: 保存编排结果

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

**响应**:
```json
{
  "code": 200,
  "success": true,
  "msg": "保存成功"
}
```

### 5.4 自动分配接口

#### POST /api/martial/schedule/auto-assign-venues
**功能**: 自动分配场地

**请求参数**:
```json
{
  "competitionId": 200,
  "groups": [...]
}
```

**响应**:
```json
{
  "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

```vue
<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 完整操作流程

```mermaid
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
