赛事记录退款,赛事报名限制

This commit is contained in:
马梓彭 2026-04-02 08:42:24 +08:00
parent c9e80c3a87
commit 150c7f26b9
4 changed files with 957 additions and 17 deletions

View File

@ -176,4 +176,56 @@ export const usereventrecordMembersReplacePost = (registrationid, userid, data)
*/
export const usereventrecordCertificateGet = (registrationid) => {
return http.get(`/usereventrecord/certificate/${registrationid}`);
};
/**
* 7. 获取取消报名预览
* @param {number} registrationId - 报名记录ID (路径参数)
* @returns {Promise<{
* registrationId: number,
* eventName: string,
* teamName: string,
* chargeMode: number,
* chargeModeText: string,
* originalAmount: number,
* couponDiscountAmount: number,
* actualPaidAmount: number,
* daysBeforeMatch: number,
* refundRuleDescription: string,
* deductPercentage: number,
* deductAmount: number,
* estimatedRefundAmount: number,
* cancelableMembers: Array<{
* personId: number,
* name: string,
* canCancel: boolean,
* uncancelableReason: string,
* isLeader: boolean
* }>,
* isTeamProject: boolean,
* canPartialCancel: boolean,
* cancelReasons: Array<string>
* }>}
*/
export const usereventrecordCancelPreviewGet = (registrationId) => {
return http.get(`/usereventrecord/cancel/preview/${registrationId}`);
};
/**
* 8. 执行取消报名
* @param {Object} data - 取消报名数据
* @param {number} data.registrationId - 报名ID
* @param {number} data.cancelType - 取消类型:0-全队取消,1-部分取消
* @param {Array<number>} [data.personIds] - 取消的队员ID列表(部分取消时必填)
* @param {string} data.cancelReason - 取消原因
* @returns {Promise<{
* success: boolean,
* message: string,
* refundAmount: number,
* refundNo: string,
* cancelTime: string
* }>}
*/
export const usereventrecordCancelExecutePost = (data) => {
return http.post('/usereventrecord/cancel/execute', data);
};

View File

