赛事流程优化

This commit is contained in:
马梓彭 2026-04-15 09:58:29 +08:00
parent 0d98b72ab5
commit 8ecd8ffe45
4 changed files with 248 additions and 47 deletions

View File

@ -294,3 +294,38 @@ export const usereventregistrationOrderStatusGet = (orderNo) => {
return http.get(`/usereventregistration/order/status/${orderNo}`);
};
/**
* 检查组别选择限制
* @param {Object} data - 检查信息
* @param {number} data.eventId - 赛事ID
* @param {number} data.groupId - 要检查的组别ID
* @param {Array<number>} data.selectedGroupIds - 当前已选择的组别ID列表
* @returns {Promise<{
* canSelect: boolean,
* limitMessage: string,
* groupLimit: {
* groupLimitCount: number,
* currentRegistrations: number,
* isLimitReached: boolean
* },
* projectLimit: {
* projectLimitCount: number,
* currentSelectedCount: number,
* isLimitReached: boolean
* },
* teamProjectLimit: {
* limitCount: number,
* currentSelectedCount: number,
* isLimitReached: boolean
* },
* individualProjectLimit: {
* limitCount: number,
* currentSelectedCount: number,
* isLimitReached: boolean
* }
* }>}
*/
export const usereventregistrationCheckGroupSelectionLimitPost = (data) => {
return http.post('/usereventregistration/check-group-selection-limit', data);
};

View File

