优化大屏显示

This commit is contained in:
马梓彭 2026-09-09 18:30:09 +08:00
parent a6f520a787
commit 9aad0a02f1
7 changed files with 170 additions and 23 deletions

View File

@ -175,8 +175,19 @@ function normalizeScoringMenuRoutes(routes: any[]) {
const routeName = String(item?.name || item?.routeName || '');
const menuCode = String(item?.code || item?.meta?.code || '');
const title = String(item?.meta?.title || '');
const path = String(item?.path || '');
const isScoringOrderBookTitle = title === '秩序册' && String(item?.path || '').startsWith('/scoring/');
const isOrderBook = routeName === 'scoringOrderBook' || menuCode === 'scoring:orderbook' || isScoringOrderBookTitle;
const isScoringCastTitle = title === '成绩投屏' && path.startsWith('/scoring/');
const isScoringCast = routeName === 'scoringCast' || menuCode === 'scoring:cast' || path === '/scoring/cast' || isScoringCastTitle;
if (isScoringCast) {
return {
...item,
path: '/scoring/cast',
component: 'scoring/screen/index',
children,
};
}
if (!isOrderBook) return children === item?.children ? item : { ...item, children };
return {
...item,
@ -201,7 +212,7 @@ function ensureScoringOperationalRoutes(routes: any[]) {
{ path: '/scoring/launcher', name: 'scoringLauncherFallback', component: 'scoring/launcher/index', meta: { title: '工作台', isKeepAlive: true } },
{ path: '/scoring/orderbook', name: 'scoringOrderBookFallback', component: 'scoring/orderbook/index', meta: { title: '秩序册', isHide: true, isKeepAlive: true } },
{ path: '/scoring/workbench', name: 'scoringWorkbenchFallback', component: 'scoring/workbench/index', meta: { title: '成绩管理', isHide: true, isKeepAlive: true } },
{ path: '/scoring/cast', name: 'scoringCastFallback', component: 'scoring/workbench/index', meta: { title: '成绩投屏', isHide: true, isKeepAlive: true } },
{ path: '/scoring/cast', name: 'scoringCastFallback', component: 'scoring/screen/index', meta: { title: '成绩投屏', isHide: true, isKeepAlive: true } },
].filter((item) => !paths.has(item.path));
return [...routes, ...fallbacks];
}

View File

@ -602,10 +602,19 @@ export const staticRoutes: Array<RouteRecordRaw> = [
title: '登录',
},
},
{
path: '/scoring/cast',
name: 'scoringCast',
component: () => import('/@/views/scoring/cast/index.vue'),
meta: {
title: '赛事大屏',
isPublic: true,
},
},
{
path: '/scoring/screen',
name: 'scoringScreen',
component: () => import('/@/views/scoring/screen/index.vue'),
redirect: (to) => ({ path: '/scoring/cast', query: { ...to.query, castMode: 'score' } }),
meta: {
title: '成绩大屏',
isPublic: true,
@ -614,7 +623,7 @@ export const staticRoutes: Array<RouteRecordRaw> = [
{
path: '/scoring/checkin-screen',
name: 'scoringCheckinScreen',
component: () => import('/@/views/scoring/checkin-screen/index.vue'),
redirect: (to) => ({ path: '/scoring/cast', query: { ...to.query, castMode: 'checkin' } }),
meta: {
title: '检录进度大屏',
isPublic: true,

View File

@ -0,0 +1,116 @@
<template>
<div class="scoring-cast-container" @mousemove="showSwitch">
<!-- 模式切换浮动按钮 -->
<div class="cast-switch-bar" :class="{ visible: switchVisible }">
<div class="switch-group">
<button :class="{ active: currentMode === 'score' }" @click="switchMode('score')">成绩大屏</button>
<button :class="{ active: currentMode === 'checkin' }" @click="switchMode('checkin')">检录大屏</button>
</div>
</div>
<!-- 大屏内容 -->
<ScoreScreen v-if="currentMode === 'score'" />
<CheckinScreen v-else-if="currentMode === 'checkin'" />
</div>
</template>
<script setup name="scoringCast">
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import ScoreScreen from '/@/views/scoring/screen/index.vue';
import CheckinScreen from '/@/views/scoring/checkin-screen/index.vue';
const route = useRoute();
const router = useRouter();
const currentMode = ref(route.query.castMode === 'checkin' || route.query.mode === 'checkin' ? 'checkin' : 'score');
const switchVisible = ref(true);
let hideTimer = null;
function showSwitch() {
switchVisible.value = true;
if (hideTimer) clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
switchVisible.value = false;
}, 3500);
}
function switchMode(mode) {
currentMode.value = mode;
router.replace({ query: { ...route.query, castMode: mode } });
}
watch(
() => route.query.castMode,
(val) => {
if (val === 'checkin' || val === 'score') {
currentMode.value = val;
}
}
);
onMounted(() => {
showSwitch();
});
onBeforeUnmount(() => {
if (hideTimer) clearTimeout(hideTimer);
});
</script>
<style scoped lang="scss">
.scoring-cast-container {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
}
.cast-switch-bar {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 99999;
transition: all 0.3s ease;
opacity: 0;
pointer-events: none;
&.visible {
opacity: 1;
pointer-events: auto;
}
.switch-group {
display: flex;
background: rgba(0, 15, 50, 0.78);
border: 1px solid rgba(64, 158, 255, 0.45);
border-radius: 20px;
padding: 3px;
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
button {
padding: 6px 18px;
border: none;
border-radius: 16px;
background: transparent;
color: #b0c8e8;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover {
color: #ffffff;
}
&.active {
background: linear-gradient(135deg, #1890ff, #0050b3);
color: #ffffff;
font-weight: bold;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.5);
}
}
}
}
</style>

View File

@ -43,13 +43,15 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { checkinScreenCastGet } from '/@/api/match/workbench';
const route=useRoute(), eventId=Number(route.query.eventId)||0;
const route=useRoute();
let savedContext={};try{savedContext=JSON.parse(sessionStorage.getItem('scoring-context')||'{}')}catch{savedContext={}}
const eventId=Number(route.query.eventId)||Number(savedContext.eventId)||0;
const data=ref({stages:[],units:[]}), offline=ref(false), clock=ref(''), week=ref(''), fullscreen=ref(false), controlsVisible=ref(true);
let pollTimer,clockTimer,controlTimer,unitPage=ref(0),unitTimer,serverBaseMs=Date.now(),syncedAtMs=Date.now(),requesting=false;
const stages=computed(()=>data.value.stages||[]),currentCheckin=computed(()=>data.value.currentCheckin),currentCompeting=computed(()=>data.value.currentCompeting);
const visibleUnits=computed(()=>{const all=data.value.units||[];const pages=Math.max(1,Math.ceil(all.length/7));const page=unitPage.value%pages;return all.slice(page*7,page*7+7)});
const currentClockDate=()=>new Date(serverBaseMs+(Date.now()-syncedAtMs));
async function load(){if(!eventId||requesting)return;requesting=true;try{const res=await checkinScreenCastGet({eventId});data.value=res?.data||{stages:[],units:[]};offline.value=false;syncClock()}catch{offline.value=true}finally{requesting=false}}
async function load(){if(!eventId||requesting)return;requesting=true;try{const res=await checkinScreenCastGet({eventId,_t:Date.now()});const body=res?.data??res;data.value=body?.data??body??{stages:[],units:[]};offline.value=false;syncClock()}catch{offline.value=true}finally{requesting=false}}
function syncClock(){const raw=data.value.serverTime||data.value.serverTimeText;const parsed=raw?new Date(String(raw).replace(' ','T')):new Date();serverBaseMs=Number.isNaN(parsed.getTime())?Date.now():parsed.getTime();syncedAtMs=Date.now();tick()}
function tick(){const now=currentClockDate();clock.value=formatDate(now);week.value=['星期日','星期一','星期二','星期三','星期四','星期五','星期六'][now.getDay()]}
function formatDate(d){const p=n=>String(n).padStart(2,'0');return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`}

View File

@ -15,22 +15,24 @@
</template>
<script setup>
import { ref } from 'vue';
import { ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { orderBookTemplateGet, orderBookImportPost, orderBookConfirmPost } from '/@/api/match/workbench';
import { resolveScoringEvent } from '/@/utils/scoringContext';
const route=useRoute(),router=useRouter();
const scoringEvent=resolveScoringEvent(route.query);
const eventId=scoringEvent.eventId,eventName=scoringEvent.eventName;
const currentEvent=resolveScoringEvent(route.query);
const eventId=ref(currentEvent.eventId),eventName=ref(currentEvent.eventName);
const file=ref(null),result=ref(null),parsing=ref(false),confirming=ref(false);
if(!eventId)router.replace({path:'/scoring/launcher',query:{sourceMode:'orderbook'}});
function changeEvent(){router.push({path:'/scoring/launcher',query:{sourceMode:'orderbook'}})}
if(!eventId.value)router.replace({path:'/scoring/launcher',query:{selectEvent:'1'}});
function changeEvent(){router.push({path:'/scoring/launcher',query:{selectEvent:'1'}})}
function syncEvent(){const next=resolveScoringEvent(route.query);eventId.value=next.eventId;eventName.value=next.eventName;file.value=null;result.value=null}
watch(()=>[route.query.eventId,route.query.eventName],syncEvent);
function selectFile(uploadFile){file.value=uploadFile.raw;result.value=null}
function saveBlob(blob,name){const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=name;document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url)}
async function downloadTemplate(){saveBlob(await orderBookTemplateGet(),'秩序册导入模板.xlsx')}
async function parseFile(){parsing.value=true;try{const res=await orderBookImportPost(eventId,file.value);result.value=res?.data||null;if(result.value)ElMessage.success(result.value.errorCount?'解析完成,请处理错误项':'解析通过,请确认导入')}finally{parsing.value=false}}
async function parseFile(){parsing.value=true;try{const res=await orderBookImportPost(eventId.value,file.value);result.value=res?.data||null;if(result.value)ElMessage.success(result.value.errorCount?'解析完成,请处理错误项':'解析通过,请确认导入')}finally{parsing.value=false}}
async function confirmImport(){await ElMessageBox.confirm('确认导入当前秩序册?同一赛事原有编排数据将按导入规则处理。','确认导入',{type:'warning'});confirming.value=true;try{const res=await orderBookConfirmPost(result.value.batchId);ElMessage.success(`导入完成:${res?.data?.entryCount||0} 条出场记录`);result.value=null;file.value=null}finally{confirming.value=false}}
</script>

File diff suppressed because one or more lines are too long

View File

@ -3,7 +3,7 @@
<section class="workbench-main">
<header class="topbar">
<div><h1>{{ tabTitle }}</h1><p>{{ eventName }} · {{ tabDescription }}</p></div>
<div class="top-actions"><span :class="['live-dot', { offline }]">● {{ offline ? '连接异常' : '实时同步' }}</span><el-button v-if="activeTab !== 'screen'" :type="activeTab==='corrections'?'primary':'default'" @click="toggleCorrections">{{activeTab==='corrections'?'返回成绩列表':'修正申请'}}</el-button><el-button @click="openCheckinScreen">检录大屏</el-button><el-button @click="goLauncher">切换赛事</el-button><el-button @click="toggleFullscreen">全屏</el-button></div>
<div class="top-actions"><span :class="['live-dot', { offline }]">● {{ offline ? '连接异常' : '实时同步' }}</span><el-button v-if="activeTab !== 'screen'" :type="activeTab==='corrections'?'primary':'default'" @click="toggleCorrections">{{activeTab==='corrections'?'返回成绩列表':'修正申请'}}</el-button><el-button @click="openCheckinScreen">检录大屏</el-button><el-button @click="goLauncher">切换赛事</el-button></div>
</header>
<template v-if="activeTab !== 'corrections'">
@ -30,7 +30,7 @@
<el-button @click="resetFilters">重置</el-button>
<span class="filter-spacer"></span>
<el-button v-if="activeTab === 'screen' || activeTab === 'results'" type="success" :disabled="!rankingOptions.length" @click="openRanking">投出排名</el-button>
<el-button v-if="activeTab === 'screen'" @click="openScoreScreen">打开成绩大屏</el-button>
<el-button v-if="activeTab === 'screen'" @click="openScoreScreen">全屏显示</el-button>
<template v-if="activeTab === 'results'">
<el-button type="primary" :loading="actionLoading" :disabled="!selectedResultIds.length" @click="batchConfirm">批量确认</el-button>
<el-button @click="exportResults">成绩导出</el-button>
@ -163,7 +163,6 @@ function openScoreScreen(){window.open(router.resolve({path:'/scoring/screen',qu
function openCheckinScreen(){window.open(router.resolve({path:'/scoring/checkin-screen',query:{eventId:eventId.value}}).href,'_blank')}
function goLauncher(){router.push({path:'/scoring/launcher'})}
function toggleCorrections(){activeTab.value=activeTab.value==='corrections'?'results':'corrections';if(activeTab.value==='corrections')loadCorrections()}
function toggleFullscreen(){if(!document.fullscreenElement)document.documentElement.requestFullscreen?.();else document.exitFullscreen?.()}
function startPolling(){pollTimer=window.setInterval(()=>{if(document.visibilityState==='visible'){loadCurrentCast();if(activeTab.value!=='corrections')loadEntries(true)}},5000)}
watch(()=>selectedSession.value?.venueId,loadCurrentCast);
watch(()=>route.path,(path)=>{activeTab.value=path==='/scoring/cast'?'screen':'results'});