@ -213,4 +213,74 @@ export const usereventregistrationOrderCreatePost = (data) => {
*/
export const usereventregistrationCouponValidatePost = (data) => {
return http.post('/usereventregistration/coupon/validate', data);
};
/**
* 验证队员是否符合报名条件
* @param {Object} data - 验证信息
* @param {number} data.eventId - 赛事ID
* @param {number} data.memberId - 队员ID
* @param {number} data.groupId - 组别ID
* @returns {Promise<{
* isValid: boolean,
* ageValid: boolean,
* genderValid: boolean,
* registrationLimitValid: boolean,
* message: string
* }>}
*/
export const usereventregistrationMemberValidatePost = (data) => {
return http.post('/usereventregistration/member/validate', data);
};
/**
* 检查报名限制
* @param {Object} data - 检查信息
* @param {number} data.eventId - 赛事ID
* @param {Array<number>} data.memberIds - 队员ID列表
* @param {Array<number>} data.groupIds - 组别ID列表
* @returns {Promise<{
* hasLimit: boolean,
* limitDetails: Array<{
* memberId: number,
* memberName: string,
* limitType: string,
* limitMessage: string
* }>
* }>}
*/
export const usereventregistrationRegistrationLimitCheckPost = (data) => {
return http.post('/usereventregistration/registration-limit/check', data);
};
/**
* 获取组别可报队伍数量配置
* @param {number} eventId - 赛事ID
* @returns {Promise<{
* groupId: number,
* maxTeams: number,
* currentTeams: number
* }>}
*/
export const usereventregistrationGroupTeamLimitGet = (eventId) => {
return http.get(`/usereventregistration/group-team-limit/${eventId}`);
};
/**
* 检查项目完成人数
* @param {Object} data - 检查信息
* @param {number} data.eventId - 赛事ID
* @param {Array<{groupId: number, memberIds: Array<number>}>} data.groupMembers - 组别队员分配
* @returns {Promise<{
* hasError: boolean,
* errors: Array<{
* projectId: number,
* projectName: string,
* errorType: string,
* errorMessage: string
* }>
* }>}
*/
export const usereventregistrationProjectCompletionCheckPost = (data) => {
return http.post('/usereventregistration/project-completion/check', data);
};

View File

@ -25,11 +25,7 @@
<template #action="scope">
<el-space wrap>
<template v-if="!isCancelDisabledByRow(scope.row)">
<el-popconfirm title="确认取消报名吗?" @confirm="onCancelRegistration(scope.row.registrationId)">
<template #reference>
<el-button type="danger" size="small" text>取消报名</el-button>
</template>
</el-popconfirm>
<el-button type="danger" size="small" text @click="openCancelDialog(scope.row)">取消报名</el-button>
</template>
<template v-else>
<el-button type="danger" size="small" disabled text>取消报名</el-button>
@ -78,11 +74,7 @@
<div style="margin: 14px 0;">
<el-space wrap>
<el-popconfirm title="确认取消报名吗?" @confirm="onCancelRegistration(currentRow?.registrationId)" v-if="currentRow">
<template #reference>
<el-button type="danger" size="small" :disabled="disableCancel">取消报名</el-button>
</template>
</el-popconfirm>
<el-button type="danger" size="small" :disabled="disableCancel" @click="openCancelDialog(currentRow)" v-if="currentRow">取消报名</el-button>
<el-button type="primary" size="small" @click="openMaterialDialog(currentRow)" v-if="currentRow">上传素材</el-button>
<el-button type="primary" size="small" @click="openVoucherDialog(currentRow)" v-if="currentRow">上传凭证</el-button>
<el-button size="small" @click="openCertificateDialog" v-if="currentRow">查看证书</el-button>
@ -198,13 +190,147 @@
</span>
</template>
</el-dialog>
<!-- 取消报名对话框 -->
<el-dialog title="取消报名" v-model="isShowCancelDialog" width="900px" :close-on-click-modal="false" @closed="onCancelDialogClosed">
<el-skeleton :rows="10" animated v-if="cancelLoading" />
<template v-else>
<!-- 预览阶段 -->
<template v-if="!isCancelConfirmed">
<!-- 退款规则提示 -->
<el-alert title="注意" type="warning" :closable="false" show-icon style="margin-bottom: 16px;">
<template #default>
<div>
<p>• 退款规则:开赛前7天扣除金额5%,3-7天扣除金额10%,3天内扣除金额20%</p>
<p>• 退款路径:原路退款,请注意查收</p>
</div>
</template>
</el-alert>
<!-- 项目和队员列表 -->
<div class="project-list">
<!-- 示例项目1 - 个人项目 -->
<div class="project-card">
<div class="project-header">
<span class="project-tag">(个人/1) 30秒单摇-少年儿童组/18-20/男</span>
</div>
<div class="member-group">
<div
v-for="member in mockMembers1"
:key="member.id"
:class="['member-tag', { 'member-selected': selectedMembers.includes(member.id) }]"
@click="toggleMember(member.id)">
{{ member.name }}
<el-icon v-if="selectedMembers.includes(member.id)" class="remove-icon" @click.stop="removeMember(member.id)">
<Close />
</el-icon>
</div>
</div>
</div>
<!-- 示例项目2 - 团队项目 -->
<div class="project-card">
<div class="project-header">
<span class="project-tag">(团队/3) 交互绳-少年儿童组/10-24/女</span>
<el-icon
class="remove-team-icon"
:class="{ 'team-removed': teamRemoved }"
@click="toggleTeam()">
<Close v-if="!teamRemoved" /><CircleCheck v-else />
</el-icon>
</div>
<div class="member-group">
<div
v-for="member in mockMembers2"
:key="member.id"
:class="['member-tag', { 'team-removed': teamRemoved, 'disabled': true }]">
{{ member.name }}
</div>
</div>
<div class="member-group" v-if="mockMembers2.length > 2">
<div
v-for="member in mockMembers3"
:key="member.id"
:class="['member-tag', { 'team-removed': teamRemoved, 'disabled': true }]">
{{ member.name }}
</div>
</div>
</div>
</div>
<!-- 验证提示 -->
<div class="validation-hints">
<el-alert v-if="hasValidationError" type="error" :closable="false" show-icon style="margin-bottom: 8px;">
队员信息不符合组别要求
</el-alert>
<el-alert v-if="hasLimitError" type="warning" :closable="false" show-icon style="margin-bottom: 8px;">
此队员已达到报名限制
</el-alert>
<el-alert v-if="hasCountError" type="error" :closable="false" show-icon>
项目完成人数错误
</el-alert>
</div>
</template>
<!-- 确认阶段 -->
<template v-else>
<el-result
:icon="cancelResult?.success ? 'success' : 'error'"
:title="cancelResult?.success ? '取消报名成功' : '取消报名失败'"
:sub-title="cancelResult?.message">
<template #extra>
<div v-if="cancelResult?.success">
<p v-if="cancelResult?.refundAmount > 0">
退款金额:<span style="font-weight: bold; color: #1fae66;">¥{{ cancelResult?.refundAmount?.toFixed(2) }}</span>
</p>
<p v-if="cancelResult?.refundNo">
退款单号:{{ cancelResult?.refundNo }}
</p>
</div>
</template>
</el-result>
</template>
</template>
<!-- 底部操作栏 -->
<template #footer v-if="!isCancelConfirmed">
<div class="footer-bar">
<div class="footer-left">
<el-checkbox v-model="cancelAll">取消全部</el-checkbox>
</div>
<div class="footer-right">
<span class="refund-label">退款金额</span>
<span class="refund-amount">¥{{ totalRefundAmount.toFixed(2) }}</span>
<el-button @click="isShowCancelDialog = false" class="footer-btn">上一步</el-button>
<el-button
type="danger"
:loading="cancelSubmitting"
@click="submitCancel"
class="footer-btn primary">
确认取消
</el-button>
</div>
</div>
<div class="footer-note">
<el-link type="danger" :underline="false">取消报名后优惠券不予退还</el-link>
</div>
</template>
<template #footer v-else>
<span class="dialog-footer">
<el-button @click="closeCancelDialog" v-if="cancelResult?.success">关闭</el-button>
<el-button @click="resetCancelConfirm" v-else>重试</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { UploadFilled } from '@element-plus/icons-vue';
import { UploadFilled, ArrowRight, Close, CircleCheck } from '@element-plus/icons-vue';
import ProTable from '/@/components/ProTable/index.vue';
import {
usereventrecordListGet,
@ -213,6 +339,8 @@ import {
usereventrecordVoucherPost,
usereventrecordMembersGet,
usereventrecordCertificateGet,
usereventrecordCancelPreviewGet,
usereventrecordCancelExecutePost,
} from '/@/api/match/usereventrecord';
const tableRef = ref();
@ -287,14 +415,18 @@ const tagTextForCertStatus = (v) => certTagMap[normalizeToNumber(v)]?.text ?? (v
const disableCancel = computed(() => {
if (!currentRow.value) return true;
const st = normalizeToNumber(currentRow.value.status);
return st === 2 || st === 3 || st === 4;
const status = normalizeToNumber(currentRow.value.status);
const payStatus = normalizeToNumber(currentRow.value.payStatus);
// 只有报名状态为已支付(1) 且 支付状态为已支付(1),才能取消/退款
return !(status === 1 && payStatus === 1);
});
const isCancelDisabledByRow = (row) => {
if (!row) return true;
const st = normalizeToNumber(row.status);
return st === 2 || st === 3 || st === 4;
const status = normalizeToNumber(row.status);
const payStatus = normalizeToNumber(row.payStatus);
// 只有报名状态为已支付(1) 且 支付状态为已支付(1),才能取消/退款
return !(status === 1 && payStatus === 1);
};
const onOpenMenu = async (row) => {
@ -502,6 +634,232 @@ const openCertificateDialog = async () => {
certLoading.value = false;
}
};
// 取消报名
const isShowCancelDialog = ref(false);
const cancelLoading = ref(false);
const cancelSubmitting = ref(false);
const isCancelConfirmed = ref(false);
const cancelPreviewData = ref(null);
const cancelResult = ref(null);
// 示例数据(实际应从后端获取)
const mockMembers1 = ref([
{ id: 1, name: '崔一' },
{ id: 2, name: '崔一' }
]);
const mockMembers2 = ref([
{ id: 3, name: '崔一' },
{ id: 4, name: '崔三' }
]);
const mockMembers3 = ref([
{ id: 5, name: '崔一' },
{ id: 6, name: '崔三' }
]);
// 新的响应式变量
const selectedMembers = ref([]);
const teamRemoved = ref(false);
const cancelAll = ref(false);
const hasValidationError = ref(false);
const hasLimitError = ref(false);
const hasCountError = ref(false);
const isFreeProject = ref(false); // 控制是否为免费项目
// 每个项目的状态(根据项目标签判断类型)
const isProjectTeamType = (projectTag) => {
// 判断是否为团队项目:标签包含"团队"
return projectTag && projectTag.includes('团队');
};
const totalRefundAmount = computed(() => {
// 免费项目退款金额为0
if (isFreeProject.value) {
return 0;
}
// 示例:每个队员100元
const memberPrice = 100;
let total = 0;
if (cancelAll.value) {
// 取消全部:所有个人项目队员 + 整个团队
// 个人项目队员
total += mockMembers1.value.length * memberPrice;
// 团队项目(整个团队)
total += (mockMembers2.value.length + mockMembers3.value.length) * memberPrice;
} else {
// 个人项目:按选中的队员计算(只计算个人项目的队员,不包括团队项目)
// 个人项目队员ID:1, 2
const personalMemberIds = [1, 2];
const selectedPersonalMembers = selectedMembers.value.filter(id => personalMemberIds.includes(id));
total += selectedPersonalMembers.length * memberPrice;
// 团队项目:如果团队被取消,则计算整个团队
if (teamRemoved.value) {
total += (mockMembers2.value.length + mockMembers3.value.length) * memberPrice;
}
}
// 边界条件:确保金额不小于0
return Math.max(0, total);
});
const toggleMember = (memberId) => {
// 如果取消全部被选中,则不允许手动修改
if (cancelAll.value) {
return;
}
// 团队项目队员不允许手动选择
const teamMemberIds = [3, 4, 5, 6];
if (teamMemberIds.includes(memberId)) {
return;
}
const index = selectedMembers.value.indexOf(memberId);
if (index > -1) {
selectedMembers.value.splice(index, 1);
} else {
selectedMembers.value.push(memberId);
}
};
const removeMember = (memberId) => {
// 如果取消全部被选中,则不允许手动修改
if (cancelAll.value) {
return;
}
const index = selectedMembers.value.indexOf(memberId);
if (index > -1) {
selectedMembers.value.splice(index, 1);
}
};
const toggleTeam = () => {
teamRemoved.value = !teamRemoved.value;
if (teamRemoved.value) {
// 团队被移除时,清空该团队的队员选择
const teamMemberIds = [...mockMembers2.value, ...mockMembers3.value].map(m => m.id);
selectedMembers.value = selectedMembers.value.filter(id => !teamMemberIds.includes(id));
}
};
// 监听取消全部的变化
watch(cancelAll, (newVal) => {
if (newVal) {
// 取消全部:选中所有个人项目队员,并取消团队
selectedMembers.value = [...mockMembers1.value.map(m => m.id)];
teamRemoved.value = true;
} else {
// 取消取消全部:清空选择
selectedMembers.value = [];
teamRemoved.value = false;
}
});
const openCancelDialog = async (row) => {
if (!row?.registrationId) return;
isShowCancelDialog.value = true;
isCancelConfirmed.value = false;
cancelLoading.value = true;
cancelPreviewData.value = null;
cancelResult.value = null;
selectedMembers.value = [];
teamRemoved.value = false;
cancelAll.value = false;
hasValidationError.value = false;
hasLimitError.value = false;
hasCountError.value = false;
isFreeProject.value = false; // 默认为收费项目
try {
const { succeeded, data } = await usereventrecordCancelPreviewGet(row.registrationId);
if (succeeded) {
cancelPreviewData.value = data;
// 根据后端返回的数据设置是否为免费项目
isFreeProject.value = data?.chargeMode === 0 || data?.actualPaidAmount <= 0;
} else {
ElMessage.error(data ?? '获取取消报名预览失败');
isShowCancelDialog.value = false;
}
} catch (e) {
ElMessage.error('获取取消报名预览失败');
isShowCancelDialog.value = false;
} finally {
cancelLoading.value = false;
}
};
const submitCancel = async () => {
if (!cancelPreviewData.value?.registrationId) return;
cancelSubmitting.value = true;
try {
const payload = {
registrationId: cancelPreviewData.value.registrationId,
cancelType: cancelAll.value ? 0 : 1,
cancelReason: '个人原因'
};
if (!cancelAll.value) {
payload.personIds = selectedMembers.value;
}
const { succeeded, data } = await usereventrecordCancelExecutePost(payload);
isCancelConfirmed.value = true;
cancelResult.value = {
success: succeeded,
message: data?.message || (succeeded ? '操作成功' : '操作失败'),
refundAmount: data?.refundAmount || totalRefundAmount.value,
refundNo: data?.refundNo || null,
cancelTime: data?.cancelTime || new Date().toISOString()
};
if (succeeded) {
initParam.value.refresh = !initParam.value.refresh;
isShowDialog.value = false;
}
} catch (e) {
ElMessage.error('取消报名失败');
isCancelConfirmed.value = true;
cancelResult.value = {
success: false,
message: '取消报名失败',
refundAmount: 0,
refundNo: null,
cancelTime: new Date().toISOString()
};
} finally {
cancelSubmitting.value = false;
}
};
const resetCancelConfirm = () => {
isCancelConfirmed.value = false;
cancelResult.value = null;
};
const closeCancelDialog = () => {
isShowCancelDialog.value = false;
};
const onCancelDialogClosed = () => {
isCancelConfirmed.value = false;
cancelPreviewData.value = null;
cancelResult.value = null;
// 按顺序重置,避免 watch 触发问题
selectedMembers.value = [];
teamRemoved.value = false;
cancelAll.value = false;
hasValidationError.value = false;
hasLimitError.value = false;
hasCountError.value = false;
isFreeProject.value = false;
};
</script>
<style scoped lang="scss">
@ -514,4 +872,190 @@ const openCertificateDialog = async () => {
margin-top: 14px;
color: var(--el-text-color-primary);
}
// 项目列表
.project-list {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 16px;
}
.project-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
padding: 12px;
}
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.project-tag {
display: inline-block;
padding: 4px 12px;
background: linear-gradient(90deg, #409eff, #66b1ff);
color: white;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
}
.remove-team-icon {
cursor: pointer;
color: var(--el-text-color-secondary);
font-size: 20px;
transition: all 0.3s;
padding: 4px;
&:hover {
color: #f56c6c;
background: rgba(245, 108, 108, 0.1);
border-radius: 4px;
}
&.team-removed {
color: #1fae66;
}
}
.member-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.member-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
background: white;
border: 2px solid #409eff;
border-radius: 20px;
color: #409eff;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
position: relative;
&:hover {
background: rgba(64, 158, 255, 0.1);
}
&.member-selected {
background: rgba(64, 158, 255, 0.1);
}
&.team-removed {
opacity: 0.5;
cursor: not-allowed;
}
&.disabled {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
}
.remove-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
background: #f56c6c;
color: white;
border-radius: 50%;
font-size: 12px;
position: absolute;
right: -4px;
top: -4px;
}
// 验证提示
.validation-hints {
margin-bottom: 16px;
}
// 底部操作栏
.footer-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: linear-gradient(90deg, #409eff, #66b1ff);
border-radius: 4px;
color: white;
}
.footer-left {
display: flex;
align-items: center;
:deep(.el-checkbox) {
.el-checkbox__label {
color: white;
}
}
}
.footer-right {
display: flex;
align-items: center;
gap: 16px;
}
.refund-label {
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
}
.refund-amount {
font-size: 28px;
font-weight: 600;
color: white;
}
.footer-btn {
background: white;
color: #409eff;
border: none;
font-weight: 500;
&:hover {
background: rgba(255, 255, 255, 0.9);
color: #409eff;
}
&.primary {
background: #409eff;
color: white;
border: 1px solid white;
&:hover {
background: #66b1ff;
}
}
}
.footer-note {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 16px;
margin-top: 8px;
font-size: 12px;
:deep(.el-link) {
&.el-link--danger {
color: #f56c6c;
}
}
}
</style>

View File

@ -179,6 +179,7 @@
v-for="m in (groupMemberMap[group.id] ?? [])"
:key="m.userId ?? m.id"
closable
:type="getMemberValidationResult(m, group).isValid ? '' : 'danger'"
@close="removeMemberFromGroup(group.id, m.userId ?? m.id)"
style="margin: 3px;"
>
@ -186,6 +187,19 @@
</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">
<el-alert
v-if="!getMemberValidationResult(m, group).isValid"
:title="getMemberValidationResult(m, group).message"
type="error"
:closable="false"
size="small"
style="margin-top: 4px;"
/>
</div>
</div>
</div>
<el-empty v-if="!selectedGroupItems.length" description="请先在第一步选择报名组别" />
</div>
@ -226,6 +240,58 @@
</div>
</div>
</div>
<!-- 验证提示区域 -->
<div class="validation-notes-section">
<!-- 年龄性别不符合要求提示 -->
<el-alert
v-if="hasAnyValidationErrors()"
title="队员信息不符合组别要求"
type="error"
:closable="false"
style="margin-bottom: 12px;"
/>
<!-- 限报提示 -->
<el-alert
v-if="hasAnyRegistrationLimit()"
title="此队员已达到报名限制"
type="warning"
:closable="false"
style="margin-bottom: 12px;"
/>
<!-- 项目完成人数提示 -->
<el-alert
v-if="hasProjectCompletionCountError()"
title="项目完成人数错误"
type="error"
:closable="false"
style="margin-bottom: 12px;"
/>
<!-- 队伍数量限制提示 -->
<el-alert
v-if="showTeamLimitHint"
title="若管理后台配置一个组别只能限报2队,添加2队后,“添加”按钮隐藏"
type="info"
:closable="false"
style="margin-bottom: 12px;"
/>
</div>
<!-- 底部操作区域 -->
<div class="step2-footer">
<el-button @click="handlePrevious">上一步</el-button>
<el-button
type="primary"
@click="handleStep2Next"
:loading="submitDraftLoading"
:disabled="hasAnyValidationErrors() || hasProjectCompletionCountError() || hasAnyRegistrationLimit()"
>
预览
</el-button>
</div>
</div>
<!-- Step 3 -->
@ -376,6 +442,10 @@ import {
usereventregistrationPreviewGet,
usereventregistrationOrderCreatePost,
usereventregistrationCouponValidatePost,
usereventregistrationMemberValidatePost,
usereventregistrationRegistrationLimitCheckPost,
usereventregistrationGroupTeamLimitGet,
usereventregistrationProjectCompletionCheckPost,
} from '/@/api/match/usereventregistration';
import { teamPageGet, teamDetailGet } from '/@/api/match/unitUser';
import QRCode from 'qrcodejs2-fixes';
@ -646,6 +716,13 @@ const memberKeyword = ref('');
const memberLoading = ref(false);
const selectedMemberIds = ref([]);
// 队员验证相关
const memberValidationMap = ref({}); // { groupId_memberId: { isValid: boolean, ageValid: boolean, genderValid: boolean, message: string } }
const groupTeamLimitMap = ref({}); // { groupId: { maxTeams: number, currentTeams: number } }
const memberRegistrationLimitMap = ref({}); // { memberId: { isLimited: boolean, message: string } }
const projectCompletionCheckResult = ref(null); // 项目完成人数检查结果
const registrationLimitCheckResult = ref(null); // 报名限制检查结果
// Step2: 团队相关
const teamList = ref([]);
const teamListLoading = ref(false);
@ -699,6 +776,11 @@ const clearRegisterState = () => {
teamMemberLoading.value = false;
activeAssignGroupId.value = null;
groupMemberMap.value = {};
memberValidationMap.value = {};
groupTeamLimitMap.value = {};
memberRegistrationLimitMap.value = {};
projectCompletionCheckResult.value = null;
registrationLimitCheckResult.value = null;
previewLoading.value = false;
previewData.value = null;
paymentMethod.value = 1;
@ -814,8 +896,81 @@ const isMemberAssignedToActive = (memberId) => {
return list.some((m) => (m.userId ?? m.id) === memberId);
};
// ==================== 队员验证相关函数 ====================
// 调用后端API验证队员
const validateMemberForGroup = async (member, group) => {
if (!currentEventId.value) {
return { isValid: true, ageValid: true, genderValid: true, registrationLimitValid: true, message: '' };
}
try {
const { succeeded, data } = await usereventregistrationMemberValidatePost({
eventId: currentEventId.value,
memberId: member.userId ?? member.id,
groupId: group.id
});
if (succeeded && data) {
const key = `${group.id}_${member.userId ?? member.id}`;
memberValidationMap.value[key] = data;
return data;
}
} catch {
console.error('验证队员失败');
}
return { isValid: true, ageValid: true, genderValid: true, registrationLimitValid: true, message: '' };
};
// 获取队员验证结果
const getMemberValidationResult = (member, group) => {
const key = `${group.id}_${member.userId ?? member.id}`;
return memberValidationMap.value[key] || { isValid: true, ageValid: true, genderValid: true, registrationLimitValid: true, message: '' };
};
// ==================== 验证相关计算属性 ====================
// 检查某个组别是否有验证错误
const hasValidationErrorsForGroup = (group) => {
const members = groupMemberMap.value[group.id] || [];
return members.some(member => {
const result = getMemberValidationResult(member, group);
return !result.isValid;
});
};
// 检查是否有任何验证错误
const hasAnyValidationErrors = () => {
return selectedGroupItems.value.some(group => hasValidationErrorsForGroup(group));
};
// 检查是否有任何报名限制
const hasAnyRegistrationLimit = () => {
return registrationLimitCheckResult.value?.hasLimit ?? false;
};
// 检查是否有项目完成人数错误
const hasProjectCompletionCountError = () => {
return projectCompletionCheckResult.value?.hasError ?? false;
};
// 判断组别是否已达到队伍数量限制
const isGroupAtTeamLimit = (groupId) => {
const limit = groupTeamLimitMap.value[groupId];
if (!limit) return false;
return limit.currentTeams >= limit.maxTeams;
};
// 是否显示队伍数量限制提示
const showTeamLimitHint = computed(() => {
return selectedGroupItems.value.some(group => isGroupAtTeamLimit(group.id));
});
// ==================== 队员分配函数 ====================
// 将成员分配到当前激活的组别
const assignMemberToActiveGroup = (member) => {
const assignMemberToActiveGroup = async (member) => {
const gId = activeAssignGroupId.value;
if (!gId) {
ElMessage.warning('请先点击左侧的组别卡片');
@ -829,7 +984,22 @@ const assignMemberToActiveGroup = (member) => {
if (groupMemberMap.value[gId].some((m) => (m.userId ?? m.id) === memberId)) {
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;
}
}
groupMemberMap.value[gId].push({ ...member });
// 实时检查项目完成人数和报名限制
await checkProjectCompletion();
await checkRegistrationLimit();
};
// 从组别中移除成员
@ -915,6 +1085,64 @@ const handlePrevious = () => {
}
};
// 加载组别队伍限制配置
const fetchGroupTeamLimit = async () => {
if (!currentEventId.value) return;
try {
const { succeeded, data } = await usereventregistrationGroupTeamLimitGet(currentEventId.value);
if (succeeded && data) {
const map = {};
data.forEach(item => {
map[item.groupId] = {
maxTeams: item.maxTeams,
currentTeams: item.currentTeams
};
});
groupTeamLimitMap.value = map;
}
} catch {
console.error('加载队伍限制配置失败');
}
};
// 检查项目完成人数
const checkProjectCompletion = async () => {
if (!currentEventId.value) return;
const groupMembers = selectedGroupIds.value.map((gId) => ({
groupId: gId,
memberIds: (groupMemberMap.value[gId] ?? []).map((m) => m.userId ?? m.id),
}));
try {
const { succeeded, data } = await usereventregistrationProjectCompletionCheckPost({
eventId: currentEventId.value,
groupMembers
});
if (succeeded) {
projectCompletionCheckResult.value = data;
}
} catch {
console.error('检查项目完成人数失败');
}
};
// 检查报名限制
const checkRegistrationLimit = async () => {
if (!currentEventId.value) return;
const allMemberIds = [...new Set(Object.values(groupMemberMap.value).flat().map(m => m.userId ?? m.id))];
try {
const { succeeded, data } = await usereventregistrationRegistrationLimitCheckPost({
eventId: currentEventId.value,
memberIds: allMemberIds,
groupIds: selectedGroupIds.value
});
if (succeeded) {
registrationLimitCheckResult.value = data;
}
} catch {
console.error('检查报名限制失败');
}
};
const handleStep1Next = async () => {
if (!(selectedGroupIds.value?.length ?? 0)) {
ElMessage.warning('请先选择报名组别');
@ -929,11 +1157,31 @@ const handleStep1Next = async () => {
}
// 加载团队列表
await fetchTeamList();
// 加载队伍限制配置
await fetchGroupTeamLimit();
};
const handleStep2Next = async () => {
if (!currentEventId.value) return;
// 先检查项目完成人数和报名限制
await checkProjectCompletion();
await checkRegistrationLimit();
// 检查是否有项目完成人数错误
if (hasProjectCompletionCountError()) {
const errors = projectCompletionCheckResult.value?.errors ?? [];
ElMessage.error(errors[0]?.errorMessage ?? '项目完成人数有错误,请检查');
return;
}
// 检查是否有报名限制
if (hasAnyRegistrationLimit()) {
const details = registrationLimitCheckResult.value?.limitDetails ?? [];
ElMessage.warning(details[0]?.limitMessage ?? '有队员达到报名限制');
return;
}
// 检查每个组别是否至少分配了一个队员
const emptyGroups = selectedGroupItems.value.filter((g) => !(groupMemberMap.value[g.id]?.length));
if (emptyGroups.length > 0) {
@ -1487,6 +1735,32 @@ const handleCreateOrderAndPay = async () => {
color: #67c23a;
}
/* 阶段二验证提示区域 */
.validation-notes-section {
margin-top: 16px;
padding: 0 16px;
}
.validation-error-section {
margin-top: 8px;
}
.validation-error-item {
margin-bottom: 4px;
}
/* 阶段二底部操作区域 */
.step2-footer {
margin-top: 24px;
padding: 16px;
background: #f5f7fa;
border-top: 1px solid #e4e7ed;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
/* 支付二维码区域 */
.payment-qrcode-section {
text-align: center;