赛事页面调整

This commit is contained in:
马梓彭 2026-04-13 06:59:37 +08:00
parent d0a527dd9e
commit ed6cb2b19e
5 changed files with 1117 additions and 54 deletions

View File

@ -6,14 +6,16 @@ import { http } from '../../utils/request';
/**
* 分页查询参赛记录
* @param {Object} params - 查询参数
* @param {number} [params.AppUserId] - 普通用户ID
* @param {number} [params.MerchantId] - 商户ID
* @param {number} [params.AppUserId] - 普通用户ID(AppUser表的Id)
* @param {number} [params.SysUserId] - 系统用户ID(SysUser表的Id,用于单位用户)
* @param {number} [params.MerchantId] - 商户ID(已弃用,使用SysUserId)
* @param {string} [params.EventName] - 赛事名称 (模糊查询)
* @param {number} [params.RegistrationStatus] - 报名状态 (0:待支付, 1:已支付, 2:已取消, 3:已退款, 4:已完成)
* @param {number} [params.PaymentStatus] - 支付状态 (0:未支付/待审核, 1:已支付/审核通过, 2:支付失败/审核拒绝, 3:已退款, 4:已取消)
* @param {string} [params.StartTime] - 开始时间
* @param {string} [params.EndTime] - 结束时间
* @param {number} [params.PageNo] - 当前页码
* @param {number} [params.PageSize] - 每页条数
* @param {number} [params.refresh] - 刷新标记(用于强制刷新数据)
* @returns {Promise<{
* pageNo: number,
* pageSize: number,

View File

@ -69,24 +69,44 @@
</ProTable>
</el-tab-pane>
<el-tab-pane label="赛事编排" name="third">
<el-descriptions :title="item.projectName" v-for="(item, index) in eventlist" :key="index">
<el-descriptions-item label="排序:">
<el-input-number v-model="item.sortIndex" :min="1" :step="1" />
</el-descriptions-item>
<el-descriptions-item label="设置场地数量:">
<el-input-number v-model="item.venueCount" :min="0" :step="1" />
</el-descriptions-item>
<el-descriptions-item label="类型:">{{ item.projectType }}</el-descriptions-item>
<el-descriptions-item label="时长:">{{ item.totalDurationMinutes }}:{{ item.durationSeconds
}}</el-descriptions-item>
<el-descriptions-item :label="`组别${idx + 1}:`" v-for="(group, idx) in item.groups">
<el-tag>{{ group.groupName }}</el-tag>
<span style="margin: 0 70px;">参与人数: {{ group.participantCount }}</span>
</el-descriptions-item>
</el-descriptions>
<div style="text-align: right; margin-top: 20px;">
<el-button type="primary" @click="onSave"> 保存修改 </el-button>
<el-button type="primary" @click="getArr"> 导出编排 </el-button>
<div class="arrangement-container">
<div class="sort-hint-top">
<el-icon><Rank /></el-icon>
<span>拖动项目卡片可调整顺序,按最终顺序导出</span>
</div>
<div ref="arrangementContainerRef" class="arrangement-cards-wrapper">
<div class="arrangement-card" v-for="(item, index) in eventlist" :key="index">
<div class="card-left">
<div class="project-name">{{ item.projectName }}</div>
<div class="project-type">类型: {{ item.projectType }}</div>
<div class="project-duration">时长: {{ item.totalDurationMinutes }}分{{ item.durationSeconds }}秒</div>
</div>
<div class="card-middle">
<div class="group-tag" v-for="(group, idx) in item.groups" :key="idx">
<span class="group-name">{{ group.groupName }}</span>
<span class="group-count">{{ group.participantCount }}{{ getParticipantUnit(item.projectType) }}</span>
</div>
</div>
<div class="card-right">
<div class="venue-box">
<div class="box-label">设置场地数量</div>
<el-input-number v-model="item.venueCount" :min="0" :step="1" size="large" />
</div>
<div class="duration-box">
<div class="box-label">项目所需时长</div>
<div class="duration-value">{{ item.totalDurationMinutes }}'</div>
</div>
</div>
</div>
</div>
<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>
</el-tab-pane>
<el-tab-pane label="成绩与证书" name="fourth">
@ -153,15 +173,66 @@
@change="onScoreFileChange"
/>
<!-- 赛事编排预览对话框 -->
<el-dialog
v-model="previewDialogVisible"
title="赛事编排预览"
width="90%"
style="min-height: 70vh;"
:close-on-click-modal="false"
>
<div class="preview-container">
<div class="preview-title">{{ previewEventName }}</div>
<div class="preview-table-wrapper">
<table class="preview-table" border="1" cellpadding="6" cellspacing="0">
<thead>
<tr class="header-row">
<th class="time-col">时间</th>
<th class="project-col">参赛项目</th>
<th class="group-col">参赛组别</th>
<th class="team-col">参赛队伍</th>
<th class="athlete-col">运动员姓名</th>
<th class="session-col">场次</th>
<th class="venue-col">场地</th>
</tr>
</thead>
<tbody>
<template v-for="(item, index) in previewData" :key="index">
<tr :class="index % 2 === 0 ? 'data-row-even' : 'data-row-odd'">
<td
v-if="!isTimeMerged(index)"
:rowspan="getTimeRowspan(index)"
class="time-cell"
>
{{ formatTime(item.timeRange) }}
</td>
<td class="project-cell">{{ item.projectName }}</td>
<td class="group-cell">{{ item.groupName }}</td>
<td class="team-cell">{{ item.teamName || '-' }}</td>
<td class="athlete-cell">{{ item.athleteName }}</td>
<td class="session-cell">{{ item.sessionIndex }}</td>
<td class="venue-cell">{{ item.venueIndex }}</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<template #footer>
<el-button @click="previewDialogVisible = false">关闭</el-button>
<el-button type="primary" @click="getArr">导出Excel</el-button>
</template>
</el-dialog>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, markRaw } from 'vue';
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
import { ArrowLeft, ArrowRight, Check, CircleCheckFilled, Refresh } from '@element-plus/icons-vue';
import { eventPost, eventPut, eventDetailGet } from '/@/api/match/event';
import { eventarrangementListGet, eventarrangementPut, eventarrangementExportPost } from '/@/api/match/eventarrangement';
import { Check, View, Download, Rank } from '@element-plus/icons-vue';
import { eventDetailGet } from '/@/api/match/event';
import { eventarrangementListGet, eventarrangementPut, eventarrangementExportPost, eventarrangementPreviewPost } from '/@/api/match/eventarrangement';
import { eventregistrationPagePost, eventregistrationBatchauditPost, eventregistrationMaterialurlsPost } from '/@/api/match/eventregistration';
import { eventscorePageathletePost, eventscorePagecoachleaderPost,eventscoreImportscorePost, eventscoreDownloadtemplateGet, eventscoreExportcertificatePost } from '/@/api/match/eventscore';
import Step1BasicInfo from './Steps/Step1BasicInfo.vue';
@ -170,12 +241,20 @@ import Step3ProjectConfig from './Steps/Step3ProjectConfig.vue';
import Step4FeeConfig from './Steps/Step4FeeConfig.vue';
import ProTable from '/@/components/ProTable/index.vue';
import PreviewImage from '/@/components/PreviewImage/index.vue';
import Sortable from 'sortablejs';
const isShowDialog = ref(false);
const activeName = ref('first')
const eventlist = ref([]);
const importingScore = ref(false);
const arrangementContainerRef = ref(null);
let sortableInstance = null;
// 预览相关
const previewDialogVisible = ref(false);
const previewData = ref([]);
const previewEventName = ref('赛事编排预览');
const scoreFileInputRef = ref(null);
const tableuu = ref();
const tableCoach = ref();
@ -230,9 +309,11 @@ const formData = ref({
const openDialog = async (id) => {
isShowDialog.value = true;
const res = await eventDetailGet(id);
initParam.value.eventId = id;
initParam.value.id = id;
const d = res?.data;
if (d) {
previewEventName.value = d.eventName || '赛事编排预览';
formData.value.basicInfo = {
...formData.value.basicInfo,
id: d.id,
@ -263,22 +344,48 @@ const openDialog = async (id) => {
const res2 = await eventarrangementListGet(id);
eventlist.value = res2.data
await nextTick();
initSortable();
};
const initSortable = () => {
if (!arrangementContainerRef.value) return;
if (sortableInstance) {
sortableInstance.destroy();
}
sortableInstance = new Sortable(arrangementContainerRef.value, {
animation: 150,
ghostClass: 'sort-ghost',
dragClass: 'sort-drag',
handle: '.arrangement-card',
onEnd: (evt) => {
const { oldIndex, newIndex } = evt;
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return;
const item = eventlist.value.splice(oldIndex, 1)[0];
eventlist.value.splice(newIndex, 0, item);
}
});
};
const initParam = ref({
refresh: false,
id: '',
eventId: 0,
id: 0,
})
const initParamCoach = computed(() => ({
refresh: initParam.value.refresh,
id: initParam.value.id,
eventId: initParam.value.eventId,
personType: 'Coach'
}))
const initParamLeader = computed(() => ({
refresh: initParam.value.refresh,
id: initParam.value.id,
eventId: initParam.value.eventId,
personType: 'Leader'
}))
@ -378,6 +485,52 @@ const selection = (e) => {
selectListIds.value.push(element.id);
});
}
//预览赛事编排
const previewArrangement = async () => {
try {
const res = await eventarrangementPreviewPost(initParam.value.id);
if (res.succeeded) {
previewData.value = res.data || [];
previewDialogVisible.value = true;
}
} catch (error) {
console.error('预览编排失败:', error);
ElMessage.error('预览编排失败');
}
}
// 时间格式化
const formatTime = (timeStr) => {
if (!timeStr) return '';
// 将 "30''" 格式转换为 "30''"
return timeStr.replace("''", "''");
}
// 判断是否需要合并时间列
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;
}
// 获取时间列的合并行数
const getTimeRowspan = (index) => {
const current = previewData.value[index];
if (!current) return 1;
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) {
count++;
} else {
break;
}
}
return count;
}
//更新赛事编排
const onSave = async () => {
const res = await eventarrangementPut({
@ -577,7 +730,7 @@ const downloadTemplate = async () => {
const exportCertificate = async () => {
try {
const { succeeded, data, errors } = await eventscoreExportcertificatePost({
eventId: initParam.value.id
eventId: initParam.value.eventId
});
if (!succeeded) {
ElMessage.error(errors || '导出证书失败');
@ -604,8 +757,291 @@ const exportCertificate = async () => {
}
};
// 获取参与人数的单位显示
const getParticipantUnit = (projectType) => {
return projectType === '团队' ? '队' : '人';
};
// 暴露变量
defineExpose({
openDialog,
});
</script>
<style scoped>
.arrangement-container {
padding: 20px 0;
}
.sort-hint-top {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #ecf5ff;
border: 1px solid #b3d8ff;
border-radius: 6px;
margin-bottom: 20px;
color: #409eff;
font-size: 14px;
}
.sort-hint-top .el-icon {
font-size: 18px;
}
.arrangement-cards-wrapper {
min-height: 100px;
}
.arrangement-card {
display: flex;
border: 2px solid #409eff;
border-radius: 12px;
margin-bottom: 20px;
overflow: hidden;
background: #ffffff;
cursor: move;
transition: box-shadow 0.2s, transform 0.2s;
}
.arrangement-card:hover {
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
transform: translateY(-2px);
}
.sort-ghost {
opacity: 0.4;
background: #c6e2ff !important;
}
.sort-drag {
opacity: 0.9;
box-shadow: 0 8px 20px rgba(64, 158, 255, 0.4);
}
.card-left {
flex: 0 0 180px;
background: #409eff;
color: #ffffff;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.card-left .project-name {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.card-left .project-type,
.card-left .project-duration {
font-size: 14px;
line-height: 1.5;
}
.card-middle {
flex: 1;
padding: 16px;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-content: flex-start;
border-right: 2px solid #409eff;
}
.group-tag {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid #409eff;
border-radius: 6px;
padding: 4px 12px;
background: #ffffff;
}
.group-tag .group-name {
font-size: 14px;
color: #303133;
}
.group-tag .group-count {
font-size: 14px;
color: #606266;
}
.card-right {
flex: 0 0 280px;
display: flex;
padding: 0;
}
.venue-box,
.duration-box {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 8px;
}
.venue-box {
border-right: 2px solid #409eff;
}
.box-label {
font-size: 13px;
color: #606266;
margin-bottom: 16px;
margin-top: 4px;
text-align: center;
min-height: 20px;
}
.venue-box .el-input-number {
width: 140px;
}
.venue-box :deep(.el-input__wrapper) {
justify-content: center;
padding: 8px 15px;
}
.venue-box :deep(.el-input__inner) {
font-size: 24px;
font-weight: 600;
text-align: center;
}
.duration-value {
font-size: 28px;
font-weight: 600;
color: #303133;
}
.action-bar {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #ebeef5;
}
.action-bar .el-button {
min-width: 100px;
height: 36px;
font-size: 14px;
}
@media (max-width: 768px) {
.arrangement-card {
flex-direction: column;
}
.card-left {
flex: none;
width: 100%;
}
.card-middle {
border-right: none;
border-bottom: 2px solid #409eff;
}
.card-right {
flex: none;
width: 100%;
}
.venue-box {
border-right: 2px solid #409eff;
}
}
/* 赛事编排预览样式 */
.preview-container {
padding: 10px 0;
}
.preview-title {
font-size: 20px;
font-weight: 700;
text-align: center;
padding: 20px;
background: #ffd9b3;
color: #333;
margin-bottom: 15px;
}
.preview-table-wrapper {
overflow-x: auto;
border: 2px solid #333;
}
.preview-table {
width: 100%;
border-collapse: collapse;
background: #fff;
}
.preview-table .header-row {
background: #ccffcc;
}
.preview-table th {
padding: 12px 8px;
text-align: center;
font-weight: 700;
font-size: 14px;
border: 1px solid #333;
}
.preview-table td {
padding: 10px 8px;
text-align: center;
font-size: 14px;
border: 1px solid #333;
}
.data-row-even {
background: #f0fff0;
}
.data-row-odd {
background: #fff;
}
.time-col, .time-cell {
width: 10%;
background: #e8f4f8;
font-weight: 600;
}
.project-col, .project-cell {
width: 16%;
}
.group-col, .group-cell {
width: 13%;
}
.team-col, .team-cell {
width: 22%;
}
.athlete-col, .athlete-cell {
width: 14%;
}
.session-col, .session-cell {
width: 7%;
}
.venue-col, .venue-cell {
width: 8%;
}
</style>

