605 lines
20 KiB
Vue
Raw Normal View History

2026-03-23 18:01:01 +08:00
<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="width: 70px; height: 46px;"
: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="eventStatusTagType(scope.row.status)">{{ eventStatusText(scope.row.status) }}</el-tag>
</template>
<template #registrationStartTime="scope">
{{ formatRange(scope.row.registrationStartTime, scope.row.registrationEndTime) }}
</template>
<template #matchStartTime="scope">
{{ formatRange(scope.row.matchStartTime, scope.row.matchEndTime) }}
</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%" @closed="clearDetailState">
<el-skeleton :rows="12" animated v-if="detailLoading" />
<template v-else>
<div v-if="detailData" style="display: flex; gap: 18px; align-items: flex-start;">
<el-image v-if="detailData?.coverImage" style="width: 180px; height: 120px; flex: 0 0 auto;"
:src="detailData.coverImage" fit="cover" />
<el-empty v-else description="暂无封面" style="width: 180px; height: 120px;" />
<div style="flex: 1;">
<el-descriptions :column="2" border size="small" v-if="detailData">
<el-descriptions-item label="赛事名称">{{ detailData?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="赛事类型">
{{ eventTypeText(detailData) }}
</el-descriptions-item>
<el-descriptions-item label="报名方式">
{{ registrationTypeText(detailData) }}
</el-descriptions-item>
<el-descriptions-item label="收费模式">{{ chargeModeText(detailData) }}</el-descriptions-item>
<el-descriptions-item label="赛事状态">
<el-tag :type="eventStatusTagType(detailData?.status)">{{ eventStatusText(detailData?.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="报名起止时间">
{{ formatRange(detailData?.registrationStartTime, detailData?.registrationEndTime) }}
</el-descriptions-item>
<el-descriptions-item label="比赛起止时间">
{{ formatRange(detailData?.matchStartTime, detailData?.matchEndTime) }}
</el-descriptions-item>
<el-descriptions-item label="积分发放规则">
{{ detailData?.pointsRuleName ?? '-' }}
</el-descriptions-item>
</el-descriptions>
<div class="detail-section-title">项目列表</div>
<el-table :data="detailData?.projects ?? []" border size="small" style="margin-top: 10px;">
<el-table-column prop="name" label="项目" align="center" show-overflow-tooltip />
<el-table-column prop="groupName" label="组别" align="center" />
<el-table-column prop="fee" label="费用(元)" align="center" width="120" />
<el-table-column prop="limitCount" label="名额" align="center" width="120" />
<el-table-column prop="registeredCount" label="已报名" align="center" width="120" />
</el-table>
</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-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 />
<el-table v-else :data="projects" border row-key="id" size="small"
@selection-change="onProjectSelectionChange">
<el-table-column type="selection" />
<el-table-column prop="name" label="项目" align="center" show-overflow-tooltip />
<el-table-column prop="groupName" label="组别" align="center" show-overflow-tooltip />
<el-table-column prop="fee" label="费用(元)" align="center" width="120" />
<el-table-column prop="limitCount" label="名额" align="center" width="120" />
<el-table-column prop="registeredCount" label="已报名" align="center" width="120" />
<el-table-column prop="requirements" label="要求" align="center" show-overflow-tooltip />
</el-table>
</div>
<!-- Step 2 -->
<div v-else-if="activeStep === 1">
<div class="search-row">
<el-input v-model="memberKeyword" placeholder="输入姓名/手机号搜索队员" style="width: 260px;"
@keyup.enter="fetchMembers" />
<el-button type="primary" @click="fetchMembers" :loading="memberLoading">搜索</el-button>
</div>
<el-empty v-if="!memberLoading && !(members?.length ?? 0)" description="暂无队员数据" style="margin-top: 18px;" />
<el-skeleton v-else-if="memberLoading" :rows="8" animated style="margin-top: 18px;" />
<el-table v-else :data="members" border row-key="userId" size="small"
@selection-change="onMemberSelectionChange">
<el-table-column type="selection" :selectable="isMemberSelectable" />
<el-table-column prop="name" label="姓名" align="center" />
<el-table-column prop="mobile" label="手机号" align="center" />
<el-table-column prop="idCard" label="身份证" align="center" show-overflow-tooltip />
<el-table-column prop="gender" label="性别" align="center" />
<el-table-column label="资格" align="center" width="160">
<template #default="scope2">
<el-tag :type="scope2.row.isEligible ? 'success' : 'info'">
{{ scope2.row.isEligible ? '可报名' : (scope2.row.ineligibilityReason ?? '不可报名') }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<!-- Step 3 -->
<div v-else-if="activeStep === 2">
<el-skeleton :rows="10" animated v-if="previewLoading" />
<template v-else>
<el-descriptions :column="2" border size="small" v-if="previewData">
<el-descriptions-item label="赛事">{{ previewData?.eventName ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="合计金额">
{{ previewData?.totalAmount ?? 0 }} 元
</el-descriptions-item>
</el-descriptions>
<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" />
<el-table-column prop="groupName" label="组别" align="center" />
<el-table-column prop="fee" label="费用(元)" align="center" width="120" />
<el-table-column prop="limitCount" label="名额" align="center" width="120" />
</el-table>
<div class="detail-section-title">已选择的队员</div>
<el-table :data="previewData?.members ?? []" border size="small" style="margin-top: 10px;">
<el-table-column prop="name" label="姓名" align="center" />
<el-table-column prop="mobile" label="手机号" align="center" />
<el-table-column prop="idCard" label="身份证" align="center" show-overflow-tooltip />
<el-table-column prop="gender" label="性别" align="center" />
</el-table>
<div class="payment-panel">
<div class="payment-panel-title">支付方式</div>
<el-select v-model="paymentMethod" placeholder="请选择支付方式" style="width: 240px;">
<el-option :value="1" label="微信支付" />
<el-option :value="2" label="支付宝支付" />
<el-option :value="3" label="银行转账" />
</el-select>
</div>
</template>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="isShowRegisterDialog = false">取消</el-button>
<el-button v-if="activeStep === 1" @click="handlePrevious" :disabled="registerLoading">上一步</el-button>
<el-button v-if="activeStep === 2" @click="handlePrevious">上一步</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>
<el-button v-else-if="activeStep === 2" type="primary" @click="handleCreateOrderAndPay"
:loading="createOrderLoading" :disabled="!draftRegistrationId">去支付</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { ElMessage } from 'element-plus';
import ProTable from '/@/components/ProTable/index.vue';
import { formatDate } from '/@/utils/formatTime';
import {
usereventregistrationListGet,
usereventregistrationDetailGet,
usereventregistrationProjectsGet,
usereventregistrationMembersSearchGet,
usereventregistrationSubmitPost,
usereventregistrationPreviewGet,
usereventregistrationOrderCreatePost,
} from '/@/api/match/usereventregistration';
const tableRef = ref();
const initParam = ref({ refresh: true });
const columns = ref([
{ type: 'index', label: '序号', align: 'center', width: 70 },
{
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: 220 },
{ prop: 'matchStartTime', label: '比赛起止时间', align: 'center', minWidth: 220 },
{ 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 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 1:
return '仅个人报名';
case 'OnlyTeam':
case 2:
return '仅团队报名';
case 'Both':
case 3:
return '个人&团队';
default:
return v ?? '-';
}
};
const chargeModeText = (row) => {
const v = row?.chargeMode ?? row?.feeConfig?.chargeMode ?? row?.feeMode;
if (!v) return '-';
// 后端可能返回 Free/Paid 等枚举,这里做一个常见值映射
if (v === 'Free' || v === 0 || v === 'free') return '免费';
if (v === 'Paid' || v === 1 || v === 'paid') return '收费';
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;
return s === 'Enrolling' || s === 'enrolling' || s === 0 || s === '0';
};
// -------------------- 赛事详情 --------------------
const isShowDetailDialog = ref(false);
const detailLoading = ref(false);
const detailData = ref(null);
const clearDetailState = () => {
detailLoading.value = false;
detailData.value = null;
};
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;
} 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 selectedProjectIds = ref([]);
const members = ref([]);
const memberKeyword = ref('');
const memberLoading = ref(false);
const selectedMemberIds = ref([]);
const previewLoading = ref(false);
const previewData = ref(null);
const paymentMethod = ref(1);
const draftRegistrationId = ref(null);
const clearRegisterState = () => {
activeStep.value = 0;
registerLoading.value = false;
submitDraftLoading.value = false;
createOrderLoading.value = false;
currentEventId.value = null;
projects.value = [];
selectedProjectIds.value = [];
members.value = [];
memberKeyword.value = '';
memberLoading.value = false;
selectedMemberIds.value = [];
previewLoading.value = false;
previewData.value = null;
paymentMethod.value = 1;
draftRegistrationId.value = null;
};
const currentEventId = ref(null);
const resetStep2AndBeyond = () => {
members.value = [];
selectedMemberIds.value = [];
memberKeyword.value = '';
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 usereventregistrationProjectsGet(eventId);
if (!succeeded) {
ElMessage.error(data ?? '项目加载失败');
return;
}
projects.value = Array.isArray(data) ? data : [];
} catch {
ElMessage.error('项目加载失败');
} finally {
registerLoading.value = false;
}
};
const onProjectSelectionChange = (rows) => {
const list = (rows ?? []).map((r) => r?.id ?? r?.projectId).filter((id) => id !== undefined && id !== null);
selectedProjectIds.value = list;
};
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 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 handleStep1Next = async () => {
if (!(selectedProjectIds.value?.length ?? 0)) {
ElMessage.warning('请先选择报名项目');
return;
}
activeStep.value = 1;
resetStep2AndBeyond();
// 进入第二步后拉取一次队员列表(默认空关键字)
await fetchMembers();
};
const handleStep2Next = async () => {
if (!currentEventId.value) return;
if (!(selectedMemberIds.value?.length ?? 0)) {
ElMessage.warning('请先选择队员');
return;
}
submitDraftLoading.value = true;
try {
const payload = {
eventId: currentEventId.value,
projectIds: selectedProjectIds.value,
memberIds: selectedMemberIds.value,
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 handleCreateOrderAndPay = async () => {
if (!draftRegistrationId.value) return;
createOrderLoading.value = true;
try {
const payload = {
registrationId: draftRegistrationId.value,
paymentMethod: paymentMethod.value,
};
const { succeeded, data } = await usereventregistrationOrderCreatePost(payload);
if (!succeeded) {
ElMessage.error(data ?? '创建支付订单失败');
return;
}
if (data?.payUrl) {
window.open(data.payUrl, '_blank');
} else {
ElMessage.warning('未获取到支付链接');
}
// 触发列表刷新(支付成功后后端状态会变更)
initParam.value.refresh = !initParam.value.refresh;
isShowRegisterDialog.value = false;
} 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);
}
</style>