2033 lines
54 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="system-menu-container layout-padding">
<ProTable ref="tableRef" :request-api="usereventregistrationListGet" :columns="columns" :pagination="true"
:tool-button="false" :init-param="initParam" row-key="id">
<template #coverImage="scope">
<el-image v-if="scope.row?.coverImage" style="height: 60px; width: 80px"
:src="scope.row.coverImage" fit="cover" />
<span v-else>-</span>
</template>
<template #type="scope">
<el-tag :type="eventTypeTagType(scope.row)">{{ eventTypeText(scope.row) }}</el-tag>
</template>
<template #registrationType="scope">
{{ registrationTypeText(scope.row) }}
</template>
<template #chargeMode="scope">
{{ chargeModeText(scope.row) }}
</template>
<template #status="scope">
<el-tag :type="scope.row.status === 0 || scope.row.status === 'Enrolling' || scope.row.status === 'enrolling' ? 'success' : scope.row.status === 1 || scope.row.status === 'Ongoing' || scope.row.status === 'ongoing' ? 'warning' : 'danger'">
{{ scope.row.status === 0 || scope.row.status === 'Enrolling' || scope.row.status === 'enrolling' ? '报名中' : scope.row.status === 1 || scope.row.status === 'Ongoing' || scope.row.status === 'ongoing' ? '比赛中' : '已结束' }}
</el-tag>
</template>
<template #registrationStartTime="scope">
<div v-if="scope.row.registrationStartTime && scope.row.registrationEndTime">
<div>开始:{{ formatDateTime(scope.row.registrationStartTime) }}</div>
<div>截止:{{ formatDateTime(scope.row.registrationEndTime) }}</div>
</div>
<span v-else>-</span>
</template>
<template #matchStartTime="scope">
<div v-if="scope.row.matchStartTime && scope.row.matchEndTime">
<div>开始:{{ formatDateTime(scope.row.matchStartTime) }}</div>
<div>截止:{{ formatDateTime(scope.row.matchEndTime) }}</div>
</div>
<span v-else>-</span>
</template>
<template #action="scope">
<el-space wrap>
<el-button v-if="isEventEnrolling(scope.row)" size="small" type="primary" text
@click="openRegistrationDialog(scope.row)">去报名</el-button>
<el-button v-else size="small" text disabled>不可报名</el-button>
<el-button size="small" text type="primary" @click="openDetailDialog(scope.row)">查看详情</el-button>
</el-space>
</template>
</ProTable>
<!-- 赛事详情弹窗 -->
<el-dialog title="赛事详情" v-model="isShowDetailDialog" width="80%" style="min-height: 600px;" @closed="clearDetailState">
<el-skeleton :rows="12" animated v-if="detailLoading" />
<template v-else>
<div v-if="formData">
<Step1BasicInfo v-model:modelValue="formData.basicInfo" :readonly="true" />
<div style="height:50px;"></div>
<Step2Units v-model:modelValue="formData.units" :readonly="true" />
<div style="height:50px;"></div>
<Step3ProjectConfig v-model:modelValue="formData.projectConfig" :readonly="true" />
<div style="height:50px;"></div>
<Step4FeeConfig v-model:modelValue="formData.feeConfig" :readonly="true" />
<div style="height:100px;"></div>
</div>
<el-empty v-else description="暂无详情数据" />
</template>
<template #footer>
<span class="dialog-footer">
<el-button @click="isShowDetailDialog = false">关闭</el-button>
</span>
</template>
</el-dialog>
<!-- 赛事报名三步弹窗 -->
<el-dialog title="赛事报名" v-model="isShowRegisterDialog" width="900px" :close-on-click-modal="false"
@closed="clearRegisterState">
<!-- 规则提示 -->
<el-alert
title="报名规则"
type="info"
:closable="false"
style="margin-bottom: 20px;"
show-icon
>
<template #default>
<ul style="margin: 0; padding-left: 20px;">
<li>一次报名操作仅允许选择并提交<strong>单个团队</strong>内的运动员</li>
<li>该团队只能报名参与<strong>一个项目</strong></li>
</ul>
</template>
</el-alert>
<el-steps :active="activeStep" finish-status="success" align-center>
<el-step title="选择报名项目" />
<el-step title="分配队员" />
<el-step title="预览支付" />
</el-steps>
<div class="register-step-content">
<!-- Step 1 -->
<div v-if="activeStep === 0">
<el-empty v-if="!registerLoading && !(projects?.length ?? 0)" description="暂无报名项目" />
<el-skeleton v-else-if="registerLoading" :rows="8" animated />
<template v-else>
<!-- 项目卡片列表 -->
<div class="project-card" v-for="project in projects" :key="project.id">
<div class="project-card-left">
<div class="project-card-name">名称:{{ project.name }}</div>
<div class="project-card-type">类型:{{ project.parentProjectTypeName ?? project.projectTypeName ?? '' }}{{ project.parentProjectTypeName || project.projectTypeName ? '-' + project.name : '' }}</div>
<div class="project-card-count">完成人数:{{ project.completionCount ?? 0 }}</div>
</div>
<div class="project-card-right">
<div
v-for="g in (project.groups ?? [])"
:key="g.id"
class="group-tag"
:class="{ 'group-tag-selected': selectedGroupIds.includes(g.id) }"
@click="toggleGroupSelection(g.id)"
>
<span class="group-tag-name">{{ g.groupName ?? g.name }}</span>
<span class="group-tag-date" v-if="g.birthDateStart || g.birthDateEnd">{{ formatGroupDate(g) }}</span>
<span class="group-tag-gender">{{ formatGender(g.gender) }}</span>
</div>
<el-empty v-if="!(project.groups?.length)" description="暂无组别" :image-size="40" style="padding: 8px 0;" />
</div>
</div>
<!-- 已选报名项目汇总 -->
<div class="selected-summary" v-if="selectedGroupItems.length > 0">
<div class="selected-summary-header">报名项目</div>
<div class="selected-summary-list">
<el-tag
v-for="item in selectedGroupItems"
:key="item.id"
closable
@close="toggleGroupSelection(item.id)"
style="margin: 4px;"
size="large"
>
{{ item.groupName ?? item.name }}
<template v-if="item.dateRange"> · {{ item.dateRange }}</template>
· {{ formatGender(item.gender) }}
</el-tag>
</div>
</div>
</template>
</div>
<!-- Step 2 -->
<div v-else-if="activeStep === 1">
<div class="step2-layout">
<!-- 左侧:已选组别,每个组别可分配队员 -->
<div class="step2-left">
<div
class="assign-card"
v-for="(group, gIdx) in selectedGroupItems"
:key="group.id"
:class="{ 'assign-card-active': activeAssignGroupId === group.id }"
@click="activeAssignGroupId = group.id"
>
<div class="assign-card-header">
<span>
({{ group.parentProjectTypeName ?? group.projectTypeName ?? (group.projectTeamSize > 1 ? '团队' : '个人') }}/{{ group.projectCompletionCount ?? 0 }})
{{ group.projectName }}-{{ group.groupName ?? group.name }}/{{ formatGroupDate(group) }}/{{ formatGender(group.gender) }}
</span>
<el-icon class="assign-card-close" @click.stop="toggleGroupSelection(group.id)"><Close /></el-icon>
</div>
<div class="assign-card-members">
<el-tag
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;"
>
{{ m.name }}
</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>
<!-- 右侧:团队选择器 + 成员列表 -->
<div class="step2-right">
<div class="team-selector">
<el-select
v-model="selectedTeamId"
placeholder="请选择团队"
style="width: 100%;"
@change="onTeamChange"
:loading="teamListLoading"
filterable
>
<el-option
v-for="t in teamList"
:key="t.id"
:label="t.name"
:value="t.id"
/>
</el-select>
</div>
<div class="team-members-panel">
<el-skeleton v-if="teamMemberLoading" :rows="6" animated />
<el-empty v-else-if="!teamMembers.length" :description="selectedTeamId ? '该团队暂无运动员' : '请先选择团队'" :image-size="60" />
<template v-else>
<div
v-for="m in teamMembers"
:key="m.userId ?? m.id"
class="team-member-tag"
:class="{ 'team-member-tag-assigned': isMemberAssignedToActive(m.userId ?? m.id) }"
@click="assignMemberToActiveGroup(m)"
>
{{ m.name }}
</div>
</template>
</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 -->
<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 class="qrcode-wrapper">
<div v-if="paymentData.qrcodeUrl" class="qrcode-container">
<div ref="qrcodeRef"></div>
</div>
<div v-else class="qrcode-placeholder">
<el-icon :size="60" color="#909399"><Picture /></el-icon>
<div class="qrcode-tip">支付链接获取失败</div>
</div>
</div>
<div class="payment-amount">
<span class="amount-label">支付金额:</span>
<span class="amount-value">¥ {{ finalPaymentAmount }}</span>
</div>
<div class="payment-status" v-if="pollingStatus">
<el-icon class="is-loading"><Loading /></el-icon>
{{ pollingStatus }}
</div>
<div class="payment-actions">
<el-button @click="cancelPayment">取消支付</el-button>
<el-button type="primary" @click="refreshPayment" :loading="polling">刷新二维码</el-button>
</div>
</div>
<!-- 预览区域(未支付时) -->
<template v-else>
<!-- 根据原型图,将项目和队员整合在一个表格中 -->
<div class="detail-section-title">报名详情</div>
<el-table :data="previewData?.projects ?? []" border size="small" style="margin-top: 10px;">
<el-table-column prop="name" label="项目" align="center" width="180" />
<el-table-column prop="groupName" label="组别" align="center" width="150" />
<el-table-column prop="projectTypeName" label="类型" align="center" width="180" />
<el-table-column label="参赛队员" align="center" min-width="200">
<template #default="scope">
<el-tag size="small" style="margin: 2px;" v-for="member in scope.row?.members" :key="member.userId">
{{ member.name }}
</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 优惠券输入区域 -->
<div class="coupon-section">
<div class="detail-section-title">优惠券</div>
<div class="coupon-input-wrapper">
<el-input
v-model="couponCode"
placeholder="请输入优惠券兑换码"
:disabled="!!validatedCoupon"
style="flex: 1; margin-right: 10px;"
clearable
/>
<el-button
v-if="!validatedCoupon"
type="primary"
:loading="couponValidationLoading"
@click="validateCoupon"
>
使用优惠券
</el-button>
<el-button
v-else
type="danger"
@click="clearCoupon"
>
清除优惠券
</el-button>
</div>
<div v-if="validatedCoupon" class="coupon-result success">
<el-icon style="color: #67c23a;"><CircleCheck /></el-icon>
<span>{{ validatedCoupon.couponName }},优惠 -{{ validatedCoupon.discountAmount }} 元</span>
</div>
</div>
<!-- 底部金额和操作区域 -->
<div class="preview-footer-section">
<div class="total-amount-display">
<span class="total-label">合计金额:</span>
<span v-if="!validatedCoupon" class="total-value">
¥ {{ previewData?.totalAmount ?? 0 }}
</span>
<span v-else class="total-value">
<del class="original-price">¥ {{ previewData?.totalAmount ?? 0 }}</del>
<span class="discounted-price">¥ {{ validatedCoupon.finalAmount }}</span>
</span>
</div>
<div class="payment-actions">
<el-button @click="handlePrevious">上一步</el-button>
<div class="payment-method-wrapper" v-if="!isFreePreview">
<el-select v-model="paymentMethod" placeholder="请选择支付方式" style="width: 200px;">
<el-option :value="1" label="微信支付" />
<el-option :value="2" label="支付宝支付" />
<el-option :value="3" label="银行转账" />
</el-select>
<el-button type="primary" @click="handleCreateOrderAndPay" :loading="createOrderLoading" :disabled="!draftRegistrationId">
确认支付
</el-button>
</div>
<el-button v-else type="primary" @click="handleCreateOrderAndPay" :loading="createOrderLoading" :disabled="!draftRegistrationId">
确认报名
</el-button>
</div>
</div>
<!-- 免费项目提示 -->
<div v-if="isFreePreview" class="free-project-hint">
<el-tag type="success">免费项目</el-tag>
<span>无需支付,点击确认报名直接完成</span>
</div>
</template>
</template>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="isShowRegisterDialog = false">取消</el-button>
<el-button v-if="activeStep === 0" type="primary" @click="handleStep1Next"
:loading="registerLoading">下一步</el-button>
<el-button v-else-if="activeStep === 1" type="primary" @click="handleStep2Next"
:loading="submitDraftLoading">下一步</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, nextTick, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';
import { Close, Picture, Loading, CircleCheck, ArrowRight } from '@element-plus/icons-vue';
import ProTable from '/@/components/ProTable/index.vue';
import { formatDate } from '/@/utils/formatTime';
import {
usereventregistrationListGet,
usereventregistrationDetailGet,
usereventregistrationMembersSearchGet,
usereventregistrationSubmitPost,
usereventregistrationPreviewGet,
usereventregistrationOrderCreatePost,
usereventregistrationCouponValidatePost,
usereventregistrationMemberValidatePost,
usereventregistrationRegistrationLimitCheckPost,
usereventregistrationGroupTeamLimitGet,
usereventregistrationProjectCompletionCheckPost,
usereventregistrationOrderStatusGet,
} from '/@/api/match/usereventregistration';
import { teamPageGet, teamDetailGet } from '/@/api/match/unitUser';
import QRCode from 'qrcodejs2-fixes';
import Step1BasicInfo from '/@/views/match/manage/Steps/Step1BasicInfo.vue';
import Step2Units from '/@/views/match/manage/Steps/Step2Units.vue';
import Step3ProjectConfig from '/@/views/match/manage/Steps/Step3ProjectConfig.vue';
import Step4FeeConfig from '/@/views/match/manage/Steps/Step4FeeConfig.vue';
const tableRef = ref();
const initParam = ref({ refresh: true });
const columns = ref([
{ type: 'index', label: '序号', align: 'center', width: 70, fixed: 'left' },
{
prop: 'name',
label: '赛事名称',
search: { el: 'input', key: 'Keyword' },
fixed: 'left',
minWidth: 170,
},
{ prop: 'coverImage', label: '封面', align: 'center', width: 110 },
{ prop: 'type', label: '赛事类型', align: 'center', width: 120 },
{ prop: 'registrationType', label: '报名方式', align: 'center', width: 120 },
{ prop: 'chargeMode', label: '收费模式', align: 'center', width: 120 },
{ prop: 'status', label: '赛事状态', align: 'center', width: 120 },
{ prop: 'registrationStartTime', label: '报名起止时间', align: 'center', minWidth: 320 },
{ prop: 'matchStartTime', label: '比赛起止时间', align: 'center', minWidth: 320 },
{ prop: 'action', label: '操作', align: 'center', fixed: 'right', width: 210 },
]);
// -------------------- 字段展示(列表) --------------------
const safeString = (v) => (v === null || v === undefined ? '' : String(v));
const formatDateTime = (v) => {
if (!v) return '-';
try {
return formatDate(new Date(v), 'YYYY-mm-dd HH:MM:SS');
} catch {
return safeString(v);
}
};
const formatTimeRange = (start, end) => {
if (!start && !end) return '-';
if (start && end) return `${formatDateTime(start)} - ${formatDateTime(end)}`;
return formatDateTime(start || end);
};
const formatRange = (start, end) => {
if (!start && !end) return '-';
if (start && end) return `${formatDateTime(start)} 至 ${formatDateTime(end)}`;
return formatDateTime(start || end);
};
const eventTypeText = (row) => {
const t = row?.type ?? row?.eventType;
if (t === 'Online' || t === 0) return '线上';
if (t === 'Offline' || t === 1) return '线下';
return t ?? '-';
};
const eventTypeTagType = (row) => (eventTypeText(row) === '线上' ? 'primary' : 'info');
const registrationTypeText = (row) => {
const v = row?.registrationType ?? row?.registrationMode ?? row?.registrationTypeName;
if (!v) return '-';
switch (v) {
case 'OnlyIndividual':
case 0:
return '只允许个人报名';
case 'OnlyTeam':
case 1:
return '只允许团队报名';
case 'Both':
case 2:
return '两者都有';
default:
return v ?? '-';
}
};
const chargeModeText = (row) => {
const v = row?.chargeMode ?? row?.feeConfig?.chargeMode ?? row?.feeMode;
if (!v) return '-';
// 后端枚举值映射,确保与后端完全匹配
switch (v) {
case 'Free':
case 0:
case 'free':
return '免费';
case 'FixedPrice':
case 1:
return '定价(赛事)';
case 'UnitPriceEvent':
case 2:
return '单价(赛事)';
case 'UnitPriceProject':
case 3:
return '单价(项目)';
default:
return v ?? '-';
}
};
const eventStatusText = (status) => {
if (!status && status !== 0) return '-';
// 兼容:string 枚举 + 数字枚举
const s = status;
switch (s) {
case 'Enrolling':
case 'enrolling':
case 0:
return '报名中';
case 'Ongoing':
case 'ongoing':
case 1:
return '比赛中';
case 'Ended':
case 'ended':
case 2:
return '已结束';
default:
return safeString(status);
}
};
const eventStatusTagType = (status) => {
const text = eventStatusText(status);
if (text === '报名中') return 'warning';
if (text === '比赛中') return 'primary';
if (text === '已结束') return 'info';
return 'info';
};
const isEventEnrolling = (row) => {
// 用户端"去报名"只对报名中开放,同时需要用户通过商户认证
const s = row?.status ?? row?.eventStatus;
const isEnrolling = s === 'Enrolling' || s === 'enrolling' || s === 0 || s === '0';
const canRegister = row?.canRegister ?? row?.CanRegister ?? false;
return isEnrolling && canRegister;
};
// 展平项目和组别数据用于详情弹窗显示
const detailProjectsForDisplay = computed(() => {
const list = [];
(detailData.value?.projects ?? []).forEach((p) => {
(p.groups ?? []).forEach((g) => {
list.push({
projectName: p.name,
groupName: g.groupName,
fee: p.fee,
limitCount: p.limitCount,
completionCount: p.completionCount
});
});
// 如果没有组别,只显示项目
if (!(p.groups?.length)) {
list.push({
projectName: p.name,
groupName: '-',
fee: p.fee,
limitCount: p.limitCount,
completionCount: p.completionCount
});
}
});
return list;
});
// -------------------- 赛事详情 --------------------
const isShowDetailDialog = ref(false);
const detailLoading = ref(false);
const detailData = ref(null);
const formData = ref({
// 步骤1:基本信息
basicInfo: {
id: undefined,
eventName: '',
coverImage: '',
eventVideo: '',
eventType: '',
registrationType: '',
pointsRuleId: undefined,
registrationTime: [],
isNeedMaterial: false,
materialRemark: '',
materialUploadTime: [],
matchTime: [],
regionIds: [],
detailAddress: '',
eventIntroduction: '',
customConfigJson: '',
},
// 步骤2:参与单位
units: {
units: [],
},
// 步骤3:项目与限报配置
projectConfig: {
projects: [],
registrationLimit: {
individualProjectLimit: 1,
teamProjectLimit: 1,
},
},
// 步骤4:费用与账户配置
feeConfig: {
chargeMode: 'Free',
price: 0,
merchantId: undefined,
merchantName: '',
creditCode: '',
bankName: '',
accountNumber: '',
},
});
const clearDetailState = () => {
detailLoading.value = false;
detailData.value = null;
formData.value = {
basicInfo: {
id: undefined,
eventName: '',
coverImage: '',
eventVideo: '',
eventType: '',
registrationType: '',
pointsRuleId: undefined,
registrationTime: [],
isNeedMaterial: false,
materialRemark: '',
materialUploadTime: [],
matchTime: [],
regionIds: [],
detailAddress: '',
eventIntroduction: '',
customConfigJson: '',
},
units: { units: [] },
projectConfig: {
projects: [],
registrationLimit: { individualProjectLimit: 1, teamProjectLimit: 1 },
},
feeConfig: {
chargeMode: 'Free',
price: 0,
merchantId: undefined,
merchantName: '',
creditCode: '',
bankName: '',
accountNumber: '',
},
};
};
const openDetailDialog = async (row) => {
const eventId = row?.id;
if (!eventId) return;
isShowDetailDialog.value = true;
detailLoading.value = true;
detailData.value = null;
try {
const { succeeded, data } = await usereventregistrationDetailGet(eventId);
if (!succeeded) {
ElMessage.error(data ?? '赛事详情加载失败');
return;
}
detailData.value = data ?? null;
if (data) {
// 映射数据到 formData 结构
formData.value.basicInfo = {
...formData.value.basicInfo,
id: data.id,
eventName: data.name || data.eventName || '',
coverImage: data.coverImage || '',
eventVideo: data.eventVideo || '',
eventType: data.type || data.eventType || '',
registrationType: data.registrationType || data.registrationMode || '',
pointsRuleId: data.pointsRuleId,
registrationTime: [data.registrationStartTime, data.registrationEndTime],
isNeedMaterial: !!data.isNeedMaterial,
materialRemark: data.materialRemark || '',
materialUploadTime: [data.materialUploadStartTime, data.materialUploadEndTime],
matchTime: [data.matchStartTime, data.matchEndTime],
regionIds: [data.provinceId, data.cityId, data.districtId].filter((x) => x !== undefined && x !== null),
detailAddress: data.detailAddress || '',
eventIntroduction: data.eventIntroduction || '',
customConfigJson: data.customConfigJson || '',
};
formData.value.units = { units: data.units || [] };
formData.value.projectConfig = {
projects: data.projects || [],
registrationLimit: data.registrationLimit || { individualProjectLimit: 1, teamProjectLimit: 1 },
};
formData.value.feeConfig = { ...formData.value.feeConfig, ...(data.feeConfig || {}) };
}
} catch {
ElMessage.error('赛事详情加载失败');
} finally {
detailLoading.value = false;
}
};
// -------------------- 三步报名 --------------------
const isShowRegisterDialog = ref(false);
const activeStep = ref(0);
const registerLoading = ref(false);
const submitDraftLoading = ref(false);
const createOrderLoading = ref(false);
const projects = ref([]);
const selectedGroupIds = ref([]);
// 从所有 projects 的 groups 中提取所有组别的扁平列表
const allGroups = computed(() => {
const list = [];
(projects.value ?? []).forEach((p) => {
(p.groups ?? []).forEach((g) => {
list.push({
...g,
projectId: p.id,
projectName: p.name,
projectTeamSize: p.teamSize ?? 1,
projectCompletionCount: p.completionCount ?? 0,
parentProjectTypeName: p.parentProjectTypeName,
projectTypeName: p.projectTypeName,
});
});
});
return list;
});
// 已选中的组别详情列表
const selectedGroupItems = computed(() => {
return allGroups.value
.filter((g) => selectedGroupIds.value.includes(g.id))
.map((g) => ({
...g,
dateRange: formatGroupDate(g),
}));
});
// 已选中的项目ID列表(去重)
const selectedProjectIds = computed(() => {
const ids = new Set();
selectedGroupItems.value.forEach((g) => {
if (g.projectId) {
ids.add(g.projectId);
}
});
return Array.from(ids);
});
// 判断是否为免费项目预览
const isFreePreview = computed(() => {
if (!previewData.value) {
return false;
}
const baseAmount = previewData.value?.totalAmount ?? 0;
const finalAmount = validatedCoupon.value?.finalAmount ?? baseAmount;
return finalAmount <= 0;
});
// 切换组别选中状态
const toggleGroupSelection = (id) => {
const idx = selectedGroupIds.value.indexOf(id);
if (idx >= 0) {
selectedGroupIds.value.splice(idx, 1);
} else {
// 检查是否已经选择了其他项目的组别
const groupToAdd = allGroups.value.find(g => g.id === id);
if (groupToAdd && selectedGroupIds.value.length > 0) {
const existingProjectId = allGroups.value.find(g => g.id === selectedGroupIds.value[0])?.projectId;
if (groupToAdd.projectId !== existingProjectId) {
ElMessage.warning('一次报名只能选择一个项目,请先取消已选择的项目');
return;
}
}
selectedGroupIds.value.push(id);
}
};
// 格式化性别限制
const formatGender = (gender) => {
const v = Number(gender);
if (v === 0) return '男';
if (v === 1) return '女';
return '不限';
};
// 格式化组别的年龄段(出生日期范围)
const formatGroupDate = (item) => {
const start = item?.birthDateStart;
const end = item?.birthDateEnd;
if (!start && !end) return '';
const fmt = (v) => {
if (!v) return '';
try { return formatDate(new Date(v), 'YYYY-mm-dd'); } catch { return String(v); }
};
if (start && end) return `${fmt(start)} ~ ${fmt(end)}`;
return fmt(start || end);
};
const members = ref([]);
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);
const selectedTeamId = ref(null);
const teamMembers = ref([]);
const teamMemberLoading = ref(false);
const activeAssignGroupId = ref(null); // 当前正在分配队员的组别ID
const groupMemberMap = ref({}); // { groupId: [member, ...] }
const usedTeamId = ref(null); // 本次报名已使用的团队ID
const previewLoading = ref(false);
const previewData = ref(null);
const paymentMethod = ref(1);
const draftRegistrationId = ref(null);
// 支付相关
const paymentData = ref(null);
const qrcodeRef = ref(null);
const polling = ref(false);
const pollingStatus = ref('');
let pollingTimer = null;
let qrcodeInstance = null;
// 计算最终支付金额
const finalPaymentAmount = computed(() => {
if (!previewData.value) return 0;
const baseAmount = previewData.value.totalAmount ?? 0;
return validatedCoupon.value?.finalAmount ?? baseAmount;
});
// 优惠券相关
const couponCode = ref('');
const couponValidationLoading = ref(false);
const validatedCoupon = ref(null);
const showCouponSection = ref(true);
const clearRegisterState = () => {
activeStep.value = 0;
registerLoading.value = false;
submitDraftLoading.value = false;
createOrderLoading.value = false;
currentEventId.value = null;
projects.value = [];
selectedGroupIds.value = [];
members.value = [];
memberKeyword.value = '';
memberLoading.value = false;
selectedMemberIds.value = [];
teamList.value = [];
selectedTeamId.value = null;
teamMembers.value = [];
teamMemberLoading.value = false;
activeAssignGroupId.value = null;
groupMemberMap.value = {};
usedTeamId.value = null;
memberValidationMap.value = {};
groupTeamLimitMap.value = {};
memberRegistrationLimitMap.value = {};
projectCompletionCheckResult.value = null;
registrationLimitCheckResult.value = null;
previewLoading.value = false;
previewData.value = null;
paymentMethod.value = 1;
draftRegistrationId.value = null;
// 支付相关
paymentData.value = null;
polling.value = false;
pollingStatus.value = '';
clearQRCode();
stopPolling();
// 优惠券相关
couponCode.value = '';
couponValidationLoading.value = false;
validatedCoupon.value = null;
showCouponSection.value = true;
};
const currentEventId = ref(null);
const resetStep2AndBeyond = () => {
members.value = [];
selectedMemberIds.value = [];
memberKeyword.value = '';
selectedTeamId.value = null;
teamMembers.value = [];
teamMemberLoading.value = false;
activeAssignGroupId.value = null;
groupMemberMap.value = {};
usedTeamId.value = null;
previewData.value = null;
previewLoading.value = false;
draftRegistrationId.value = null;
};
const openRegistrationDialog = async (row) => {
const eventId = row?.id;
if (!eventId) return;
clearRegisterState();
currentEventId.value = eventId;
isShowRegisterDialog.value = true;
activeStep.value = 0;
registerLoading.value = true;
try {
const { succeeded, data } = await usereventregistrationDetailGet(eventId);
if (!succeeded) {
ElMessage.error(data ?? '项目加载失败');
return;
}
// 保存完整的详情数据,用于项目类型显示等
detailData.value = data;
projects.value = Array.isArray(data?.projects) ? data.projects : [];
} catch {
ElMessage.error('项目加载失败');
} finally {
registerLoading.value = false;
}
};
const isMemberSelectable = (row) => row?.isEligible !== false;
const onMemberSelectionChange = (rows) => {
const list = (rows ?? []).map((r) => r?.userId ?? r?.id).filter((id) => id !== undefined && id !== null);
selectedMemberIds.value = list;
};
// 加载团队列表
const fetchTeamList = async () => {
teamListLoading.value = true;
try {
const { succeeded, data } = await teamPageGet({ PageNo: 1, PageSize: 200 });
if (succeeded && data?.rows) {
teamList.value = data.rows;
}
} catch {
ElMessage.error('团队列表加载失败');
} finally {
teamListLoading.value = false;
}
};
// 切换团队时加载成员
const onTeamChange = async (teamId) => {
teamMembers.value = [];
if (!teamId) return;
// 检查是否已经分配了其他团队的队员
if (usedTeamId.value && usedTeamId.value !== teamId) {
ElMessage.warning('本次报名已选择了其他团队的队员,请先移除已分配的队员');
// 恢复到之前选择的团队
selectedTeamId.value = usedTeamId.value;
return;
}
teamMemberLoading.value = true;
try {
const { succeeded, data } = await teamDetailGet(teamId);
if (succeeded && data?.members) {
// 只取 personType 为 1 的运动员
teamMembers.value = data.members
.filter((m) => m.personType === 1)
.map((m) => ({
userId: m.userId ?? m.id,
name: m.name,
mobile: m.mobile ?? '',
gender: m.gender ?? '',
role: m.role ?? '',
teamId: teamId, // 添加团队ID
}));
}
} catch {
ElMessage.error('团队成员加载失败');
} finally {
teamMemberLoading.value = false;
}
};
// 判断某成员是否已分配到当前激活的组别
const isMemberAssignedToActive = (memberId) => {
if (!activeAssignGroupId.value) return false;
const list = groupMemberMap.value[activeAssignGroupId.value] ?? [];
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 = async (member) => {
const gId = activeAssignGroupId.value;
if (!gId) {
ElMessage.warning('请先点击左侧的组别卡片');
return;
}
const memberId = member.userId ?? member.id;
if (!groupMemberMap.value[gId]) {
groupMemberMap.value[gId] = [];
}
// 已存在则不重复添加
if (groupMemberMap.value[gId].some((m) => (m.userId ?? m.id) === memberId)) {
return;
}
// 检查队员是否来自已选择的团队
if (usedTeamId.value && member.teamId !== usedTeamId.value) {
ElMessage.warning('本次报名只能选择同一个团队的队员');
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 checkProjectCompletion();
await checkRegistrationLimit();
};
// 从组别中移除成员
const removeMemberFromGroup = (groupId, memberId) => {
if (!groupMemberMap.value[groupId]) return;
groupMemberMap.value[groupId] = groupMemberMap.value[groupId].filter((m) => (m.userId ?? m.id) !== memberId);
// 检查是否还有队员,如果没有则重置已使用的团队
let hasMembers = false;
Object.values(groupMemberMap.value).forEach(members => {
if (members.length > 0) {
hasMembers = true;
}
});
if (!hasMembers) {
usedTeamId.value = null;
}
};
// 验证优惠券
const validateCoupon = async () => {
if (!couponCode.value?.trim()) {
ElMessage.warning('请输入优惠券码');
return;
}
if (!currentEventId.value) return;
if (!previewData.value?.totalAmount) {
ElMessage.warning('请先完成报名信息填写');
return;
}
couponValidationLoading.value = true;
try {
const { succeeded, data } = await usereventregistrationCouponValidatePost({
couponCode: couponCode.value.trim(),
eventId: currentEventId.value,
originalAmount: previewData.value.totalAmount
});
if (!succeeded) {
ElMessage.error(data?.invalidReason ?? '优惠券验证失败');
validatedCoupon.value = null;
return;
}
if (!data.isValid) {
ElMessage.error(data.invalidReason ?? '优惠券无效');
validatedCoupon.value = null;
return;
}
validatedCoupon.value = data;
ElMessage.success('优惠券验证成功');
} catch {
ElMessage.error('优惠券验证失败');
validatedCoupon.value = null;
} finally {
couponValidationLoading.value = false;
}
};
// 清除优惠券
const clearCoupon = () => {
couponCode.value = '';
validatedCoupon.value = null;
};
const fetchMembers = async () => {
if (!currentEventId.value) return;
memberLoading.value = true;
try {
const { succeeded, data } = await usereventregistrationMembersSearchGet({ keyword: memberKeyword.value });
if (!succeeded) {
ElMessage.error(data ?? '队员加载失败');
return;
}
members.value = Array.isArray(data) ? data : [];
} catch {
ElMessage.error('队员加载失败');
} finally {
memberLoading.value = false;
}
};
const handlePrevious = () => {
if (activeStep.value === 2) {
activeStep.value = 1;
previewData.value = null;
previewLoading.value = false;
draftRegistrationId.value = null;
return;
}
if (activeStep.value === 1) {
activeStep.value = 0;
resetStep2AndBeyond();
return;
}
};
// 加载组别队伍限制配置
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('请先选择报名组别');
return;
}
activeStep.value = 1;
resetStep2AndBeyond();
// 默认激活第一个组别
if (selectedGroupItems.value.length > 0) {
activeAssignGroupId.value = selectedGroupItems.value[0].id;
}
// 加载团队列表
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) {
ElMessage.warning(`请为所有组别分配队员,"${emptyGroups[0].groupName ?? emptyGroups[0].name}"尚未分配`);
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))];
submitDraftLoading.value = true;
try {
const payload = {
eventId: currentEventId.value,
projectIds: selectedProjectIds.value,
groupIds: selectedGroupIds.value,
groupMembers,
memberIds: allMemberIds,
isDraft: true,
};
const { succeeded, data } = await usereventregistrationSubmitPost(payload);
if (!succeeded) {
ElMessage.error(data ?? '报名提交失败');
return;
}
draftRegistrationId.value = data;
activeStep.value = 2;
previewLoading.value = true;
try {
const { succeeded: pOk, data: pData } = await usereventregistrationPreviewGet(data);
if (!pOk) {
ElMessage.error(pData ?? '预览支付加载失败');
return;
}
previewData.value = pData ?? null;
} finally {
previewLoading.value = false;
}
} catch {
ElMessage.error('报名提交失败');
} finally {
submitDraftLoading.value = false;
}
};
// 生成二维码图片
const generateQRCode = async (qrcodeUrl) => {
try {
await nextTick();
// 清理之前的二维码
if (qrcodeInstance) {
qrcodeInstance.clear();
qrcodeInstance = null;
}
if (!qrcodeRef.value) {
console.error('二维码容器未找到');
return;
}
// 清空容器
qrcodeRef.value.innerHTML = '';
// 创建新的二维码
qrcodeInstance = new QRCode(qrcodeRef.value, {
text: qrcodeUrl,
width: 220,
height: 220,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.H
});
} catch (error) {
console.error('生成二维码失败:', error);
}
};
// 清理二维码
const clearQRCode = () => {
if (qrcodeInstance) {
qrcodeInstance.clear();
qrcodeInstance = null;
}
};
// 组件卸载时清理
onUnmounted(() => {
clearQRCode();
stopPolling();
});
// 开始支付状态轮询
const startPolling = (orderNo) => {
polling.value = true;
pollingStatus.value = '等待支付...';
pollingTimer = setInterval(async () => {
try {
const { succeeded, data } = await usereventregistrationOrderStatusGet(orderNo);
if (succeeded && data === 1) {
stopPolling();
onPaymentSuccess();
}
} catch (error) {
console.error('查询支付状态失败:', error);
}
}, 3000);
};
// 停止轮询
const stopPolling = () => {
polling.value = false;
pollingStatus.value = '';
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = null;
}
};
// 支付成功处理
const onPaymentSuccess = () => {
pollingStatus.value = '支付成功!';
ElMessage.success('支付成功!报名已完成');
initParam.value.refresh = !initParam.value.refresh;
setTimeout(() => {
isShowRegisterDialog.value = false;
}, 2000);
};
// 取消支付
const cancelPayment = () => {
stopPolling();
clearQRCode();
paymentData.value = null;
};
// 刷新二维码(重新创建订单)
const refreshPayment = async () => {
stopPolling();
await handleCreateOrderAndPay();
};
const handleCreateOrderAndPay = async () => {
if (!draftRegistrationId.value) return;
createOrderLoading.value = true;
try {
const payload = {
registrationId: draftRegistrationId.value,
paymentMethod: paymentMethod.value,
// 如果使用了优惠券,传递优惠券ID
couponId: validatedCoupon.value?.couponId
};
const { succeeded, data } = await usereventregistrationOrderCreatePost(payload);
if (!succeeded) {
ElMessage.error(data ?? '创建支付订单失败');
return;
}
// 检查是否为免费项目
if (data?.isFree) {
ElMessage.success('报名成功!');
initParam.value.refresh = !initParam.value.refresh;
isShowRegisterDialog.value = false;
return;
}
// 付费项目,显示二维码
paymentData.value = data;
await generateQRCode(data.qrcodeUrl);
// 开始轮询支付状态
if (data.orderNo) {
startPolling(data.orderNo);
}
} catch {
ElMessage.error('创建支付订单失败');
} finally {
createOrderLoading.value = false;
}
};
</script>
<style scoped lang="scss">
.dialog-footer {
margin-top: 80px;
}
.register-step-content {
min-height: 380px;
padding: 20px 0;
}
.search-row {
display: flex;
align-items: center;
gap: 10px;
margin: 6px 0 12px;
}
.detail-section-title {
font-weight: 600;
margin-top: 14px;
margin-bottom: 8px;
color: var(--el-text-color-primary);
}
.payment-panel {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 16px;
margin-top: 16px;
padding: 14px;
border: 1px solid var(--el-border-color);
border-radius: 6px;
}
.payment-panel-title {
font-weight: 600;
color: var(--el-text-color-primary);
}
/* ---- 项目卡片 ---- */
.project-card {
display: flex;
border: 1px solid #d0e4f7;
border-radius: 8px;
overflow: hidden;
margin-bottom: 16px;
&-left {
flex: 0 0 150px;
background: linear-gradient(135deg, #56b4f9, #3d9be9);
color: #fff;
padding: 14px 12px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
font-size: 13px;
}
&-name {
font-weight: 600;
font-size: 14px;
}
&-right {
flex: 1;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
align-content: flex-start;
padding: 12px;
gap: 10px;
}
}
.group-tag {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border: 1px solid #d0e4f7;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: #333;
transition: all 0.2s;
user-select: none;
&:hover {
border-color: #56b4f9;
background: #f0f7ff;
}
&-selected {
border-color: #409eff;
background: #ecf5ff;
color: #409eff;
box-shadow: 0 0 0 1px #409eff;
}
&-name {
font-weight: 500;
}
&-date, &-gender {
color: #888;
font-size: 12px;
}
&-selected &-date,
&-selected &-gender {
color: #409eff;
}
}
/* ---- 已选报名项目汇总 ---- */
.selected-summary {
margin-top: 18px;
border: 1px solid #d0e4f7;
border-radius: 8px;
overflow: hidden;
&-header {
background: linear-gradient(135deg, #56b4f9, #3d9be9);
color: #fff;
font-weight: 600;
text-align: center;
padding: 10px 0;
font-size: 15px;
}
&-list {
padding: 14px;
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 50px;
}
}
/* ---- Step2 分配队员 ---- */
.step2-layout {
display: flex;
gap: 16px;
min-height: 400px;
}
.step2-left {
flex: 1;
min-width: 0;
overflow-y: auto;
max-height: 520px;
}
.step2-right {
flex: 0 0 280px;
display: flex;
flex-direction: column;
gap: 10px;
}
.assign-card {
border: 1px solid #d0e4f7;
border-radius: 8px;
margin-bottom: 14px;
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);
}
&-header {
background: linear-gradient(135deg, #56b4f9, #3d9be9);
color: #fff;
padding: 10px 14px;
font-size: 13px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: space-between;
}
&-close {
cursor: pointer;
font-size: 16px;
opacity: 0.8;
&:hover {
opacity: 1;
}
}
&-members {
padding: 10px 14px;
min-height: 46px;
display: flex;
flex-wrap: wrap;
align-items: center;
}
&-placeholder {
color: #c0c4cc;
font-size: 13px;
}
}
.team-selector {
flex-shrink: 0;
}
.team-members-panel {
flex: 1;
border: 1px solid #d0e4f7;
border-radius: 8px;
padding: 12px;
overflow-y: auto;
max-height: 460px;
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 8px;
}
.team-member-tag {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 100px;
padding: 8px 16px;
border: 1px solid #d0e4f7;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: #333;
transition: all 0.2s;
user-select: none;
&:hover {
border-color: #56b4f9;
background: #f0f7ff;
}
&-assigned {
border-color: #409eff;
background: #ecf5ff;
color: #409eff;
}
}
/* 优惠券相关样式 */
.coupon-section {
margin-top: 20px;
padding: 16px;
border: 1px solid #d0e4f7;
border-radius: 8px;
}
.coupon-input-wrapper {
display: flex;
gap: 10px;
align-items: center;
margin-top: 10px;
}
.coupon-info {
margin-top: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.coupon-discount {
color: #f56c6c;
font-weight: 600;
}
.coupon-preview-section {
margin-top: 14px;
padding: 12px;
background: #f0f9eb;
border: 1px solid #e1f3d8;
border-radius: 6px;
display: flex;
align-items: center;
}
.preview-footer-section {
margin-top: 24px;
padding: 20px;
background: linear-gradient(90deg, #409eff, #66b1ff);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.total-amount-display {
display: flex;
align-items: baseline;
gap: 10px;
color: #fff;
}
.total-label {
font-size: 16px;
font-weight: 500;
}
.total-value {
font-size: 32px;
font-weight: bold;
display: flex;
align-items: baseline;
gap: 12px;
}
.original-price {
font-size: 20px;
color: rgba(255, 255, 255, 0.7);
text-decoration: line-through;
}
.discounted-price {
color: #fff;
}
.payment-actions {
display: flex;
align-items: center;
gap: 12px;
}
.payment-method-wrapper {
display: flex;
align-items: center;
gap: 12px;
}
.free-project-hint {
margin-top: 16px;
padding: 12px 16px;
background: #f0f9eb;
border: 1px dashed #67c23a;
border-radius: 6px;
display: flex;
align-items: center;
gap: 10px;
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;
padding: 20px;
}
.qrcode-title {
font-size: 20px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
}
.qrcode-wrapper {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.qrcode-container {
width: 260px;
height: 260px;
border: 2px solid #e0e0e0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
.qrcode-image {
width: 240px;
height: 240px;
}
.qrcode-placeholder {
width: 260px;
height: 260px;
border: 2px dashed #e0e0e0;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fafafa;
}
.qrcode-tip {
margin-top: 12px;
color: #909399;
font-size: 14px;
}
.payment-amount {
margin-bottom: 20px;
}
.amount-label {
font-size: 16px;
color: #666;
margin-right: 8px;
}
.amount-value {
font-size: 32px;
font-weight: bold;
color: #f56c6c;
}
.payment-status {
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #409eff;
font-size: 14px;
}
/* 优惠券区域 */
.coupon-section {
margin-top: 20px;
padding: 16px;
background: #f5f7fa;
border-radius: 8px;
}
.coupon-input-wrapper {
display: flex;
align-items: center;
}
.coupon-result {
margin-top: 12px;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 6px;
&.success {
background: #f0f9eb;
color: #67c23a;
}
}
</style>