裁判报名分配限制人数

This commit is contained in:
马梓彭 2026-08-18 00:22:11 +08:00
parent 90e298b049
commit d3cb65b065

View File

@ -19,6 +19,7 @@
<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>
@ -27,19 +28,23 @@
</template>
</div>
<el-dialog v-model="assignVisible" title="分配裁判" width="min(860px, 94vw)" destroy-on-close>
<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: selectedRole === slot.code, readonly: viewOnly }]" @click="selectRole(slot.code)">
<strong>{{ slot.name }}</strong><el-tag v-if="assignmentMap[slot.code]" :closable="!viewOnly" @close.stop="clearRole(slot.code)">{{ candidateName(assignmentMap[slot.code]) }}</el-tag><span v-else>{{ viewOnly ? '未分配' : '点击后从右侧选择裁判' }}</span>
<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">裁判报名</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 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>
@ -84,9 +89,15 @@ const eventRefereeColumns = [
{ prop: 'roles', label: '裁判角色', minWidth: 210 },
{ prop: 'createdTime', label: '报名时间', minWidth: 170 },
];
const assignmentColumns = [{ prop: 'sessionName', label: '场次', minWidth: 160 }, { prop: 'venueName', label: '场地', minWidth: 160 }, { prop: 'assigned', label: '状态', width: 110 }, { prop: 'action', label: '操作', width: 200, fixed: 'right' }];
const roleFlag = role => role === 'T' ? 1 : role === 'P' ? 2 : role === 'CJ' ? 8 : role === 'CQ' ? 16 : 4;
const eligibleCandidates = computed(() => candidates.value.filter(item => selectedRole.value && (item.roles & roleFlag(selectedRole.value)) !== 0));
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;
@ -99,22 +110,37 @@ async function load() {
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;
roleSections.value = JSON.parse(project.judgeRoleSchemaJson || '[]');
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]);
session.assignments.forEach(item => assignmentMap[item.judgeRole] = item.eventRefereeId);
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 = roleSections.value[0]?.slots?.[0]?.code || ''; assignVisible.value = true;
selectedRole.value = sections[0]?.slots?.[0]?.code || ''; assignVisible.value = true;
}
function selectRole(role) { if (!viewOnly.value) selectedRole.value = role; }
function chooseCandidate(item) { if (!selectedRole.value) return; const occupied = Object.entries(assignmentMap).find(([role, id]) => id === item.eventRefereeId && role !== selectedRole.value); if (occupied) return ElMessage.warning('同一场次一名裁判只能分配一个岗位'); assignmentMap[selectedRole.value] = item.eventRefereeId; }
function clearRole(role) { delete assignmentMap[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 slots = roleSections.value.flatMap(section => section.slots);
if (slots.some(slot => !assignmentMap[slot.code])) return ElMessage.warning('请完成全部裁判岗位分配');
await judgeAssignmentSavePost({ eventId: Number(props.eventId), sessionId: currentSession.value.sessionId, assignments: slots.map(slot => { const relation = candidates.value.find(item => item.eventRefereeId === assignmentMap[slot.code]); return { judgeRole: slot.code, eventRefereeId: relation.eventRefereeId, refereeId: relation.refereeId }; }) });
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' }); await judgeAssignmentSubmitPost({ eventId: Number(props.eventId) }); ElMessage.success('提交成功'); 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; }
@ -141,7 +167,10 @@ watch(() => props.active, value => value && load(), { immediate: true });
.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; }
.role-slot span { color: var(--el-text-color-placeholder); }
.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; }