View File

@ -37,8 +37,8 @@
</ProTable>
<!-- 弹窗 -->
<el-dialog title="参赛记录" v-model="isShowDialog" width="80%">
<ProTable ref="table" :request-api="eventrecordPageGet" :columns="columnsUser" :pagination="true"
<el-dialog title="参赛记录" v-model="isShowDialog" width="80%" @close="onDialogClose">
<ProTable v-if="isShowDialog" ref="table" :request-api="eventrecordPageGet" :columns="columnsUser" :pagination="true"
:tool-button="false" :initParams="initParamUser">
<template #index="scope">
{{ scope.$index + 1 }}
@ -89,7 +89,7 @@ const initParam = ref({
});
const selectedListIds = ref([]);
const initParamUser = ref({
appUserId: '',
appUserId: null,
});
const statusList = ['info', 'warning', 'success', 'danger'];
@ -214,11 +214,28 @@ const columnsUser = ref([
]);
const isShowDialog = ref(false);
const table = ref();
const onOpenMenu = (row) => {
console.log(row);
// 参数验证
if (!row || !row.id) {
ElMessage.warning('用户信息无效,无法查看参赛记录');
return;
}
if (typeof row.id !== 'number' || row.id <= 0) {
ElMessage.warning('用户ID无效,请刷新页面后重试');
return;
}
// 设置用户ID参数
initParamUser.value.appUserId = row.id;
isShowDialog.value = true;
// 下次打开对话框时强制刷新数据
initParamUser.value.refresh = Date.now();
};
const onBatchAudit = async () => {
@ -250,6 +267,15 @@ const lookBook = async (row) => {
};
// 对话框关闭时清理
const onDialogClose = () => {
// 清理参数,避免下次查询时使用旧参数
initParamUser.value = {
appUserId: null,
refresh: Date.now()
};
};
// 取消
const onCancel = () => {
isShowDialog.value = false;

View File

@ -33,8 +33,8 @@
</ProTable>
<!-- 弹窗 -->
<el-dialog title="参赛记录" v-model="isShowDialog" width="80%">
<ProTable ref="table" :request-api="eventrecordPageGet" :columns="columnsUser" :pagination="true"
<el-dialog title="参赛记录" v-model="isShowDialog" width="80%" @close="onDialogClose">
<ProTable v-if="isShowDialog" ref="table" :request-api="eventrecordPageGet" :columns="columnsUser" :pagination="true"
:tool-button="false" :initParams="initParamUser">
<template #index="scope">
{{ scope.$index + 1 }}
@ -105,7 +105,7 @@ const initParam = ref({
});
const selectedListIds = ref([]);
const initParamUser = ref({
appUserId: '',
sysUserId: null,
});
const statusList = ['info', 'warning', 'success', 'danger'];
@ -219,11 +219,28 @@ const columnsUser = ref([
]);
const isShowDialog = ref(false);
const table = ref();
const onOpenMenu = (row) => {
console.log(row);
initParamUser.value.appUserId = row.userId;
// 参数验证
if (!row || !row.userId) {
ElMessage.warning('用户信息无效,无法查看参赛记录');
return;
}
if (typeof row.userId !== 'number' || row.userId <= 0) {
ElMessage.warning('用户ID无效,请刷新页面后重试');
return;
}
// 设置用户ID参数 - 单位用户使用 sysUserId
initParamUser.value.sysUserId = row.userId;
isShowDialog.value = true;
// 下次打开对话框时强制刷新数据
initParamUser.value.refresh = Date.now();
};
const onCancelB = () => {
@ -275,6 +292,15 @@ const onSubmitAudit = async () => {
}
};
// 对话框关闭时清理
const onDialogClose = () => {
// 清理参数,避免下次查询时使用旧参数
initParamUser.value = {
sysUserId: null,
refresh: Date.now()
};
};
// 取消
const onCancel = () => {
isShowDialog.value = false;

View File

@ -174,7 +174,63 @@
</span>
<el-icon class="assign-card-close" @click.stop="toggleGroupSelection(group.id)"><Close /></el-icon>
</div>
<div class="assign-card-members">
<!-- 团队数量信息和添加团队按钮 -->
<div class="team-info-section" v-if="isTeamProject(group) && getGroupMaxTeams(group.id) > 1">
<div class="team-count-info">
已添加 {{ getGroupTeams(group.id).length }} / {{ getGroupMaxTeams(group.id) }} 队
</div>
<el-button
v-if="getGroupTeams(group.id).length < getGroupMaxTeams(group.id)"
type="primary"
size="small"
text
@click.stop="addTeam(group.id)"
>
<el-icon><Plus /></el-icon>
添加团队
</el-button>
</div>
<!-- 团队列表 -->
<div v-if="isTeamProject(group) && getGroupMaxTeams(group.id) > 1" class="team-section">
<div
v-for="team in getGroupTeams(group.id)"
:key="team.teamIndex"
class="team-card"
:class="{ 'team-card-active': activeTeamKey === `${group.id}_${team.teamIndex}` }"
@click.stop="setActiveTeam(group.id, team.teamIndex)"
>
<div class="team-card-header">
<span class="team-name">团队{{ getTeamNumber(team.teamIndex) }}</span>
<el-button
v-if="getGroupTeams(group.id).length > 1"
type="danger"
size="small"
text
@click.stop="removeTeam(group.id, team.teamIndex)"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div class="team-members">
<el-tag
v-for="m in team.members"
:key="m.userId ?? m.id"
closable
:type="getMemberValidationResult(m, group).isValid ? '' : 'danger'"
@close.stop="removeMemberFromTeam(group.id, team.teamIndex, m.userId ?? m.id)"
style="margin: 3px;"
>
{{ m.name }}
</el-tag>
<span v-if="!team.members.length" class="team-placeholder">选择此团队后添加队员</span>
</div>
</div>
</div>
<!-- 单团队模式(向后兼容) -->
<div v-else class="assign-card-members">
<el-tag
v-for="m in (groupMemberMap[group.id] ?? [])"
:key="m.userId ?? m.id"
@ -187,9 +243,10 @@
</el-tag>
<span v-if="!(groupMemberMap[group.id]?.length)" class="assign-card-placeholder">点击此卡片后,从右侧选择队员</span>
</div>
<!-- 队员验证错误提示 -->
<div v-if="hasValidationErrorsForGroup(group)" class="validation-error-section">
<div class="validation-error-item" v-for="m in (groupMemberMap[group.id] ?? [])" :key="m.userId ?? m.id">
<div class="validation-error-item" v-for="m in (isTeamProject(group) && getGroupMaxTeams(group.id) > 1 ? getGroupTeams(group.id).flatMap(t => t.members) : (groupMemberMap[group.id] ?? []))" :key="m.userId ?? m.id">
<el-alert
v-if="!getMemberValidationResult(m, group).isValid"
:title="getMemberValidationResult(m, group).message"
@ -232,7 +289,7 @@
:key="m.userId ?? m.id"
class="team-member-tag"
:class="{ 'team-member-tag-assigned': isMemberAssignedToActive(m.userId ?? m.id) }"
@click="assignMemberToActiveGroup(m)"
@click="assignMemberToActiveTeam(m)"
>
{{ m.name }}
</div>
@ -292,9 +349,37 @@
<div v-else-if="activeStep === 2">
<el-skeleton :rows="10" animated v-if="previewLoading" />
<template v-else>
<!-- 支付二维码显示区域 -->
<div v-if="paymentData" class="payment-qrcode-section">
<div class="qrcode-title">请使用微信扫码支付</div>
<!-- 银行转账信息显示区域 -->
<div v-if="paymentData && paymentMethod === 3" class="bank-transfer-section">
<div class="bank-transfer-title">银行卡转账</div>
<div class="bank-transfer-content">
<div class="info-item">
<text class="info-label">收款商户:</text>
<text class="info-value">上海跃动文化科技有限公司</text>
</div>
<div class="info-item">
<text class="info-label">收款账号:</text>
<text class="info-value">456498465198456261</text>
<el-button type="primary" size="small" @click="copyAccount">复制</el-button>
</div>
</div>
<div class="payment-amount">
<span class="amount-label">转账金额:</span>
<span class="amount-value">¥ {{ finalPaymentAmount }}</span>
</div>
<div class="payment-tip">
<el-alert title="提示" type="info" :closable="false">
请使用银行APP或网银进行转账,转账完成后系统会自动审核。
</el-alert>
</div>
<div class="payment-actions">
<el-button @click="cancelPayment">取消支付</el-button>
<el-button type="primary" @click="confirmBankTransfer">确认已转账</el-button>
</div>
</div>
<!-- 支付二维码显示区域(微信/支付宝) -->
<div v-else-if="paymentData" class="payment-qrcode-section">
<div class="qrcode-title">请使用{{ paymentMethod === 2 ? '支付宝' : '微信'}}扫码支付</div>
<div class="qrcode-wrapper">
<div v-if="paymentData.qrcodeUrl" class="qrcode-container">
<div ref="qrcodeRef"></div>
@ -421,7 +506,7 @@
<script setup>
import { ref, computed, nextTick, onUnmounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Close, Picture, Loading, CircleCheck, ArrowRight } from '@element-plus/icons-vue';
import { Close, Picture, Loading, CircleCheck, ArrowRight, Plus, Delete } from '@element-plus/icons-vue';
import ProTable from '/@/components/ProTable/index.vue';
import { formatDate } from '/@/utils/formatTime';
import {
@ -758,6 +843,7 @@ const allGroups = computed(() => {
projectCompletionCount: p.completionCount ?? 0,
parentProjectTypeName: p.parentProjectTypeName,
projectTypeName: p.projectTypeName,
projectType: p.projectType,
});
});
});
@ -860,6 +946,14 @@ const activeAssignGroupId = ref(null); // 当前正在分配队员的组别ID
const groupMemberMap = ref({}); // { groupId: [member, ...] }
const usedTeamId = ref(null); // 本次报名已使用的团队ID
// ==================== 多团队管理相关 ====================
// 每个组别的团队列表: { groupId: [{ teamIndex, members: [...] }, ...] }
const groupTeamsMap = ref({});
// 当前正在编辑的团队(组别ID + 团队索引)
const activeTeamKey = ref(null); // 格式: "groupId_teamIndex"
// 组别限报团队数量: { groupId: maxTeams }
const groupMaxTeamsMap = ref({});
const previewLoading = ref(false);
const previewData = ref(null);
const paymentMethod = ref(1);
@ -913,6 +1007,10 @@ const clearRegisterState = () => {
previewData.value = null;
paymentMethod.value = 1;
draftRegistrationId.value = null;
// 多团队管理相关
groupTeamsMap.value = {};
activeTeamKey.value = null;
groupMaxTeamsMap.value = {};
// 支付相关
paymentData.value = null;
polling.value = false;
@ -938,6 +1036,10 @@ const resetStep2AndBeyond = () => {
activeAssignGroupId.value = null;
groupMemberMap.value = {};
usedTeamId.value = null;
// 多团队管理相关
groupTeamsMap.value = {};
activeTeamKey.value = null;
groupMaxTeamsMap.value = {};
previewData.value = null;
previewLoading.value = false;
draftRegistrationId.value = null;
@ -1178,6 +1280,235 @@ const removeMemberFromGroup = (groupId, memberId) => {
}
};
// ==================== 多团队管理函数 ====================
// 获取阿拉伯数字表示
const getTeamNumber = (num) => {
return num.toString();
};
// 判断项目是否为团队类型
const isTeamProject = (group) => {
// 优先使用 projectType 判断
if (group?.projectType) {
// 项目类型枚举值:
// 1003: 个人速度
// 1004: 个人花样
// 1005: 团队速度
// 1006: 团队花样
return group.projectType === 1005 || group.projectType === 1006;
}
// 备用判断:使用 projectTypeName 或 parentProjectTypeName
if (group?.projectTypeName || group?.parentProjectTypeName) {
const typeName = group.projectTypeName || group.parentProjectTypeName;
return typeName && typeName.includes('团队');
}
// 最后的备用判断:使用 projectTeamSize
if (group?.projectTeamSize) {
return group.projectTeamSize > 1;
}
return false;
};
// 初始化组别团队数据
const initGroupTeams = (groupId) => {
if (!groupTeamsMap.value[groupId]) {
groupTeamsMap.value[groupId] = [
{
teamIndex: 1,
members: []
}
];
}
};
// 获取某个组别的团队列表
const getGroupTeams = (groupId) => {
initGroupTeams(groupId);
return groupTeamsMap.value[groupId];
};
// 获取某个组别的限报团队数量
const getGroupMaxTeams = (groupId) => {
return groupMaxTeamsMap.value[groupId] ?? 1;
};
// 添加团队
const addTeam = (groupId) => {
initGroupTeams(groupId);
const teams = groupTeamsMap.value[groupId];
const maxTeams = getGroupMaxTeams(groupId);
if (teams.length >= maxTeams) {
ElMessage.warning(`已达到该项目组别限报${maxTeams}队的上限`);
return;
}
const newTeamIndex = teams.length + 1;
teams.push({
teamIndex: newTeamIndex,
members: []
});
ElMessage.success(`已添加团队${getTeamNumber(newTeamIndex)}`);
};
// 删除团队
const removeTeam = async (groupId, teamIndex) => {
initGroupTeams(groupId);
const teams = groupTeamsMap.value[groupId];
if (teams.length <= 1) {
ElMessage.warning('至少需要保留一个团队');
return;
}
try {
await ElMessageBox.confirm(
`确定要删除团队${getTeamNumber(teamIndex)}吗?该团队的队员也会被移除。`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
);
const teamIdx = teams.findIndex(t => t.teamIndex === teamIndex);
if (teamIdx > -1) {
teams.splice(teamIdx, 1);
// 重新编号团队索引
teams.forEach((team, idx) => {
team.teamIndex = idx + 1;
});
ElMessage.success('团队删除成功');
}
} catch {
// 用户取消
}
};
// 获取当前激活的团队(如果启用了多团队模式)
const getActiveTeamMembers = (groupId) => {
// 向后兼容:如果没有启用多团队,使用原来的 groupMemberMap
if (!groupTeamsMap.value[groupId] || groupTeamsMap.value[groupId].length === 0) {
return groupMemberMap.value[groupId] ?? [];
}
// 多团队模式:找到当前激活的团队
if (!activeTeamKey.value) {
return [];
}
const [activeGroupId, activeTeamIdx] = activeTeamKey.value.split('_').map(Number);
if (activeGroupId !== groupId) {
return [];
}
const team = groupTeamsMap.value[groupId].find(t => t.teamIndex === activeTeamIdx);
return team?.members ?? [];
};
// 设置当前激活的团队
const setActiveTeam = (groupId, teamIndex) => {
activeTeamKey.value = `${groupId}_${teamIndex}`;
};
// 将成员分配到当前激活的团队
const assignMemberToActiveTeam = async (member) => {
const gId = activeAssignGroupId.value;
if (!gId) {
ElMessage.warning('请先点击左侧的组别卡片');
return;
}
initGroupTeams(gId);
// 获取当前组别信息
const group = selectedGroupItems.value.find(g => g.id === gId);
// 检查是否为团队项目且启用了多团队模式
const isMultiTeamMode = group && isTeamProject(group) &&
(groupTeamsMap.value[gId].length > 1 || getGroupMaxTeams(gId) > 1);
if (!isMultiTeamMode) {
// 单团队模式,使用原来的逻辑
await assignMemberToActiveGroup(member);
return;
}
// 多团队模式
if (!activeTeamKey.value) {
ElMessage.warning('请先选择要分配队员的团队');
return;
}
const [activeGroupId, activeTeamIdx] = activeTeamKey.value.split('_').map(Number);
if (activeGroupId !== gId) {
ElMessage.warning('请先选择对应组别的团队');
return;
}
const team = groupTeamsMap.value[gId].find(t => t.teamIndex === activeTeamIdx);
if (!team) {
return;
}
const memberId = member.userId ?? member.id;
// 已存在则不重复添加
if (team.members.some((m) => (m.userId ?? m.id) === memberId)) {
return;
}
// 检查队员是否来自已选择的团队
if (usedTeamId.value && member.teamId !== usedTeamId.value) {
ElMessage.warning('本次报名只能选择同一个团队的队员');
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();
};
// 从团队中移除成员
const removeMemberFromTeam = (groupId, teamIndex, memberId) => {
initGroupTeams(groupId);
const team = groupTeamsMap.value[groupId].find(t => t.teamIndex === teamIndex);
if (!team) return;
team.members = team.members.filter((m) => (m.userId ?? m.id) !== memberId);
// 检查是否还有队员
let hasMembers = false;
groupTeamsMap.value[groupId].forEach(t => {
if (t.members.length > 0) {
hasMembers = true;
}
});
if (!hasMembers) {
usedTeamId.value = null;
}
};
// 验证优惠券
const validateCoupon = async () => {
if (!couponCode.value?.trim()) {
@ -1262,13 +1593,17 @@ const fetchGroupTeamLimit = async () => {
const { succeeded, data } = await usereventregistrationGroupTeamLimitGet(currentEventId.value);
if (succeeded && data) {
const map = {};
const maxTeamsMap = {};
data.forEach(item => {
map[item.groupId] = {
maxTeams: item.maxTeams,
currentTeams: item.currentTeams
};
// 同时设置限报团队数量
maxTeamsMap[item.groupId] = item.maxTeams ?? 1;
});
groupTeamLimitMap.value = map;
groupMaxTeamsMap.value = maxTeamsMap;
}
} catch {
console.error('加载队伍限制配置失败');
@ -1311,6 +1646,16 @@ const handleStep1Next = async () => {
await fetchTeamList();
// 加载队伍限制配置
await fetchGroupTeamLimit();
// 初始化每个组别的团队数据
await nextTick();
selectedGroupItems.value.forEach(group => {
initGroupTeams(group.id);
// 默认激活第一个团队
if (group.id === activeAssignGroupId.value) {
setActiveTeam(group.id, 1);
}
});
};
const handleStep2Next = async () => {
@ -1326,8 +1671,30 @@ const handleStep2Next = async () => {
return;
}
// 检查每个组别是否至少分配了一个队员
const emptyGroups = selectedGroupItems.value.filter((g) => !(groupMemberMap.value[g.id]?.length));
// 检查每个组别是否至少分配了一个队员(考虑多团队模式)
const emptyGroups = [];
for (const g of selectedGroupItems.value) {
const gId = g.id;
let hasMembers = false;
// 检查是否为团队项目且启用了多团队模式
if (isTeamProject(g) && groupTeamsMap.value[gId] && groupTeamsMap.value[gId].length > 0) {
for (const team of groupTeamsMap.value[gId]) {
if (team.members.length > 0) {
hasMembers = true;
break;
}
}
} else {
// 单团队模式
hasMembers = !!(groupMemberMap.value[gId]?.length);
}
if (!hasMembers) {
emptyGroups.push(g);
}
}
if (emptyGroups.length > 0) {
ElMessage.warning(`请为所有组别分配队员,"${emptyGroups[0].groupName ?? emptyGroups[0].name}"尚未分配`);
return;
@ -1340,14 +1707,56 @@ const handleStep2Next = async () => {
return;
}
// 构造按组别分配的数据
const groupMembers = selectedGroupIds.value.map((gId) => ({
groupId: gId,
memberIds: (groupMemberMap.value[gId] ?? []).map((m) => m.userId ?? m.id),
}));
// 收集所有不重复的 memberIds
const allMemberIds = [...new Set(groupMembers.flatMap((g) => g.memberIds))];
// 构造按组别分配的数据(支持多团队)
const groupMembers = [];
const allTeams = []; // 用于收集所有团队数据
const allMemberIdsSet = new Set();
for (const gId of selectedGroupIds.value) {
// 找到当前组别信息
const group = selectedGroupItems.value.find(g => g.id === gId);
// 检查是否为团队项目且启用了多团队模式
const isMultiTeamMode = group && isTeamProject(group) && groupTeamsMap.value[gId] &&
(groupTeamsMap.value[gId].length > 1 || getGroupMaxTeams(gId) > 1);
if (isMultiTeamMode) {
// 多团队模式
const teams = groupTeamsMap.value[gId];
const groupTeamsData = [];
for (const team of teams) {
const memberIds = team.members.map(m => m.userId ?? m.id);
memberIds.forEach(id => allMemberIdsSet.add(id));
groupTeamsData.push({
teamIndex: team.teamIndex,
memberIds: memberIds
});
}
groupMembers.push({
groupId: gId,
memberIds: groupTeamsData.flatMap(t => t.memberIds), // 保持向后兼容
teams: groupTeamsData
});
allTeams.push(...groupTeamsData.map(t => ({
groupId: gId,
...t
})));
} else {
// 单团队模式(保持向后兼容)
const memberIds = (groupMemberMap.value[gId] ?? []).map((m) => m.userId ?? m.id);
memberIds.forEach(id => allMemberIdsSet.add(id));
groupMembers.push({
groupId: gId,
memberIds: memberIds
});
}
}
const allMemberIds = [...allMemberIdsSet];
submitDraftLoading.value = true;
try {
@ -1484,6 +1893,47 @@ const refreshPayment = async () => {
await handleCreateOrderAndPay();
};
// 复制银行账号
const copyAccount = () => {
navigator.clipboard.writeText('456498465198456261').then(() => {
ElMessage.success('账号已复制');
}).catch(() => {
// 如果clipboard API不可用,使用备用方法
const textArea = document.createElement('textarea');
textArea.value = '456498465198456261';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
ElMessage.success('账号已复制');
} catch (err) {
ElMessage.error('复制失败,请手动复制');
}
document.body.removeChild(textArea);
});
};
// 确认银行转账
const confirmBankTransfer = () => {
ElMessageBox.confirm(
'确认已完成转账吗?转账完成后订单将进入待审核状态。',
'确认转账',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
ElMessage.success('订单已提交,等待审核');
initParam.value.refresh = !initParam.value.refresh;
setTimeout(() => {
isShowRegisterDialog.value = false;
}, 1500);
}).catch(() => {
// 用户取消
});
};
const handleCreateOrderAndPay = async () => {
if (!draftRegistrationId.value) return;
createOrderLoading.value = true;
@ -1508,7 +1958,13 @@ const handleCreateOrderAndPay = async () => {
return;
}
// 付费项目,显示二维码
// 如果是银行转账,直接显示银行信息,不需要二维码和轮询
if (paymentMethod.value === 3) {
paymentData.value = data;
return;
}
// 微信/支付宝支付,显示二维码
paymentData.value = data;
await generateQRCode(data.qrcodeUrl);
@ -1740,6 +2196,74 @@ const handleCreateOrderAndPay = async () => {
flex-shrink: 0;
}
/* ---- 团队管理样式 ---- */
.team-info-section {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: #f0f7ff;
border-bottom: 1px solid #e6f4ff;
}
.team-count-info {
font-size: 13px;
color: #409eff;
font-weight: 500;
}
.team-section {
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.team-card {
border: 1px solid #e6f4ff;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
overflow: hidden;
&:hover {
border-color: #56b4f9;
}
&-active {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
}
.team-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: #ecf5ff;
border-bottom: 1px solid #d9ecff;
}
.team-name {
font-size: 13px;
font-weight: 500;
color: #409eff;
}
.team-members {
padding: 8px 10px;
min-height: 40px;
display: flex;
flex-wrap: wrap;
align-items: center;
}
.team-placeholder {
color: #c0c4cc;
font-size: 12px;
}
.team-members-panel {
flex: 1;
border: 1px solid #d0e4f7;
@ -1870,6 +2394,55 @@ const handleCreateOrderAndPay = async () => {
gap: 12px;
}
/* 银行转账信息显示区域样式 */
.bank-transfer-section {
text-align: center;
padding: 20px 0;
}
.bank-transfer-title {
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 24px;
}
.bank-transfer-content {
background: #f5f7fa;
padding: 24px;
border-radius: 8px;
margin-bottom: 24px;
}
.bank-transfer-content .info-item {
display: flex;
align-items: center;
margin-bottom: 16px;
justify-content: center;
flex-wrap: wrap;
}
.bank-transfer-content .info-item:last-child {
margin-bottom: 0;
}
.bank-transfer-content .info-label {
font-size: 14px;
color: #606266;
font-weight: 500;
}
.bank-transfer-content .info-value {
font-size: 16px;
color: #303133;
font-weight: 600;
margin: 0 8px;
}
.payment-tip {
margin: 24px 0;
}
.free-project-hint {
margin-top: 16px;
padding: 12px 16px;