YD_Event.Web/src/views/match/manage/JudgeAssignmentPanel.vue
2026-10-21 17:42:29 +08:00

213 lines
16 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="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>