裁判库,裁判报名与分配

This commit is contained in:
马梓彭 2026-08-11 23:40:41 +08:00
parent 712998124a
commit 84c1078589
11 changed files with 266 additions and 11 deletions

View File

@ -91,7 +91,8 @@ export const eventPagePost = (data) => {
* }>,
* registrationLimit: {
* individualProjectLimit: number,
* teamProjectLimit: number
* teamProjectLimit: number,
* refereeLimit: number
* },
* feeConfig: {
* chargeMode: string,
@ -139,7 +140,7 @@ export const eventDetailGet = (id) => {
* @param {string} [data.customConfigJson] - 自定义配置JSON
* @param {Array<Object>} data.units - 参与单位列表
* @param {Array<Object>} data.projects - 项目列表
* @param {Object} data.registrationLimit - 限报配置
* @param {Object} data.registrationLimit - 限报配置(含个人/团队项目限报数、裁判限报数)
* @param {Object} data.feeConfig - 费用配置
*/
export const eventPost = (data) => {
@ -172,7 +173,7 @@ export const eventPost = (data) => {
* @param {string} [data.customConfigJson] - 自定义配置JSON
* @param {Array<Object>} data.units - 参与单位列表
* @param {Array<Object>} data.projects - 项目列表
* @param {Object} data.registrationLimit - 限报配置
* @param {Object} data.registrationLimit - 限报配置(含个人/团队项目限报数、裁判限报数)
* @param {Object} data.feeConfig - 费用配置
*/
export const eventPut = (data) => {

View File

@ -81,4 +81,6 @@ export const eventarrangementDeleteDelete = (data) => {
*/
export const eventarrangementSetstatusPatch = (data) => {
return http.patch('/eventarrangement/setstatus', data);
};
};
export const eventarrangementConfirmPost = (eventid) => http.post(`/eventarrangement/confirm/${eventid}`);

View File

@ -0,0 +1,8 @@
import { http } from '../../utils/request';
export const judgeApplicationsGet = (eventId) => http.get(`/judgeassignment/applications/${eventId}`);
export const judgeApplicationDelete = (id) => http.delete('/judgeassignment/applications', { data: { id } });
export const judgeAssignmentBoardGet = (eventId) => http.get(`/judgeassignment/board/${eventId}`);
export const judgeCandidatesGet = (eventId, sessionId) => http.get(`/judgeassignment/candidates/${eventId}/${sessionId}`);
export const judgeAssignmentSavePost = (data) => http.post('/judgeassignment/save', data);
export const judgeAssignmentSubmitPost = (data) => http.post('/judgeassignment/submit', data);
export const judgeRegistrationCodePost = (eventId) => http.post(`/judgeassignment/registration-code/${eventId}`);

7
src/api/match/referee.js Normal file
View File

@ -0,0 +1,7 @@
import { http } from '../../utils/request';
export const refereePageGet = (params) => http.get('/referee/page', { params });
export const refereeAddPost = (data) => http.post('/referee', data);
export const refereeUpdatePost = (data) => http.put('/referee', data);
export const refereeDelete = (id) => http.delete('/referee/delete', { data: { id } });
export const refereeAuditPost = (data) => http.post('/referee/audit', data);

View File

@ -0,0 +1,2 @@
import { http } from '../../utils/request';
export const scoreRuleListGet = () => http.get('/scorerule/list');

View File

@ -0,0 +1,80 @@
<template>
<div class="judge-panel">
<el-radio-group v-model="mode" @change="load">
<el-radio-button value="applications">裁判报名</el-radio-button>
<el-radio-button value="assignments">裁判分配</el-radio-button>
</el-radio-group>
<div v-if="mode === 'applications'" class="panel-body">
<div class="toolbar"><el-button type="success" @click="generateCode">生成裁判报名码</el-button></div>
<el-table v-loading="loading" :data="applications" border>
<el-table-column prop="name" label="裁判姓名" />
<el-table-column prop="regionName" label="地区" min-width="170" />
<el-table-column prop="levelName" label="级别" />
<el-table-column label="封面" width="90"><template #default="{row}"><el-image :src="row.certificateUrl" :preview-src-list="[row.certificateUrl]" preview-teleported class="certificate" /></template></el-table-column>
<el-table-column prop="mobile" label="手机号(账号)" width="130" />
<el-table-column label="裁判角色" min-width="190"><template #default="{row}">{{ row.roleNames.join('/') }}</template></el-table-column>
<el-table-column prop="createdTime" label="生成时间" width="170" />
<el-table-column label="操作" width="80"><template #default="{row}"><el-button link type="danger" @click="removeApplication(row)">删除</el-button></template></el-table-column>
</el-table>
</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-title">{{ project.projectName }}({{ project.ruleName }})</div>
<el-table :data="project.sessions" border>
<el-table-column prop="venueName" label="场地" />
<el-table-column label="状态"><template #default="{row}"><el-tag :type="row.assigned ? 'success' : 'info'">{{ row.assigned ? '已分配' : '未分配' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="180"><template #default="{row}"><el-button link type="primary" :disabled="board.submitted" @click="openAssign(project,row)">分配裁判</el-button><el-button link :disabled="!row.assigned" @click="openAssign(project,row,true)">查看详情</el-button></template></el-table-column>
</el-table>
</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="860px" 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>
</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.refereeId" 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>
</div>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { judgeApplicationDelete, judgeApplicationsGet, judgeAssignmentBoardGet, judgeAssignmentSavePost, judgeAssignmentSubmitPost, judgeCandidatesGet, judgeRegistrationCodePost } from '/@/api/match/judgeassignment';
const props = defineProps({ eventId: { type: [String, Number], required: true }, active: Boolean });
const mode=ref('applications'), loading=ref(false), applications=ref([]), board=reactive({arrangementConfirmed:false,submitted:false,projects:[]});
const assignVisible=ref(false), viewOnly=ref(false), currentProject=ref(), currentSession=ref(), candidates=ref([]), roleSections=ref([]), selectedRole=ref(''), assignmentMap=reactive({});
const roleFlag = role => role==='T'?1:role==='P'?2:role==='CJ'?8:role==='CQ'?16:4;
const eligibleCandidates=computed(()=>candidates.value.filter(x=>selectedRole.value && (x.roles & roleFlag(selectedRole.value))!==0));
const candidateName=id=>candidates.value.find(x=>x.refereeId===id)?.name || currentSession.value?.assignments?.find(x=>x.refereeId===id)?.refereeName || id;
async function load(){if(!props.active||!props.eventId)return;loading.value=true;try{if(mode.value==='applications'){const r=await judgeApplicationsGet(props.eventId);applications.value=r.data||[];}else{const r=await judgeAssignmentBoardGet(props.eventId);Object.assign(board,r.data||{arrangementConfirmed:false,submitted:false,projects:[]});}}finally{loading.value=false;}}
async function generateCode(){const r=await judgeRegistrationCodePost(props.eventId);await ElMessageBox.alert(`裁判报名码:${r.data}`, '生成成功',{confirmButtonText:'复制'});await navigator.clipboard?.writeText(r.data);ElMessage.success('报名码已复制');}
async function removeApplication(row){await ElMessageBox.confirm(`确定移除裁判“${row.name}”的报名吗?`,'提示',{type:'warning'});await judgeApplicationDelete(row.applicationId);load();}
async function openAssign(project,session,readonly=false){viewOnly.value=readonly;currentProject.value=project;currentSession.value=session;roleSections.value=JSON.parse(project.judgeRoleSchemaJson||'[]');Object.keys(assignmentMap).forEach(k=>delete assignmentMap[k]);session.assignments.forEach(x=>assignmentMap[x.judgeRole]=x.refereeId);if(!readonly){const r=await judgeCandidatesGet(props.eventId,session.sessionId);candidates.value=r.data||[];}else{candidates.value=[];}selectedRole.value=roleSections.value[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.refereeId&&role!==selectedRole.value);if(occupied)return ElMessage.warning('同一场次一名裁判只能分配一个岗位');assignmentMap[selectedRole.value]=item.refereeId;}
function clearRole(role){delete assignmentMap[role];}
async function saveAssignments(){const slots=roleSections.value.flatMap(x=>x.slots);if(slots.some(x=>!assignmentMap[x.code]))return ElMessage.warning('请完成全部裁判岗位分配');await judgeAssignmentSavePost({eventId:props.eventId,sessionId:currentSession.value.sessionId,assignments:slots.map(x=>({judgeRole:x.code,refereeId:assignmentMap[x.code]}))});ElMessage.success('分配已保存');assignVisible.value=false;load();}
async function submitAll(){await ElMessageBox.confirm('提交后分配任务即可同步到裁判端,是否继续?','提交确认',{type:'warning'});await judgeAssignmentSubmitPost({eventId:props.eventId});ElMessage.success('提交成功');load();}
watch(()=>props.active,v=>v&&load(),{immediate:true});
</script>
<style scoped>
.judge-panel{padding:12px 28px}.panel-body{margin-top:18px}.toolbar{display:flex;justify-content:flex-end;margin-bottom:10px}.certificate{width:48px;height:36px}.project-section{margin-bottom:18px}.project-title{width:360px;padding:7px 14px;color:#fff;background:#409eff;border-radius:4px 4px 0 0}.submit-bar{text-align:center;margin-top:24px}.assign-layout{display:grid;grid-template-columns:1fr 190px;gap:12px}.assign-layout.readonly{grid-template-columns:1fr}.role-section{border:1px solid #c6e2ff;margin-bottom:12px}.role-title,.candidate-title{padding:7px;text-align:center;color:#fff;background:#409eff}.role-slots{display:grid;grid-template-columns:repeat(2,1fr)}.role-slot{min-height:64px;padding:10px;border:1px solid #e5e7eb;cursor:pointer;display:flex;flex-direction:column;gap:8px}.role-slot.active{background:#ecf5ff;border-color:#409eff}.role-slot.readonly{cursor:default}.role-slot span{color:#a8abb2}.candidate-area{border:1px solid #dcdfe6;padding-bottom:10px}.candidate{margin:8px 8px 0;width:calc(100% - 16px)}
</style>

View File

@ -141,6 +141,21 @@
</el-form-item>
<span v-else>{{ localFormData.registrationLimit.teamProjectLimit }}</span>
</div>
<div class="limit-summary__card limit-summary__card--full">
<div class="limit-summary__label">
<span class="required-star" v-if="!readonly">*</span>
裁判限报配置
</div>
<!-- 裁判限报数即使有报名记录也可以编辑 -->
<el-form-item v-if="!readonly"
prop="registrationLimit.refereeLimit"
:rules="[{ required: true, message: '请填写裁判限报数', trigger: 'change' }]"
style="margin: 0"
>
<el-input-number v-model="localFormData.registrationLimit.refereeLimit" :min="0" :step="1" />
</el-form-item>
<span v-else>{{ localFormData.registrationLimit.refereeLimit }}</span>
</div>
</div>
</el-card>
</el-form>
@ -200,7 +215,7 @@ const isOnlyIndividual = computed(() => {
const formRef = ref(null);
const localFormData = ref({
projects: [],
registrationLimit: { individualProjectLimit: 1, teamProjectLimit: 1 },
registrationLimit: { individualProjectLimit: 1, teamProjectLimit: 1, refereeLimit: 1 },
});
const genderList = ref(['全部', '男', '女'])
@ -332,7 +347,12 @@ const isSyncingFromParent = ref(false);
const normalizeFromParent = async (v) => {
isSyncingFromParent.value = true;
localFormData.value.registrationLimit = v?.registrationLimit ?? { individualProjectLimit: 1, teamProjectLimit: 1 };
const parentLimit = v?.registrationLimit ?? {};
localFormData.value.registrationLimit = {
individualProjectLimit: parentLimit.individualProjectLimit ?? 1,
teamProjectLimit: parentLimit.teamProjectLimit ?? 1,
refereeLimit: parentLimit.refereeLimit ?? 1,
};
localFormData.value.projects = (v?.projects ?? []).map((p) => ({
...p,
limitCount: p?.limitCount ?? 0,
@ -382,6 +402,7 @@ watch(
const rules = computed(() => {
const baseRules = {
'registrationLimit.individualProjectLimit': [{ required: true, message: '请填写个人项目限报数', trigger: 'change' }],
'registrationLimit.refereeLimit': [{ required: true, message: '请填写裁判限报数', trigger: 'change' }],
};
// 只有在不是仅允许个人报名时,才添加团队项目限报数的验证
if (!isOnlyIndividual.value) {
@ -492,6 +513,10 @@ defineExpose({ validate });
gap: 12px;
}
.limit-summary__card--full {
grid-column: 1 / -1;
}
.limit-summary__label {
font-weight: 600;
color: var(--el-text-color-primary);

View File

@ -171,6 +171,7 @@ const formData = ref({
registrationLimit: {
individualProjectLimit: 1,
teamProjectLimit: 1,
refereeLimit: 1,
},
canEditProject: true, // 是否可以编辑项目(有报名记录时不能编辑)
},
@ -242,7 +243,11 @@ const openDialog = async (id) => {
formData.value.units = { units: d.units ?? [] };
formData.value.projectConfig = {
projects: d.projects ?? [],
registrationLimit: d.registrationLimit ?? { individualProjectLimit: 1, teamProjectLimit: 1 },
registrationLimit: {
individualProjectLimit: d.registrationLimit?.individualProjectLimit ?? 1,
teamProjectLimit: d.registrationLimit?.teamProjectLimit ?? 1,
refereeLimit: d.registrationLimit?.refereeLimit ?? 1,
},
canEditProject: d.canEditProject ?? true,
};
// 完全覆盖 feeConfig,不要保留旧数据
@ -382,7 +387,7 @@ const handleReset = () => {
formData.value.units = { units: [] };
formData.value.projectConfig = {
projects: [],
registrationLimit: { individualProjectLimit: 1, teamProjectLimit: 1 },
registrationLimit: { individualProjectLimit: 1, teamProjectLimit: 1, refereeLimit: 1 },
};
formData.value.feeConfig = {
chargeMode: 0,

View File

@ -164,9 +164,13 @@
<div class="action-bar">
<el-button type="default" @click="previewArrangement">预览</el-button>
<el-button type="primary" @click="getArr">导出</el-button>
<el-button type="success" @click="confirmArrangement">确认编排</el-button>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="裁判报名分配" name="judges">
<JudgeAssignmentPanel :event-id="initParam.eventId" :active="activeName === 'judges'" />
</el-tab-pane>
<el-tab-pane label="检录员管理" name="checkin">
<CheckinManager :event-id="initParam.eventId" :active="activeName === 'checkin'" />
</el-tab-pane>
@ -343,7 +347,7 @@ import { ref, reactive, computed, onMounted, watch, nextTick, h } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Check, View, Download, Rank, Warning } from '@element-plus/icons-vue';
import { eventDetailGet } from '/@/api/match/event';
import { eventarrangementListGet, eventarrangementPut, eventarrangementExportPost, eventarrangementPreviewPost } from '/@/api/match/eventarrangement';
import { eventarrangementListGet, eventarrangementPut, eventarrangementExportPost, eventarrangementPreviewPost, eventarrangementConfirmPost } from '/@/api/match/eventarrangement';
import { eventregistrationPagePost, eventregistrationBatchauditPost, eventregistrationMaterialurlsPost, eventregistrationStatsGet, eventregistrationExportPost } from '/@/api/match/eventregistration';
import { eventscorePageathletePost, eventscorePagecoachleaderPost, eventscoreImportscorePost, eventscoreDownloadtemplateGet, eventscoreExportcertificatePost, eventscoreIssuecertificatePost, eventscoreGetCertificateDetailGet, eventscoreIssuepointsPost } from '/@/api/match/eventscore';
import { certificateListGet } from '/@/api/match/certificate';
@ -352,6 +356,7 @@ import Step2Units from './Steps/Step2Units.vue';
import Step3ProjectConfig from './Steps/Step3ProjectConfig.vue';
import Step4FeeConfig from './Steps/Step4FeeConfig.vue';
import CheckinManager from './CheckinManager.vue';
import JudgeAssignmentPanel from './JudgeAssignmentPanel.vue';
import ProTable from '/@/components/ProTable/index.vue';
import PreviewImage from '/@/components/PreviewImage/index.vue';
import Sortable from 'sortablejs';
@ -432,6 +437,7 @@ const formData = ref({
registrationLimit: {
individualProjectLimit: 1,
teamProjectLimit: 1,
refereeLimit: 1,
},
},
// 步骤4:费用与账户配置
@ -491,7 +497,11 @@ const openDialog = async (id) => {
formData.value.units = { units: d.units ?? [] };
formData.value.projectConfig = {
projects: d.projects ?? [],
registrationLimit: d.registrationLimit ?? { individualProjectLimit: 1, teamProjectLimit: 1 },
registrationLimit: {
individualProjectLimit: d.registrationLimit?.individualProjectLimit ?? 1,
teamProjectLimit: d.registrationLimit?.teamProjectLimit ?? 1,
refereeLimit: d.registrationLimit?.refereeLimit ?? 1,
},
};
formData.value.feeConfig = { ...formData.value.feeConfig, ...(d.feeConfig ?? {}) };
console.log('formData.value', formData.value);
@ -849,6 +859,15 @@ const onSave = async () => {
}
}
const confirmArrangement = async () => {
await ElMessageBox.confirm('确认编排后需继续完成裁判分配,是否继续?', '重要提示', { type: 'warning', confirmButtonText: '继续' });
const settingsWithSortIndex = eventlist.value.map((item,index)=>({ProjectId:item.projectId||item.id,VenueCount:item.venueCount,SortIndex:index+1}));
const saveRes = await eventarrangementPut({eventId:initParam.value.id,settings:settingsWithSortIndex});
if(!saveRes.succeeded) return;
const res = await eventarrangementConfirmPost(initParam.value.id);
if(res.succeeded) ElMessage.success(`编排已确认:${res.data.sessionCount}个场次,${res.data.entryCount}条出场记录`);
}
//export default {
const downloadByBlob = (blob, filename = '赛事编排.xlsx') => {
const url = window.URL.createObjectURL(blob);

View File

@ -142,6 +142,12 @@
</div>
</el-form-item>
<el-form-item label="计分规则:" prop="scoreRuleId">
<el-select v-model="formData.scoreRuleId" placeholder="请选择计分规则" style="width: 100%">
<el-option v-for="rule in scoreRules" :key="rule.id" :label="rule.ruleName" :value="rule.id" />
</el-select>
</el-form-item>
<!-- 备注 -->
<el-form-item label="备注:" prop="remark">
<el-input v-model="formData.remark" placeholder="请输入备注" type="textarea" :rows="3" maxlength="500" show-word-limit></el-input>
@ -234,6 +240,7 @@
<span class="duration-label">秒</span>
</div>
</el-form-item>
<el-form-item label="计分规则:"><el-input v-model="detailData.scoreRuleName" disabled /></el-form-item>
<!-- 备注 -->
<el-form-item label="备注:">
@ -250,12 +257,15 @@
</template>
<script setup name="project">
import { defineAsyncComponent, ref, reactive } from 'vue';
import { defineAsyncComponent, ref, reactive, onMounted } from 'vue';
import { ElMessage, ElForm } from 'element-plus';
import SysMenuApi from '/@/api/SysMenuApi';
import { auths } from '/@/utils/authFunction';
import ProTable from '/@/components/ProTable/index.vue';
import { projectPageGet, projectAddPost, projectEditPut, projectDeleteDelete, projectDetailGet } from '/@/api/match/project';
import { scoreRuleListGet } from '/@/api/match/scorerule';
const scoreRules = ref([]);
onMounted(async () => { const res = await scoreRuleListGet(); scoreRules.value = res.data || []; });
// 定义表单引用类型
const formRef = ref();
@ -301,6 +311,7 @@ const formData = ref({
remark: '',
durationMinutes: '',
durationSeconds: '',
scoreRuleId: null,
groups: [
{
groupName: '',
@ -394,6 +405,7 @@ const formRules = ref({
completionCount: [{ required: true, validator: validateCompletionCount, trigger: ['blur', 'change'] }],
completionCountRange: [{ required: true, validator: validateCompletionCount, trigger: ['blur', 'change'] }],
fee: [{ required: true, message: '请输入费用', trigger: 'blur' }],
scoreRuleId: [{ required: true, message: '请选择计分规则', trigger: 'change' }],
duration: [
{ required: true, validator: validateDuration, trigger: ['blur', 'change'] }
],
@ -443,6 +455,7 @@ const columns = ref([
{ prop: 'fee', label: '费用(元)' },
{ prop: 'time', label: '时长' },
{ prop: 'scoreRuleName', label: '计分规则' },
{ prop: 'remark', label: '备注' },
@ -465,6 +478,7 @@ const onOpenMenu = async (row) => {
remark: '',
durationMinutes: '',
durationSeconds: '',
scoreRuleId: null,
groups: [
{
groupName: '',

View File

@ -0,0 +1,92 @@
<template>
<div class="layout-padding referee-page">
<el-card shadow="never" class="search-card">
<el-form :model="query" inline label-width="70px">
<el-form-item label="裁判姓名"><el-input v-model="query.name" placeholder="请输入" clearable /></el-form-item>
<el-form-item label="地区"><el-cascader v-model="query.region" :options="regionOptions" :props="regionProps" clearable placeholder="全部" /></el-form-item>
<el-form-item label="级别"><el-select v-model="query.level" clearable placeholder="全部"><el-option v-for="item in levels" :key="item.value" v-bind="item" /></el-select></el-form-item>
<el-form-item label="账号"><el-input v-model="query.mobile" placeholder="请输入" clearable /></el-form-item>
<el-form-item label="裁判角色"><el-select v-model="query.role" clearable placeholder="全部"><el-option v-for="item in roles" :key="item.value" v-bind="item" /></el-select></el-form-item>
<el-form-item label="审核状态"><el-select v-model="query.auditStatus" clearable placeholder="全部"><el-option v-for="item in auditOptions" :key="item.value" v-bind="item" /></el-select></el-form-item>
<el-form-item label="生成时间"><el-date-picker v-model="query.dates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="search">搜索</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="table-card">
<el-button type="primary" :icon="Plus" @click="openAdd">新增</el-button>
<el-table v-loading="loading" :data="rows" border stripe class="referee-table">
<el-table-column prop="name" label="裁判姓名" min-width="100" />
<el-table-column prop="regionName" label="地区" min-width="180" />
<el-table-column prop="levelName" label="级别" min-width="130" />
<el-table-column label="证件" width="90" align="center"><template #default="{ row }"><el-image class="certificate" :src="row.certificateUrl" :preview-src-list="[row.certificateUrl]" preview-teleported fit="cover" /></template></el-table-column>
<el-table-column prop="mobile" label="账号" min-width="125" />
<el-table-column label="裁判角色" min-width="220"><template #default="{ row }">{{ row.roleNames?.join(' / ') }}</template></el-table-column>
<el-table-column label="审核状态" width="100"><template #default="{ row }"><el-tag :type="auditTag(row.auditStatus)">{{ auditText(row.auditStatus) }}</el-tag></template></el-table-column>
<el-table-column prop="createdTime" label="生成时间" min-width="165" />
<el-table-column label="操作" fixed="right" width="190"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-button v-if="row.auditStatus === 1" link type="success" @click="audit(row, 2)">通过</el-button><el-button v-if="row.auditStatus === 1" link type="warning" @click="audit(row, 3)">拒绝</el-button><el-button link type="danger" @click="remove(row)">删除</el-button></template></el-table-column>
</el-table>
<el-pagination v-model:current-page="query.pageNo" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @change="load" />
</el-card>
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑裁判' : '新增裁判'" width="620px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="105px">
<el-form-item label="裁判姓名" prop="name"><el-input v-model="form.name" maxlength="64" /></el-form-item>
<el-form-item label="地区" prop="region"><el-cascader ref="regionCascader" v-model="form.region" :options="regionOptions" :props="regionProps" class="full" /></el-form-item>
<el-form-item label="级别" prop="level"><el-select v-model="form.level" class="full"><el-option v-for="item in levels" :key="item.value" v-bind="item" /></el-select></el-form-item>
<el-form-item label="裁判证件" prop="certificateUrl"><UploadImg v-model:image-url="form.certificateUrl" width="90px" height="90px"><template #tip>支持 jpg/png,大小不超过 5MB</template></UploadImg></el-form-item>
<el-form-item label="手机号(账号)" prop="mobile"><el-input v-model="form.mobile" maxlength="11" /></el-form-item>
<el-form-item v-if="needCode" label="验证码" prop="verificationCode"><el-input v-model="form.verificationCode" class="code-input" maxlength="6" /><el-button :disabled="countdown > 0 || !phoneValid" @click="sendCode">{{ countdown ? `${countdown}s` : '获取验证码' }}</el-button></el-form-item>
<el-form-item label="裁判角色" prop="roleValues"><el-select v-model="form.roleValues" multiple class="full" placeholder="请选择"><el-option v-for="item in roles" :key="item.value" v-bind="item" /></el-select></el-form-item>
</el-form>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">确定</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Plus, Refresh, Search } from '@element-plus/icons-vue';
import UploadImg from '/@/components/Upload/Img.vue';
import { regionCitiesbyprovinceGet, regionDistrictsbycityGet, regionProvincesGet } from '/@/api/match/area';
import { commonSmsSendVerificationCodePost } from '/@/api/match/common';
import { refereeAddPost, refereeAuditPost, refereeDelete, refereePageGet, refereeUpdatePost } from '/@/api/match/referee';
const levels = [{ value: 1, label: '国际级裁判员' }, { value: 2, label: '国家级裁判员' }, { value: 3, label: '一级裁判员' }, { value: 4, label: '二级裁判员' }, { value: 5, label: '三级裁判员' }];
const roles = [{ value: 1, label: '主裁' }, { value: 2, label: '表现裁判' }, { value: 4, label: '难度裁判' }, { value: 8, label: '创意编排(规定赛)' }, { value: 16, label: '完成质量(规定赛)' }];
const auditOptions = [{ value: 1, label: '待审核' }, { value: 2, label: '已通过' }, { value: 3, label: '已拒绝' }];
const query = reactive({ name: '', region: [], level: null, mobile: '', role: null, auditStatus: null, dates: [], pageNo: 1, pageSize: 10 });
const rows = ref([]); const total = ref(0); const loading = ref(false); const dialogVisible = ref(false); const saving = ref(false); const formRef = ref(); const regionCascader = ref(); const regionOptions = ref([]); const originalMobile = ref(''); const countdown = ref(0);
const emptyForm = () => ({ id: null, name: '', region: [], level: null, certificateUrl: '', mobile: '', verificationCode: '', roleValues: [] });
const form = reactive(emptyForm());
const phoneValid = computed(() => /^1[3-9]\d{9}$/.test(form.mobile));
const needCode = computed(() => !form.id || form.mobile !== originalMobile.value);
const rules = computed(() => ({ name: [{ required: true, message: '请输入裁判姓名', trigger: 'blur' }], region: [{ type: 'array', required: true, len: 3, message: '请选择完整地区', trigger: 'change' }], level: [{ required: true, message: '请选择级别', trigger: 'change' }], certificateUrl: [{ required: true, message: '请上传裁判证件', trigger: 'change' }], mobile: [{ required: true, pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }], verificationCode: needCode.value ? [{ required: true, message: '请输入验证码', trigger: 'blur' }] : [], roleValues: [{ type: 'array', required: true, min: 1, message: '至少选择一个裁判角色', trigger: 'change' }] }));
const regionProps = { value: 'code', label: 'name', lazy: true, lazyLoad: async (node, resolve) => { const { level, value } = node; const data = level === 0 ? await regionProvincesGet() : level === 1 ? await regionCitiesbyprovinceGet({ provinceCode: value }) : await regionDistrictsbycityGet({ cityCode: value }); resolve((data?.data || data || []).map(x => ({ ...x, leaf: level === 2 }))); } };
async function load() { loading.value = true; try { const params = { ...query, region: undefined, dates: undefined, regionCode: query.region?.at(-1), startTime: query.dates?.[0], endTime: query.dates?.[1] }; const res = await refereePageGet(params); const data = res?.data || res; rows.value = data?.rows || []; total.value = data?.total || 0; } finally { loading.value = false; } }
function search() { query.pageNo = 1; load(); }
function reset() { Object.assign(query, { name: '', region: [], level: null, mobile: '', role: null, auditStatus: null, dates: [], pageNo: 1 }); load(); }
function openAdd() { Object.assign(form, emptyForm()); originalMobile.value = ''; dialogVisible.value = true; }
function openEdit(row) { Object.assign(form, { id: row.id, name: row.name, region: [row.provinceCode, row.cityCode, row.districtCode], level: row.level, certificateUrl: row.certificateUrl, mobile: row.mobile, verificationCode: '', roleValues: roles.filter(x => (row.roles & x.value) === x.value).map(x => x.value) }); originalMobile.value = row.mobile; dialogVisible.value = true; }
async function sendCode() { await commonSmsSendVerificationCodePost({ phone: form.mobile }); ElMessage.success('验证码已发送'); countdown.value = 60; const timer = setInterval(() => { countdown.value -= 1; if (!countdown.value) clearInterval(timer); }, 1000); }
async function save() { if (!await formRef.value.validate().catch(() => false)) return; const labels = regionCascader.value?.getCheckedNodes?.()[0]?.pathLabels || form.region; const payload = { ...form, provinceCode: form.region[0], provinceName: labels[0], cityCode: form.region[1], cityName: labels[1], districtCode: form.region[2], districtName: labels[2], roles: form.roleValues.reduce((sum, v) => sum | v, 0), roleValues: undefined, region: undefined }; saving.value = true; try { form.id ? await refereeUpdatePost(payload) : await refereeAddPost(payload); ElMessage.success('保存成功'); dialogVisible.value = false; load(); } finally { saving.value = false; } }
async function audit(row, status) { await refereeAuditPost({ id: row.id, auditStatus: status }); ElMessage.success(status === 2 ? '审核通过' : '已拒绝'); load(); }
async function remove(row) { await ElMessageBox.confirm(`确定删除裁判“${row.name}”吗?`, '删除确认', { type: 'warning' }); await refereeDelete(row.id); ElMessage.success('删除成功'); load(); }
const auditText = value => auditOptions.find(x => x.value === value)?.label || '-';
const auditTag = value => value === 2 ? 'success' : value === 3 ? 'danger' : 'warning';
onMounted(async () => { const data = await regionProvincesGet(); regionOptions.value = (data?.data || data || []).map(x => ({ ...x, leaf: false })); load(); });
</script>
<style scoped>
.referee-page { display: flex; flex-direction: column; gap: 12px; }
.search-card :deep(.el-card__body) { padding-bottom: 2px; }
.search-card .el-input, .search-card .el-select, .search-card .el-cascader { width: 180px; }
.table-card { flex: 1; }
.referee-table { margin: 14px 0; }
.certificate { width: 48px; height: 36px; border-radius: 3px; }
.el-pagination { justify-content: flex-end; }
.full { width: 100%; }
.code-input { width: calc(100% - 120px); margin-right: 10px; }
</style>