YD_Event.Web/src/views/match/manage/JudgeAssignmentPanel.vue

182 lines
14 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)" 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>{{ slot.name }}</strong><span class="slot-quota">{{ quotaText(slot) }}</span></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}` : '裁判报名' }}</div><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>
<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 ref="qrcodeRef" class="qrcode" aria-label="裁判报名二维码"></div><div class="registration-code">报名码:{{ registrationCode }}</div><el-button type="primary" @click="downloadCode">下载二维码</el-button></div>
</el-dialog>
</div>
</template>
<script setup>
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import QRCode from 'qrcodejs2-fixes';
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 qrcodeRef = 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); if (!result?.succeeded || !result.data) return ElMessage.error('报名码生成失败,请稍后重试'); registrationCode.value = result.data; codeVisible.value = true; await nextTick(); qrcodeRef.value.innerHTML = ''; new QRCode(qrcodeRef.value, { text: registrationCode.value, width: 240, height: 240, colorDark: '#1d2129', colorLight: '#ffffff', correctLevel: QRCode.CorrectLevel.H }); } finally { codeLoading.value = false; }
}
function downloadCode() { const canvas = qrcodeRef.value?.querySelector('canvas'); const image = qrcodeRef.value?.querySelector('img'); const url = canvas?.toDataURL('image/png') || image?.src; if (!url) return ElMessage.error('二维码尚未生成,请稍后重试'); const link = document.createElement('a'); link.href = url; link.download = `裁判报名二维码-${registrationCode.value}.png`; 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) 210px; gap: 16px; }
.assign-layout.readonly { grid-template-columns: 1fr; }
.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 { padding-bottom: 10px; overflow: hidden; border: 1px solid var(--el-border-color); border-radius: 5px; }
.candidate { width: calc(100% - 16px); margin: 8px 8px 0; }
.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 :deep(img), .qrcode :deep(canvas) { display: block; width: 240px !important; height: 240px !important; }
.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; } .role-slots { grid-template-columns: 1fr; } .submit-bar { position: sticky; bottom: 0; z-index: 2; padding: 12px; background: var(--el-bg-color); } }
</style>