2026-03-23 18:01:01 +08:00

605 lines
20 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="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>