1257 lines
32 KiB
Markdown
1257 lines
32 KiB
Markdown
# 赛程编排系统功能分析文档
|
||
|
||
## 📋 文档概述
|
||
|
||
**文档名称**: 赛程编排页面系统逻辑设计与实现方案
|
||
**创建日期**: 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
|