32 KiB
32 KiB
赛程编排系统功能分析文档
📋 文档概述
文档名称: 赛程编排页面系统逻辑设计与实现方案 创建日期: 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 性能优化
- 虚拟滚动: 如果分组数量超过100个,使用虚拟滚动减少DOM渲染
- 防抖: 拖拽结束后延迟保存,避免频繁请求
- 批量保存: 收集多次修改,统一提交到后端
- 懒加载: 只加载当前时间段的分组数据
8.3 异常处理
- 网络异常: 保存失败时,提示用户重试
- 数据冲突: 多人同时编辑时,显示冲突提示
- 数据校验: 保存前检查必填字段
- 撤销/重做: 支持编排操作的撤销和重做
9. 测试用例
9.1 功能测试
| 测试项 | 测试步骤 | 预期结果 |
|---|---|---|
| 时间段生成 | 选择赛事,查看时间段 | 自动生成上午8:30和下午13:30的时间段 |
| 自动分组 | 点击"自动分组" | 集体项目在前,个人项目在后 |
| 编辑分组名称 | 双击分组名称 | 弹出输入框,可编辑 |
| 拖拽分组 | 拖拽分组到其他场地 | 分组移动成功 |
| 场地自动分配 | 点击"自动分配场地" | 分组均匀分配到各场地 |
| 保存编排 | 修改后点击保存 | 数据保存成功 |
9.2 边界测试
| 测试项 | 测试条件 | 预期结果 |
|---|---|---|
| 无报名数据 | 赛事没有报名 | 提示"暂无报名数据" |
| 无场地 | 赛事没有场地 | 提示"请先添加场地" |
| 时间段不足 | 分组太多,时间段不够 | 提示超出容量,建议增加时间 |
| 分组名称为空 | 输入空名称 | 使用原名称,提示不能为空 |
10. 未来扩展
10.1 智能推荐
- 根据历史数据,推荐最优的分组方案
- AI学习,自动优化编排结果
10.2 冲突检测
- 检测同一选手是否报名多个项目
- 检测时间冲突,自动调整
10.3 可视化增强
- 甘特图显示赛程时间线
- 热力图显示场地负载
10.4 导出功能
- 导出Excel格式的赛程表
- 导出PDF格式的秩序册
- 生成二维码,选手扫码查看赛程
11. 总结
本文档详细描述了赛程编排页面的完整系统逻辑,包括:
✅ 时间段自动生成算法 ✅ 智能分组规则(集体优先) ✅ 场地自动分配算法 ✅ 分组名称编辑功能 ✅ 拖拽移动和排序 ✅ 完整的数据库设计 ✅ API接口规范 ✅ 前端组件设计
实施建议:
- 先实现核心功能(时间段生成、自动分组)
- 再实现交互功能(拖拽、编辑)
- 最后优化体验(动画、提示)
开发周期估算: 3-5个工作日
文档维护:
- 创建人: Claude Code
- 创建日期: 2025-12-07
- 版本: v1.0