@ -75,11 +75,11 @@
<span>拖动项目卡片可调整顺序,按最终顺序导出</span>
</div>
<div ref="arrangementContainerRef" class="arrangement-cards-wrapper">
<div class="arrangement-card" v-for="(item, index) in eventlist" :key="index">
<div class="arrangement-card" v-for="(item, index) in eventlist" :key="item._tempId">
<div class="card-left">
<div class="project-name">{{ item.projectName }}</div>
<div class="project-type">类型: {{ item.projectType }}</div>
<div class="project-duration">时长: {{ formatDuration(item.durationSeconds) }}</div>
<div class="project-duration">时长: {{ formatDurationChinese(item.durationSeconds) }}</div>
</div>
<div class="card-middle">
@ -106,7 +106,7 @@
</div>
<div class="duration-box">
<div class="box-label">项目所需时长</div>
<div class="duration-value">{{ formatDuration(item.totalDurationSeconds || 0) }}</div>
<div class="duration-value">{{ formatDurationSymbol(item.totalDurationSeconds || 0) }}</div>
</div>
</div>
</div>
@ -114,7 +114,6 @@
<div class="action-bar">
<el-button type="default" @click="previewArrangement">预览</el-button>
<el-button type="primary" @click="onSave">保存修改</el-button>
<el-button type="primary" @click="getArr">导出</el-button>
</div>
</div>
@ -264,7 +263,7 @@
:rowspan="getTimeRowspan(index)"
class="time-cell"
>
{{ formatTime(item.timeRange) }}
{{ item.projectTotalDuration }}
</td>
<td class="project-cell">{{ item.projectName }}</td>
<td class="group-cell">{{ item.groupName }}</td>
@ -312,6 +311,7 @@ const importingScore = ref(false);
const issuingPoints = ref(false);
const arrangementContainerRef = ref(null);
let sortableInstance = null;
let isSorting = false; // 标志位,标记是否正在排序中
// 预览相关
const previewDialogVisible = ref(false);
@ -418,7 +418,10 @@ const openDialog = async (id) => {
}
const res2 = await eventarrangementListGet(id);
eventlist.value = filterProjectsWithParticipants(res2.data);
eventlist.value = filterProjectsWithParticipants(res2.data).map((item, idx) => ({
...item,
_tempId: item.id || `temp-${idx}-${Date.now()}`
}));
await nextTick();
initSortable();
@ -440,12 +443,24 @@ const initSortable = () => {
ghostClass: 'sort-ghost',
dragClass: 'sort-drag',
handle: '.arrangement-card',
onStart: () => {
isSorting = true;
},
onEnd: (evt) => {
const { oldIndex, newIndex } = evt;
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return;
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) {
isSorting = false;
return;
}
// 更新数据
const item = eventlist.value.splice(oldIndex, 1)[0];
eventlist.value.splice(newIndex, 0, item);
// 延迟设置 isSorting 为 false,确保 Vue 不会立即重新渲染
setTimeout(() => {
isSorting = false;
}, 200);
}
});
};
@ -614,6 +629,22 @@ const previewArrangement = async () => {
return;
}
// 为每个项目设置正确的 SortIndex(根据当前在数组中的位置)
const settingsWithSortIndex = eventlist.value.map((item, index) => ({
...item,
SortIndex: index + 1 // 从 1 开始排序
}));
// 先保存当前修改
const saveRes = await eventarrangementPut({
eventId: initParam.value.id,
settings: settingsWithSortIndex
});
if (!saveRes.succeeded) {
ElMessage.error('保存失败,无法预览');
return;
}
const res = await eventarrangementPreviewPost(initParam.value.id);
if (res.succeeded) {
previewData.value = res.data || [];
@ -637,7 +668,7 @@ const isTimeMerged = (index) => {
if (index === 0) return false;
const current = previewData.value[index];
const prev = previewData.value[index - 1];
return current?.projectName === prev?.projectName && current?.timeRange === prev?.timeRange;
return current?.projectName === prev?.projectName;
}
// 获取时间列的合并行数
@ -648,7 +679,7 @@ const getTimeRowspan = (index) => {
let count = 1;
for (let i = index + 1; i < previewData.value.length; i++) {
const next = previewData.value[i];
if (next?.projectName === current.projectName && next?.timeRange === current.timeRange) {
if (next?.projectName === current.projectName) {
count++;
} else {
break;
@ -659,9 +690,15 @@ const getTimeRowspan = (index) => {
//更新赛事编排
const onSave = async () => {
// 为每个项目设置正确的 SortIndex(根据当前在数组中的位置)
const settingsWithSortIndex = eventlist.value.map((item, index) => ({
...item,
SortIndex: index + 1 // 从 1 开始排序
}));
const res = await eventarrangementPut({
eventId: initParam.value.id,
settings: eventlist.value
settings: settingsWithSortIndex
});
if (res.succeeded) {
ElMessage.success('操作成功!');
@ -690,6 +727,22 @@ const getArr = async () => {
return;
}
// 为每个项目设置正确的 SortIndex(根据当前在数组中的位置)
const settingsWithSortIndex = eventlist.value.map((item, index) => ({
...item,
SortIndex: index + 1 // 从 1 开始排序
}));
// 先保存当前修改
const saveRes = await eventarrangementPut({
eventId: initParam.value.id,
settings: settingsWithSortIndex
});
if (!saveRes.succeeded) {
ElMessage.error('保存失败,无法导出');
return;
}
const { succeeded, data, errors } = await eventarrangementExportPost(initParam.value.id);
if (!succeeded) {
ElMessage.error(errors || '导出失败');
@ -1097,8 +1150,8 @@ const isTeamProject = (projectType) => {
return projectType && projectType.includes('团队');
};
// 格式化时长显示
const formatDuration = (totalSeconds) => {
// 格式化时长显示(中文格式:X分X秒)
const formatDurationChinese = (totalSeconds) => {
if (!totalSeconds || totalSeconds < 0) {
return '0分0秒';
}
@ -1115,6 +1168,41 @@ const formatDuration = (totalSeconds) => {
}
};
// 格式化时长显示(符号格式:X'X")
const formatDurationSymbol = (totalSeconds) => {
if (!totalSeconds || totalSeconds < 0) {
return '0\'0"';
}
// 计算小时、分钟、秒
const hours = Math.floor(totalSeconds / 3600);
const remainingSecondsAfterHours = totalSeconds % 3600;
const minutes = Math.floor(remainingSecondsAfterHours / 60);
const seconds = remainingSecondsAfterHours % 60;
// 如果不足1小时,只显示分钟和秒
if (hours === 0) {
if (seconds === 0) {
return `${minutes}'`;
} else {
// 秒数使用两位数显示,不足补零
const paddedSeconds = seconds.toString().padStart(2, '0');
return `${minutes}'${paddedSeconds}"`;
}
} else {
// 有小时的情况
let result = `${hours}h`;
if (minutes > 0 || seconds > 0) {
result += `${minutes}'`;
if (seconds > 0) {
const paddedSeconds = seconds.toString().padStart(2, '0');
result += `${paddedSeconds}"`;
}
}
return result;
}
};
// 过滤有有效参与者的项目
const filterProjectsWithParticipants = (projects) => {
if (!projects || !Array.isArray(projects)) {
@ -1204,6 +1292,9 @@ const validateVenueCount = () => {
watch(
eventlist,
(newList) => {
// 如果正在排序中,不执行计算,避免 Vue 立即重新渲染
if (isSorting) return;
if (newList && newList.length > 0) {
newList.forEach((item) => {
const result = calculateProjectDuration(item);

View File

@ -2,9 +2,7 @@
<div class="system-menu-container layout-padding">
<ProTable ref="tableRef" :request-api="usereventrecordListGet" :columns="columns" :pagination="true"
:tool-button="false" :init-param="initParam" row-key="registrationId">
<template #status="scope">
<el-tag :type="tagTypeForStatus(scope.row.status)">{{ tagTextForStatus(scope.row.status) }}</el-tag>
</template>
<template #payStatus="scope">
<el-tag :type="tagTypeForPayStatus(scope.row.payStatus)">{{ tagTextForPayStatus(scope.row.payStatus) }}</el-tag>
</template>
@ -81,9 +79,7 @@
<el-descriptions :column="2" border size="small" v-if="currentRow">
<el-descriptions-item label="赛事名称">{{ currentRow.eventName ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="参赛队伍">{{ currentRow.teamName ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="报名状态">
<el-tag :type="tagTypeForStatus(currentRow.status)">{{ tagTextForStatus(currentRow.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="支付状态">
<el-tag :type="tagTypeForPayStatus(currentRow.payStatus)">{{ tagTextForPayStatus(currentRow.payStatus) }}</el-tag>
</el-descriptions-item>
@ -646,18 +642,16 @@ const getEventStatusText = (status) => {
const disableCancel = computed(() => {
if (!currentRow.value) return true;
const status = normalizeToNumber(currentRow.value.status);
const payStatus = normalizeToNumber(currentRow.value.payStatus);
// 只有报名状态为已支付(1) 且 支付状态为已支付(1),才能取消/退款
return !(status === 1 && payStatus === 1);
// 只有支付状态为已支付(1),才能取消/退款
return !(payStatus === 1);
});
const isCancelDisabledByRow = (row) => {
if (!row) return true;
const status = normalizeToNumber(row.status);
const payStatus = normalizeToNumber(row.payStatus);
// 只有报名状态为已支付(1) 且 支付状态为已支付(1),才能取消/退款
return !(status === 1 && payStatus === 1);
// 只有支付状态为已支付(1),才能取消/退款
return !(payStatus === 1);
};
// 格式化日期

View File

@ -300,6 +300,19 @@
<!-- 验证提示区域 -->
<div class="validation-notes-section">
<!-- 验证加载中提示 -->
<el-alert
v-if="validationLoading"
title="正在验证队员信息..."
type="info"
:closable="false"
style="margin-bottom: 12px;"
>
<template #icon>
<el-icon class="is-loading"><Loading /></el-icon>
</template>
</el-alert>
<!-- 年龄性别不符合要求提示 -->
<el-alert
v-if="hasAnyValidationErrors()"
@ -929,6 +942,8 @@ const memberValidationMap = ref({}); // { groupId_memberId: { isValid: boolean,
const groupTeamLimitMap = ref({}); // { groupId: { maxTeams: number, currentTeams: number } }
const memberRegistrationLimitMap = ref({}); // { memberId: { isLimited: boolean, message: string } }
const registrationLimitCheckResult = ref(null); // 报名限制检查结果
const validationLoading = ref(false); // 验证加载状态
let validationDebounceTimer = null; // 防抖定时器
// Step2: 团队相关
const teamList = ref([]);
@ -997,6 +1012,11 @@ const clearRegisterState = () => {
groupTeamLimitMap.value = {};
memberRegistrationLimitMap.value = {};
registrationLimitCheckResult.value = null;
validationLoading.value = false;
if (validationDebounceTimer) {
clearTimeout(validationDebounceTimer);
validationDebounceTimer = null;
}
previewLoading.value = false;
previewData.value = null;
paymentMethod.value = 1;
@ -1172,6 +1192,66 @@ const validateMemberForGroup = async (member, group) => {
return { isValid: true, ageValid: true, genderValid: true, registrationLimitValid: true, message: '' };
};
// 实时验证所有已选择的队员
const validateAllMembers = async () => {
if (!currentEventId.value || activeStep.value !== 1) {
return;
}
validationLoading.value = true;
try {
// 收集所有需要验证的队员和组别组合
const validationTasks = [];
for (const group of selectedGroupItems.value) {
const gId = group.id;
// 检查是否为团队项目且启用了多团队模式
const isMultiTeamMode = isTeamProject(group) && groupTeamsMap.value[gId] &&
(groupTeamsMap.value[gId].length > 1 || getGroupMaxTeams(gId) > 1);
let membersToValidate = [];
if (isMultiTeamMode) {
// 多团队模式:收集所有团队的队员
for (const team of groupTeamsMap.value[gId]) {
membersToValidate = membersToValidate.concat(team.members);
}
} else {
// 单团队模式
membersToValidate = groupMemberMap.value[gId] || [];
}
// 为每个队员创建验证任务
for (const member of membersToValidate) {
validationTasks.push(validateMemberForGroup(member, group));
}
}
// 并行执行所有验证任务
await Promise.all(validationTasks);
// 同时检查报名限制
await checkRegistrationLimit();
} catch (error) {
console.error('批量验证队员失败:', error);
} finally {
validationLoading.value = false;
}
};
// 带防抖的实时验证
const debounceValidateAllMembers = () => {
if (validationDebounceTimer) {
clearTimeout(validationDebounceTimer);
}
validationDebounceTimer = setTimeout(() => {
validateAllMembers();
}, 400); // 400毫秒防抖
};
// 获取队员验证结果
const getMemberValidationResult = (member, group) => {
const key = `${group.id}_${member.userId ?? member.id}`;
@ -1237,24 +1317,14 @@ const assignMemberToActiveGroup = async (member) => {
return;
}
// 获取当前组别信息进行验证
const group = selectedGroupItems.value.find(g => g.id === gId);
if (group) {
const validation = await validateMemberForGroup(member, group);
if (!validation.isValid) {
ElMessage.warning(validation.message);
return;
}
}
// 设置已使用的团队
if (!usedTeamId.value) {
usedTeamId.value = member.teamId;
}
groupMemberMap.value[gId].push({ ...member, teamId: member.teamId });
// 实时检查报名限制
await checkRegistrationLimit();
// 触发实时验证(包含报名限制检查)
debounceValidateAllMembers();
};
// 从组别中移除成员
@ -1272,6 +1342,9 @@ const removeMemberFromGroup = (groupId, memberId) => {
if (!hasMembers) {
usedTeamId.value = null;
}
// 触发实时验证
debounceValidateAllMembers();
};
// ==================== 多团队管理函数 ====================
@ -1381,6 +1454,9 @@ const removeTeam = async (groupId, teamIndex) => {
});
ElMessage.success('团队删除成功');
// 触发实时验证
debounceValidateAllMembers();
}
} catch {
// 用户取消
@ -1464,23 +1540,14 @@ const assignMemberToActiveTeam = async (member) => {
return;
}
// 获取当前组别信息进行验证(group变量已在函数开头声明)
if (group) {
const validation = await validateMemberForGroup(member, group);
if (!validation.isValid) {
ElMessage.warning(validation.message);
return;
}
}
// 设置已使用的团队
if (!usedTeamId.value) {
usedTeamId.value = member.teamId;
}
team.members.push({ ...member, teamId: member.teamId });
// 实时检查报名限制
await checkRegistrationLimit();
// 触发实时验证(包含报名限制检查)
debounceValidateAllMembers();
};
// 从团队中移除成员
@ -1501,6 +1568,9 @@ const removeMemberFromTeam = (groupId, teamIndex, memberId) => {
if (!hasMembers) {
usedTeamId.value = null;
}
// 触发实时验证
debounceValidateAllMembers();
};
// 验证优惠券
@ -1650,6 +1720,10 @@ const handleStep1Next = async () => {
setActiveTeam(group.id, 1);
}
});
// 进入步骤2时立即进行一次完整验证
await nextTick();
validateAllMembers();
};
const handleStep2Next = async () => {
@ -1960,6 +2034,13 @@ const handleCreateOrderAndPay = async () => {
// 微信/支付宝支付,显示二维码
paymentData.value = data;
// 检查二维码是否获取成功
if (!data.qrcodeUrl) {
ElMessage.error('支付链接获取失败,请稍后重试');
return;
}
await generateQRCode(data.qrcodeUrl);
// 开始轮询支付状态