213 lines
16 KiB
Vue
213 lines
16 KiB
Vue
<template>
|
||
<div class="judge-panel">
|
||
<el-tabs v-model="mode" class="judge-tabs" @tab-change="load">
|
||
<el-tab-pane label="裁判报名" name="eventReferees" />
|
||
<el-tab-pane label="场次分配" name="assignments" />
|
||
</el-tabs>
|
||
|
||
<div v-if="mode === 'eventReferees'" class="panel-body">
|
||
<ProTable :data="eventReferees" :columns="eventRefereeColumns" :pagination="false" :tool-button="false" :loading="loading" row-key="eventRefereeId">
|
||
<template #tools><el-button type="success" icon="ele-Postcard" :loading="codeLoading" @click="generateCode">生成裁判报名二维码</el-button></template>
|
||
<template #certificateUrl="scope"><el-image v-if="scope.row.certificateUrl" :src="scope.row.certificateUrl" :preview-src-list="[scope.row.certificateUrl]" preview-teleported fit="cover" class="certificate" /><span v-else>-</span></template>
|
||
<template #roles="scope">{{ scope.row.roleNames?.join(' / ') || '-' }}</template>
|
||
</ProTable>
|
||
</div>
|
||
|
||
<div v-else class="panel-body" v-loading="loading">
|
||
<el-empty v-if="!board.arrangementConfirmed" description="当前赛事尚未确认编排,请稍后再试" />
|
||
<template v-else>
|
||
<section v-for="project in board.projects" :key="project.eventProjectId" class="project-section">
|
||
<div class="project-heading"><span class="project-name">{{ project.projectName }}</span><span class="project-rule">{{ project.ruleName }}</span></div>
|
||
<ProTable :data="project.sessions" :columns="assignmentColumns" :pagination="false" :tool-button="false" row-key="sessionId">
|
||
<template #judgeCount="scope">{{ scope.row.assignments?.length || 0 }}</template>
|
||
<template #assigned="scope"><el-tag :type="scope.row.assigned ? 'success' : 'info'">{{ scope.row.assigned ? '已分配' : '未分配' }}</el-tag></template>
|
||
<template #action="scope"><el-button text type="primary" :disabled="board.submitted" @click="openAssign(project, scope.row)">分配裁判</el-button><el-button text type="primary" :disabled="!scope.row.assigned" @click="openAssign(project, scope.row, true)">查看详情</el-button></template>
|
||
</ProTable>
|
||
</section>
|
||
<div class="submit-bar"><el-button @click="load">刷新</el-button><el-button type="primary" :disabled="board.submitted" @click="submitAll">{{ board.submitted ? '已提交' : '提交' }}</el-button></div>
|
||
</template>
|
||
</div>
|
||
|
||
<el-dialog v-model="assignVisible" title="分配裁判" width="min(920px, 94vw)" class="judge-assignment-dialog" append-to-body destroy-on-close>
|
||
<div :class="['assign-layout', { readonly: viewOnly }]">
|
||
<div class="role-area">
|
||
<div v-for="section in roleSections" :key="section.title" class="role-section">
|
||
<div class="role-title">{{ section.title }}</div>
|
||
<div class="role-slots">
|
||
<div v-for="slot in section.slots" :key="slot.code" :class="['role-slot', { active: !viewOnly && selectedRole === slot.code, readonly: viewOnly }]" @click="selectRole(slot.code)">
|
||
<div class="slot-head">
|
||
<strong class="slot-name">{{ slot.name }}</strong>
|
||
<span class="slot-quota">{{ quotaText(slot) }}</span>
|
||
</div>
|
||
<div v-if="slot.categories?.length" style="font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.5;">
|
||
<div v-for="cat in slot.categories" :key="cat">{{ cat }}</div>
|
||
</div>
|
||
<div v-if="picked(slot.code).length" class="slot-referees">
|
||
<el-tag v-for="id in picked(slot.code)" :key="id" :closable="!viewOnly" @close.stop="removeReferee(slot.code, id)">{{ candidateName(id) }}</el-tag>
|
||
</div>
|
||
<span v-else class="slot-empty">{{ viewOnly ? '未分配' : '点击后从右侧选择裁判' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="!viewOnly" class="candidate-area">
|
||
<div class="candidate-title">{{ selectedSlot ? `可选${selectedSlot.name}` : '裁判报名' }}({{ eligibleCandidates.length }} 人)</div>
|
||
<div class="candidate-list">
|
||
<el-empty v-if="eligibleCandidates.length === 0" description="暂无符合资格的报名裁判" :image-size="60" />
|
||
<el-button v-for="item in eligibleCandidates" :key="item.eventRefereeId" class="candidate" @click="chooseCandidate(item)">{{ item.name }} / {{ item.mobile }}</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<template #footer><el-button @click="assignVisible = false">{{ viewOnly ? '关闭' : '取消' }}</el-button><el-button v-if="!viewOnly" type="primary" @click="saveAssignments">确定</el-button></template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="codeVisible" title="裁判报名二维码" width="min(420px, 92vw)" align-center destroy-on-close>
|
||
<div class="code-dialog"><div class="qrcode"><img v-if="registrationCodeImage" :src="registrationCodeImage" alt="裁判报名小程序码" /></div><div class="scan-tip">微信扫码后将直接打开小程序赛事详情,并进入裁判报名流程</div><div class="registration-code">报名码:{{ registrationCode }}</div><el-button type="primary" @click="downloadCode">下载二维码</el-button></div>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, reactive, ref, watch } from 'vue';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
import ProTable from '/@/components/ProTable/index.vue';
|
||
import { judgeAssignmentBoardGet, judgeAssignmentSavePost, judgeAssignmentSubmitPost, judgeCandidatesGet, judgeEventRefereesGet, judgeRegistrationCodePost } from '/@/api/match/judgeassignment';
|
||
|
||
const props = defineProps({ eventId: { type: [String, Number], required: true }, active: Boolean });
|
||
const mode = ref('eventReferees');
|
||
const loading = ref(false);
|
||
const eventReferees = ref([]);
|
||
const board = reactive({ arrangementConfirmed: false, submitted: false, projects: [] });
|
||
const assignVisible = ref(false);
|
||
const viewOnly = ref(false);
|
||
const currentSession = ref();
|
||
const candidates = ref([]);
|
||
const roleSections = ref([]);
|
||
const selectedRole = ref('');
|
||
const assignmentMap = reactive({});
|
||
const codeVisible = ref(false);
|
||
const codeLoading = ref(false);
|
||
const registrationCode = ref('');
|
||
const registrationCodeImage = ref('');
|
||
|
||
const eventRefereeColumns = [
|
||
{ prop: 'index', label: '序号', type: 'index', width: 80 },
|
||
{ prop: 'name', label: '裁判姓名', minWidth: 110 },
|
||
{ prop: 'regionName', label: '地区', minWidth: 190 },
|
||
{ prop: 'levelName', label: '级别', minWidth: 130 },
|
||
{ prop: 'certificateUrl', label: '证件', width: 90 },
|
||
{ prop: 'mobile', label: '手机号', minWidth: 130 },
|
||
{ prop: 'roles', label: '裁判角色', minWidth: 210 },
|
||
{ prop: 'createdTime', label: '报名时间', minWidth: 170 },
|
||
];
|
||
const assignmentColumns = [{ prop: 'sessionName', label: '场次', minWidth: 160 }, { prop: 'venueName', label: '场地', minWidth: 160 }, { prop: 'judgeCount', label: '裁判人数', width: 100 }, { prop: 'assigned', label: '状态', width: 110 }, { prop: 'action', label: '操作', width: 200, fixed: 'right' }];
|
||
// 一个裁判类型可分配多名裁判(主裁固定 1 人,其余类型不限人数,成绩按平均值计算)。
|
||
const allSlots = computed(() => roleSections.value.flatMap(section => section.slots));
|
||
const selectedSlot = computed(() => allSlots.value.find(slot => slot.code === selectedRole.value));
|
||
const assignedIds = computed(() => new Set(Object.values(assignmentMap).flat()));
|
||
const eligibleCandidates = computed(() => selectedSlot.value ? candidates.value.filter(item => (item.roles & selectedSlot.value.roleFlag) !== 0 && !assignedIds.value.has(item.eventRefereeId)) : []);
|
||
const picked = code => assignmentMap[code] || [];
|
||
const minOf = slot => slot.minCount ?? 1;
|
||
const quotaText = slot => slot.maxCount ? `${picked(slot.code).length}/${slot.maxCount} 人` : `已选 ${picked(slot.code).length} 人(不限人数)`;
|
||
|
||
async function load() {
|
||
if (!props.active || !props.eventId) return;
|
||
loading.value = true;
|
||
try {
|
||
if (mode.value === 'eventReferees') eventReferees.value = (await judgeEventRefereesGet(props.eventId)).data || [];
|
||
else Object.assign(board, (await judgeAssignmentBoardGet(props.eventId)).data || { arrangementConfirmed: false, submitted: false, projects: [] });
|
||
} finally { loading.value = false; }
|
||
}
|
||
function candidateName(id) { return candidates.value.find(item => item.eventRefereeId === id)?.name || currentSession.value?.assignments?.find(item => item.eventRefereeId === id)?.refereeName || id; }
|
||
async function openAssign(project, session, readonly = false) {
|
||
viewOnly.value = readonly; currentSession.value = session;
|
||
const sections = (project.judgeRoleSections || []).map(section => ({ ...section, slots: section.slots || [] }));
|
||
// 计分规则调整后可能存在规则里已不存在的裁判类型,单独列出避免已分配裁判被静默丢弃。
|
||
const known = new Set(sections.flatMap(section => section.slots.map(slot => slot.code)));
|
||
const orphans = [...new Set((session.assignments || []).map(item => item.judgeRoleType).filter(code => code && !known.has(code)))];
|
||
if (orphans.length) sections.push({ title: '其他(计分规则已调整,请重新分配)', slots: orphans.map(code => ({ code, name: code, minCount: 0, maxCount: null, roleFlag: 0 })) });
|
||
roleSections.value = sections;
|
||
Object.keys(assignmentMap).forEach(key => delete assignmentMap[key]);
|
||
sections.forEach(section => section.slots.forEach(slot => (assignmentMap[slot.code] = [])));
|
||
(session.assignments || []).forEach(item => assignmentMap[item.judgeRoleType]?.push(item.eventRefereeId));
|
||
candidates.value = readonly ? [] : (await judgeCandidatesGet(props.eventId, session.sessionId)).data || [];
|
||
selectedRole.value = sections[0]?.slots?.[0]?.code || ''; assignVisible.value = true;
|
||
}
|
||
function selectRole(role) { if (!viewOnly.value) selectedRole.value = role; }
|
||
function chooseCandidate(item) {
|
||
const slot = selectedSlot.value;
|
||
if (!slot) return ElMessage.warning('请先选择裁判类型');
|
||
const list = assignmentMap[slot.code] || (assignmentMap[slot.code] = []);
|
||
if (slot.maxCount && list.length >= slot.maxCount) return ElMessage.warning(`${slot.name}最多分配 ${slot.maxCount} 名裁判`);
|
||
list.push(item.eventRefereeId);
|
||
}
|
||
function removeReferee(code, id) { const list = assignmentMap[code]; const index = list?.indexOf(id) ?? -1; if (index >= 0) list.splice(index, 1); }
|
||
async function saveAssignments() {
|
||
const incomplete = allSlots.value.find(slot => picked(slot.code).length < minOf(slot));
|
||
if (incomplete) return ElMessage.warning(`${incomplete.name}至少需要分配 ${minOf(incomplete)} 名裁判`);
|
||
const assignments = allSlots.value.flatMap(slot => picked(slot.code).map(id => ({ judgeRole: slot.code, eventRefereeId: id,
|
||
refereeId: (candidates.value.find(item => item.eventRefereeId === id) || currentSession.value.assignments?.find(item => item.eventRefereeId === id))?.refereeId || 0 })));
|
||
const result = await judgeAssignmentSavePost({ eventId: Number(props.eventId), sessionId: currentSession.value.sessionId, assignments });
|
||
if (!result?.succeeded) return;
|
||
ElMessage.success('分配已保存'); assignVisible.value = false; load();
|
||
}
|
||
async function submitAll() { await ElMessageBox.confirm('提交后分配任务即可同步到裁判端,是否继续?', '提交确认', { type: 'warning' }); const result = await judgeAssignmentSubmitPost({ eventId: Number(props.eventId) }); if (!result?.succeeded) return; ElMessage.success('提交成功'); load(); }
|
||
async function generateCode() {
|
||
codeLoading.value = true;
|
||
try {
|
||
const result = await judgeRegistrationCodePost(props.eventId);
|
||
const data = result?.data;
|
||
if (!result?.succeeded || !data?.registrationCode || !data?.qrCodeDataUrl) return ElMessage.error('裁判报名二维码生成失败,请稍后重试');
|
||
registrationCode.value = data.registrationCode;
|
||
registrationCodeImage.value = data.qrCodeDataUrl;
|
||
codeVisible.value = true;
|
||
} catch {
|
||
// 请求层已统一提示错误,此处仅收口事件 Promise,避免产生未处理异常。
|
||
} finally { codeLoading.value = false; }
|
||
}
|
||
function downloadCode() { if (!registrationCodeImage.value) return ElMessage.error('二维码尚未生成,请稍后重试'); const link = document.createElement('a'); link.href = registrationCodeImage.value; link.download = `裁判报名二维码-${registrationCode.value}.jpg`; document.body.appendChild(link); link.click(); document.body.removeChild(link); }
|
||
watch(() => props.active, value => value && load(), { immediate: true });
|
||
</script>
|
||
|
||
<style scoped>
|
||
.judge-panel { min-height: 100%; padding: 0 2px; color: var(--el-text-color-primary); }
|
||
.judge-tabs { margin-bottom: 12px; }
|
||
.panel-body { min-width: 0; }
|
||
.certificate { width: 48px; height: 36px; border-radius: 4px; }
|
||
.project-section { margin-bottom: 20px; }
|
||
.project-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; padding: 0 4px; }
|
||
.project-name { font-size: 16px; font-weight: 600; }
|
||
.project-rule { padding: 3px 8px; font-size: 12px; color: var(--el-text-color-secondary); background: var(--el-fill-color-light); border-radius: 4px; }
|
||
.submit-bar { margin-top: 24px; text-align: center; }
|
||
.assign-layout { display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 16px; height: min(680px, calc(100dvh - 170px)); min-height: 0; overflow: hidden; }
|
||
.assign-layout.readonly { grid-template-columns: 1fr; }
|
||
.role-area { min-width: 0; min-height: 0; overflow-y: auto; padding-right: 4px; scrollbar-gutter: stable; overscroll-behavior: contain; }
|
||
.role-section { margin-bottom: 12px; overflow: hidden; border: 1px solid var(--el-border-color); border-radius: 5px; }
|
||
.role-title, .candidate-title { padding: 9px; font-weight: 600; color: var(--el-color-primary); text-align: center; background: var(--el-color-primary-light-9); }
|
||
.role-slots { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||
.role-slot { display: flex; flex-direction: column; gap: 8px; min-height: 68px; padding: 10px; cursor: pointer; border: 1px solid var(--el-border-color-lighter); }
|
||
.role-slot.active { background: var(--el-color-primary-light-9); border-color: var(--el-color-primary); }
|
||
.role-slot.readonly { cursor: default; }
|
||
.slot-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||
.slot-quota { flex: none; font-size: 12px; color: var(--el-text-color-secondary); }
|
||
.slot-referees { display: flex; flex-wrap: wrap; gap: 6px; }
|
||
.slot-empty { color: var(--el-text-color-placeholder); }
|
||
.candidate-area { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; border: 1px solid var(--el-border-color); border-radius: 5px; }
|
||
.candidate-title { flex: none; }
|
||
.candidate-list { flex: 1; min-height: 0; overflow-y: auto; padding: 0 8px 10px; overscroll-behavior: contain; }
|
||
.candidate-list .candidate { width: 100%; min-height: 32px; height: auto; margin: 8px 0 0; white-space: normal; text-align: left; }
|
||
.code-dialog { display: flex; flex-direction: column; align-items: center; gap: 16px; }
|
||
.qrcode { width: 264px; height: 264px; padding: 12px; background: #fff; border: 1px solid var(--el-border-color-lighter); border-radius: 8px; box-shadow: var(--el-box-shadow-light); }
|
||
.qrcode img { display: block; width: 240px; height: 240px; }
|
||
.scan-tip { max-width: 300px; font-size: 13px; line-height: 1.6; color: var(--el-text-color-secondary); text-align: center; }
|
||
.registration-code { font-size: 14px; color: var(--el-text-color-secondary); word-break: break-all; }
|
||
@media (max-width: 768px) { .judge-panel { padding: 0; } .project-heading { flex-direction: column; align-items: flex-start; gap: 6px; } .assign-layout { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) minmax(120px, .8fr); gap: 10px; } .assign-layout.readonly { grid-template-rows: minmax(0, 1fr); } .role-slots { grid-template-columns: 1fr; } .submit-bar { position: sticky; bottom: 0; z-index: 2; padding: 12px; background: var(--el-bg-color); } }
|
||
</style>
|
||
|
||
<style>
|
||
/* 分配弹窗从全屏工作台中独立挂载,避免父级滚动容器裁切;弹窗页脚始终可见。 */
|
||
.judge-assignment-dialog { max-height: calc(100dvh - 40px); margin: 20px auto; display: flex; flex-direction: column; }
|
||
.judge-assignment-dialog .el-dialog__body { min-height: 0; overflow: hidden; }
|
||
.judge-assignment-dialog .el-dialog__header, .judge-assignment-dialog .el-dialog__footer { flex: none; }
|
||
</style>
|