feat(scoring): 完善大屏双语主题与页面加载恢复

检录大屏:统一中文在上、英文在下的标题与状态标签,放大关键信息;按实际视口延展 1920x1080 基准画布,使非 16:9 屏幕充分利用显示区域。

成绩与排名大屏:重整赛事标题、项目组别、选手成绩和分项卡片的双语层级及动画;加入仅开发环境可用的成绩/排名样例预览,并优化自适应全屏布局。

赛事控制台:增加亮色/暗色主题切换、主题色和文字色设置及预览;按赛事在本机保存配置,通过 BroadcastChannel 和 storage 事件同步到已打开的大屏,兼容旧配色记录。

路由稳定性:动态导入因短暂断网或开发服务器重启失败时,限一次重新打开目标页;持续失败或离线时显示提示,避免循环刷新。

验证:npm run build:local 通过;1920x1080 成绩与排名亮色预览已检查;git diff --cached --check 通过。
This commit is contained in:
ltx 2026-10-21 13:10:27 +08:00
parent 5c51ec85a4
commit 2dd6e48247
5 changed files with 504 additions and 38 deletions

View File

@ -1,6 +1,7 @@
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import { ElMessage } from 'element-plus';
import pinia from '/@/stores/index';
import { storeToRefs } from 'pinia';
import { useKeepALiveNames } from '/@/stores/keepAliveNames';
@ -140,5 +141,30 @@ router.afterEach(() => {
NProgress.done();
});
// 开发服务器短暂重启或网络抖动会使浏览器缓存一次失败的动态 import。
// 重新请求同一个模块并不能清除该失败记录,必须重新加载文档;每个目标页限一次,避免故障持续时循环刷新。
router.onError((error, to) => {
NProgress.done();
const message = error instanceof Error ? error.message : String(error);
if (!/Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i.test(message)) return;
if (!navigator.onLine) {
ElMessage.error('网络已断开,恢复连接后请重新进入该页面');
return;
}
const href = router.resolve(to).href;
const retryKey = `route-import-retry:${href}`;
try {
const lastRetry = Number(sessionStorage.getItem(retryKey) || 0);
if (Date.now() - lastRetry > 60_000) {
sessionStorage.setItem(retryKey, String(Date.now()));
window.location.replace(new URL(href, window.location.href).href);
return;
}
} catch (storageError) {
console.warn('无法记录路由模块重试状态', storageError);
}
ElMessage.error('页面资源加载失败,请确认本地服务正常运行后刷新页面');
});
// 导出路由
export default router;

View File

@ -4,14 +4,14 @@
<div class="ambient ambient-one"></div><div class="ambient ambient-two"></div><div class="grid-glow"></div>
<header class="screen">
<div class="title-wing wing-left"><i></i><i></i><i></i></div>
<div class="event-title"><small>CHECK-IN PROGRESS</small><h1>{{ data.eventName || route.query.eventName || '赛事检录大屏' }}</h1><span>检录进度</span></div>
<div class="event-title"><h1>{{ data.eventName || route.query.eventName || '赛事检录大屏' }}</h1><span>检录进度<small>CHECK-IN PROGRESS</small></span></div>
<div class="title-wing"><i></i><i></i><i></i></div>
<div class="time-box"><b>{{ timeText }}</b><span>{{ dateText }} · {{ week }}</span></div>
</header>
<main class="screen-content">
<aside class="schedule-panel">
<div class="section-title"><span class="section-icon"><el-icon><Calendar /></el-icon></span><div><small>ORDER OF EVENTS</small><h2>赛事检录顺序</h2></div></div>
<div class="section-title"><span class="section-icon"><el-icon><Calendar /></el-icon></span><div><h2>赛事检录顺序</h2><small>ORDER OF EVENTS</small></div></div>
<div class="stage-list">
<div v-if="hiddenStageCount.before" class="stage-overflow stage-overflow-top">上方还有 {{ hiddenStageCount.before }} 个项目</div>
<div v-for="stage in visibleProjectStages" :key="stage.eventProjectId" :class="['stage-item',`status-${stage.stageStatus}`,{current:stage._isCurrent}]">
@ -26,7 +26,7 @@
<section class="main-board">
<div class="current-head">
<div class="section-title"><span class="section-icon"><el-icon><UserFilled /></el-icon></span><div><small>CURRENT CHECK-IN</small><h2>当前检录项目</h2></div></div>
<div class="section-title"><span class="section-icon"><el-icon><UserFilled /></el-icon></span><div><h2>当前检录项目</h2><small>CURRENT CHECK-IN</small></div></div>
<div class="live-badge"><i></i>实时更新</div>
</div>
<div class="checkin-panel">
@ -45,9 +45,9 @@
<div v-if="unitPageCount > 1" class="page-dots"><i v-for="page in unitPageCount" :key="page" :class="{active:page-1===unitPage}"></i></div>
</div>
<div class="competing-head"><div class="section-title"><span class="section-icon gold"><el-icon><Trophy /></el-icon></span><div><small>NOW COMPETING</small><h2>当前正在比赛</h2></div></div></div>
<div class="competing-head"><div class="section-title"><span class="section-icon gold"><el-icon><Trophy /></el-icon></span><div><h2>当前正在比赛</h2><small>NOW COMPETING</small></div></div></div>
<div class="competing-panel">
<div class="competing-mark"><el-icon><Flag /></el-icon><span>ON STAGE</span></div>
<div class="competing-mark"><el-icon><Flag /></el-icon><span>上场中<small>ON STAGE</small></span></div>
<div class="competition-name"><small>项目 / 组别</small><strong>{{ currentCompeting?.displayName || '等待比赛信息' }}</strong></div>
<div class="info-cell"><small>参赛单位</small><b>{{ currentCompeting?.unitName || '—' }}</b></div>
<div class="info-cell"><small>队伍</small><b>{{ currentCompeting?.teamName || '—' }}</b></div>
@ -73,12 +73,17 @@ import { checkinScreenCastGet } from '/@/api/match/workbench';
const route=useRoute();
const storesTagsViewRoutes=useTagsViewRoutes();
const fullscreenDocument=route.query.presentation==='1'&&window.parent!==window?window.parent.document:document;
const viewportRef=ref(null),scale=ref(1);
const viewportRef=ref(null),viewport=ref({width:window.innerWidth,height:window.innerHeight});
let savedContext={};try{savedContext=JSON.parse(sessionStorage.getItem('scoring-context')||'{}')}catch{savedContext={}}
const eventId=Number(route.query.eventId)||Number(savedContext.eventId)||0;
const isBigScreen=computed(()=>storesTagsViewRoutes.isTagsViewCurrenFull);
const fullscreenButtonText=computed(()=>fullscreenDocument.fullscreenElement?'退出全屏':'全屏展示');
const stageStyle=computed(()=>({transform:`translate(-50%, -50%) scale(${scale.value})`}));
const scale=computed(()=>Math.max(.01,Math.min(viewport.value.width/1920,viewport.value.height/1080)));
const stageStyle=computed(()=>({
'--stage-width':`${viewport.value.width/scale.value}px`,
'--stage-height':`${viewport.value.height/scale.value}px`,
transform:`translate(-50%, -50%) scale(${scale.value})`,
}));
const data=ref({stages:[],units:[]}),offline=ref(false),clock=ref(new Date()),controlsVisible=ref(false),unitPage=ref(0);
let pollTimer,clockTimer,controlTimer,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);
@ -127,7 +132,7 @@ const dateText=computed(()=>`${clock.value.getFullYear()}.${pad(clock.value.getM
const week=computed(()=>data.value.weekDayText||['星期日','星期一','星期二','星期三','星期四','星期五','星期六'][clock.value.getDay()]);
function stageStatusLabel(status){return Number(status)===1?'检录中':Number(status)===2?'即将开始':Number(status)===4?'已完成':'等待中'}
function formatUnitSession(unit){const index=Number(unit?.sessionIndex);return index>0?`第 ${index} 场`:'场次待定'}
function resize(){const host=viewportRef.value;if(!host)return;scale.value=Math.min(host.clientWidth/1920,host.clientHeight/1080)}
function resize(){const host=viewportRef.value;if(!host)return;viewport.value={width:host.clientWidth,height:host.clientHeight}}
function tick(){clock.value=new Date(serverBaseMs+(Date.now()-syncedAtMs))}
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()}
async function load(){if(!eventId||requesting)return;requesting=true;try{const previousChecked=new Set((data.value.units||[]).filter(unit=>unit.checkinStatus>=2).map(unit=>String(unit.unitKey)));const res=await checkinScreenCastGet({eventId,_t:Date.now()});const body=res?.data??res;const nextData=body?.data??body??{stages:[],units:[]};const hasNewChecked=(nextData.units||[]).some(unit=>unit.checkinStatus>=2&&!previousChecked.has(String(unit.unitKey)));data.value=nextData;unitPage.value=hasNewChecked?0:Math.min(unitPage.value,unitPageCount.value-1);offline.value=false;syncClock()}catch{offline.value=true}finally{requesting=false}}
@ -146,7 +151,7 @@ onBeforeUnmount(()=>{[pollTimer,clockTimer,unitTimer].forEach(window.clearInterv
</script>
<style scoped lang="scss">
.checkin-viewport{position:relative;width:100%;height:100%;min-height:100vh;overflow:hidden;background:#01051e;font-family:"Microsoft YaHei","PingFang SC",sans-serif}.checkin-stage{--cyan:#22d8ff;--blue:#148cff;position:absolute;left:50%;top:50%;width:1920px;height:1080px;overflow:hidden;color:#fff;background:radial-gradient(circle at 50% 30%,rgba(19,100,222,.23),transparent 42%),linear-gradient(145deg,#031867 0%,#071452 45%,#030b35 100%);transform-origin:center}.checkin-stage::before{content:"";position:absolute;inset:0;background:linear-gradient(90deg,rgba(37,218,255,.025) 1px,transparent 1px),linear-gradient(rgba(37,218,255,.025) 1px,transparent 1px);background-size:54px 54px;mask-image:linear-gradient(to bottom,transparent,black 30%,black)}.ambient{position:absolute;border-radius:50%;filter:blur(20px);opacity:.65}.ambient-one{left:-240px;top:220px;width:520px;height:520px;background:rgba(0,95,255,.22)}.ambient-two{right:-260px;bottom:-180px;width:650px;height:650px;background:rgba(0,194,255,.14)}.grid-glow{position:absolute;left:25%;right:0;bottom:-180px;height:420px;background:repeating-radial-gradient(ellipse at 50% 100%,transparent 0 28px,rgba(28,170,255,.13) 30px 32px,transparent 34px 52px);transform:perspective(300px) rotateX(54deg)}
.checkin-viewport{position:relative;width:100%;height:100%;min-height:100vh;overflow:hidden;background:#01051e;font-family:"Microsoft YaHei","PingFang SC",sans-serif}.checkin-stage{--cyan:#22d8ff;--blue:#148cff;position:absolute;left:50%;top:50%;width:var(--stage-width);height:var(--stage-height);overflow:hidden;color:#fff;background:radial-gradient(circle at 50% 30%,rgba(19,100,222,.23),transparent 42%),linear-gradient(145deg,#031867 0%,#071452 45%,#030b35 100%);transform-origin:center}.checkin-stage::before{content:"";position:absolute;inset:0;background:linear-gradient(90deg,rgba(37,218,255,.025) 1px,transparent 1px),linear-gradient(rgba(37,218,255,.025) 1px,transparent 1px);background-size:54px 54px;mask-image:linear-gradient(to bottom,transparent,black 30%,black)}.ambient{position:absolute;border-radius:50%;filter:blur(20px);opacity:.65}.ambient-one{left:-240px;top:220px;width:520px;height:520px;background:rgba(0,95,255,.22)}.ambient-two{right:-260px;bottom:-180px;width:650px;height:650px;background:rgba(0,194,255,.14)}.grid-glow{position:absolute;left:25%;right:0;bottom:-180px;height:420px;background:repeating-radial-gradient(ellipse at 50% 100%,transparent 0 28px,rgba(28,170,255,.13) 30px 32px,transparent 34px 52px);transform:perspective(300px) rotateX(54deg)}
.title-wing{display:flex;gap:8px}.title-wing i{display:block;width:64px;height:6px;background:linear-gradient(90deg,rgba(29,211,255,.15),#20d9ff);transform:skew(-34deg)}.title-wing i:nth-child(2){width:36px}.title-wing i:nth-child(3){width:14px}.wing-left{transform:scaleX(-1)}.checkin-stage>header{position:relative;z-index:2;height:138px;display:flex;align-items:center;justify-content:center;gap:35px;border-bottom:1px solid rgba(71,181,255,.24);background:linear-gradient(180deg,rgba(5,29,113,.8),rgba(5,20,78,.2))}.event-title{text-align:center}.event-title small{display:block;color:#4fcfff;font-size:13px;font-weight:700;letter-spacing:.32em}.event-title h1{max-width:1050px;margin:6px 0 4px;overflow:hidden;font-size:36px;line-height:1.25;text-overflow:ellipsis;white-space:nowrap;text-shadow:0 6px 20px rgba(0,61,205,.5)}.event-title>span{display:inline-flex;align-items:center;gap:10px;color:#7ee9ff;font-size:16px;letter-spacing:.3em}.event-title>span::before,.event-title>span::after{content:"";width:42px;height:1px;background:linear-gradient(90deg,transparent,#50defb)}.event-title>span::after{transform:scaleX(-1)}.time-box{position:absolute;right:50px;top:36px;text-align:right}.time-box b{display:block;font-size:28px;font-variant-numeric:tabular-nums;letter-spacing:.07em}.time-box span{display:block;margin-top:6px;color:#72aee2;font-size:13px}
.screen-content{position:relative;z-index:2;height:866px;display:grid;grid-template-columns:390px 1fr;gap:24px;padding:25px 34px 20px;box-sizing:border-box}.schedule{display:flex;flex-direction:column;padding:24px 22px;border:1px solid rgba(57,179,255,.28);border-radius:18px;background:linear-gradient(160deg,rgba(11,52,143,.62),rgba(2,20,77,.72));box-shadow:inset 0 1px rgba(119,224,255,.12),0 20px 50px rgba(0,0,26,.18);overflow:hidden}.section-title,.section-title+div,.section-title>div,.section-title{display:flex}.section-title,.section-title>div{align-items:center}.section-title>div{display:block}.section-icon{width:50px;height:50px;display:grid;place-items:center;margin-right:14px;border:1px solid rgba(47,213,255,.55);border-radius:14px;color:#62eaff;background:linear-gradient(145deg,rgba(18,179,255,.25),rgba(16,74,184,.16));box-shadow:inset 0 0 18px rgba(19,185,255,.13)}.section-icon .el-icon{font-size:27px}.section-title small,.schedule .section-title small{display:block;color:#4d92cd;font-size:11px;font-weight:700;letter-spacing:.2em}.section-title h2,.schedule h2{margin:4px 0 0;font-size:24px}.stage-list{flex:1;margin-top:22px;overflow:hidden}.stage-item{position:relative;display:grid;grid-template-columns:46px 1fr;gap:13px;min-height:118px}.stage-item::after{content:"";position:absolute;left:22px;top:46px;width:1px;height:62px;background:linear-gradient(#198eea,rgba(25,142,234,.12))}.stage-item:last-child::after{display:none}.stage-index{position:relative;width:46px;height:46px;display:grid;place-items:center;border:1px solid #2178c8;border-radius:50%;color:#66caff;background:#072f83;box-shadow:0 0 0 5px rgba(21,126,218,.08)}.stage-index::after{content:"";position:absolute;inset:6px;border:1px solid rgba(82,208,255,.25);border-radius:50%}.stage-index span{position:relative;z-index:1;font-size:14px;font-weight:700}.stage-copy{padding-top:0}.stage-copy strong{display:-webkit-box;overflow:hidden;font-size:17px;line-height:1.35;-webkit-line-clamp:2;-webkit-box-orient:vertical}.stage-copy>span{display:block;margin-top:3px;color:#84a8d3;font-size:12px}.stage-copy em{display:inline-flex;align-items:center;gap:6px;margin-top:6px;padding:4px 9px;border-radius:999px;color:#67bfff;background:rgba(25,128,218,.16);font-size:11px;font-style:normal}.stage-copy em i,.live-badge i,.controls>span i{width:6px;height:6px;border-radius:50%;background:currentColor;box-shadow:0 0 8px currentColor}.status-1 .stage-index{border-color:#29e4ab;color:#62f3c7;background:#075c60;box-shadow:0 0 22px rgba(45,226,174,.2)}.status-1 .stage-copy em{color:#5af0bd;background:rgba(38,218,164,.12)}.status-2 .stage-copy em{color:#ffc75a;background:rgba(255,181,55,.12)}.status-4 .stage-index{border-color:#3f9fd5;color:#8bcfff;background:#074174}.status-4 .stage-copy em{color:#75bfe9;background:rgba(63,159,213,.12)}.schedule-foot{display:flex;align-items:center;gap:14px;padding:15px;border:1px solid rgba(43,163,235,.16);border-radius:12px;background:rgba(5,34,100,.45)}.schedule-foot>span{width:34px;height:34px;border:3px solid #2cb5ee;border-left-color:transparent;border-radius:50%}.schedule-foot p{margin:0;color:#6ea2d0;font-size:12px;line-height:1.7}
.main-board{min-width:0}.current-head,.competing-head{height:60px;display:flex;align-items:center;justify-content:space-between}.live-badge{display:flex;align-items:center;gap:8px;padding:8px 14px;border:1px solid rgba(48,230,180,.26);border-radius:999px;color:#58edc1;background:rgba(32,206,161,.08);font-size:12px}.live-badge i{animation:pulse 1.5s infinite}.checkin-panel{height:550px;margin-top:12px;padding:18px 20px 16px;box-sizing:border-box;overflow:hidden;border:1px solid rgba(51,185,255,.34);border-radius:20px;background:linear-gradient(150deg,rgba(12,55,150,.68),rgba(3,20,75,.72));box-shadow:inset 0 1px rgba(124,230,255,.14),0 22px 60px rgba(0,3,36,.25)}.project-ribbon{height:58px;display:flex;align-items:center;justify-content:center;gap:28px;border-radius:12px;color:#fff;background:linear-gradient(90deg,rgba(8,91,206,.22),rgba(14,130,239,.8),rgba(8,91,206,.22));font-size:25px;letter-spacing:.04em}.slash{width:72px;height:9px;background:repeating-linear-gradient(90deg,#38e2ff 0 8px,transparent 8px 14px);transform:skew(-28deg)}.slash.reverse{transform:skew(28deg)}.unit-grid{height:432px;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));grid-template-rows:repeat(3,1fr);gap:9px;padding-top:13px;box-sizing:border-box}.unit-card{position:relative;display:grid;grid-template-rows:auto 1fr auto;min-width:0;padding:10px 12px;border:1px solid rgba(58,164,240,.22);border-radius:11px;background:linear-gradient(145deg,rgba(10,63,148,.72),rgba(7,36,101,.8));box-shadow:inset 0 1px rgba(142,224,255,.07);animation:card-in .45s ease both;animation-delay:var(--delay)}.unit-card.checked{border-color:rgba(44,224,177,.26);background:linear-gradient(145deg,rgba(7,98,124,.62),rgba(7,43,103,.78))}.status-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:7px}.order-no{color:#3d88c3;font-size:10px;font-weight:700}.group-name{overflow:hidden;color:#6ca7d4;font-size:10px;text-align:center;text-overflow:ellipsis;white-space:nowrap}.status-row em{display:flex;align-items:center;gap:5px;color:#5cb6f1;font-size:10px;font-style:normal;white-space:nowrap}.status-row em i{width:5px;height:5px;border-radius:50%;background:currentColor}.checked .status-row em{color:#51e6b8}.athlete-names{align-self:center;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:3px 9px;max-height:48px;overflow:hidden;text-align:center}.athlete-names b{font-size:15px;line-height:1.3}.team-name{overflow:hidden;padding-top:7px;border-top:1px solid rgba(74,151,213,.13);color:#77a4cd;font-size:10px;text-align:center;text-overflow:ellipsis;white-space:nowrap}.page-dots{height:12px;display:flex;justify-content:center;gap:7px}.page-dots i{width:8px;height:4px;border-radius:4px;background:#28598d;transition:.25s}.page-dots i.active{width:25px;background:#24cfff}.unit-grid.empty{display:grid}.unit-scroll-enter-active,.unit-scroll-leave-active{transition:opacity .35s ease,transform .35s ease}.unit-scroll-enter-from{opacity:0;transform:translateY(28px)}.unit-scroll-leave-to{opacity:0;transform:translateY(-28px)}.empty-block{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#4f86b7}.empty-block .el-icon{margin-bottom:12px;font-size:38px}.empty-block b{color:#7ba9ce;font-size:17px}.empty-block span{margin-top:7px;font-size:12px}.board-empty{grid-column:1/-1;grid-row:1/-1}
@ -156,4 +161,37 @@ onBeforeUnmount(()=>{[pollTimer,clockTimer,unitTimer].forEach(window.clearInterv
.schedule-panel{display:flex;flex-direction:column;padding:24px 22px;border:1px solid rgba(57,179,255,.28);border-radius:18px;background:linear-gradient(160deg,rgba(11,52,143,.62),rgba(2,20,77,.72));box-shadow:inset 0 1px rgba(119,224,255,.12),0 20px 50px rgba(0,0,26,.18);overflow:hidden}.schedule-panel .section-title small{display:block;color:#4d92cd;font-size:11px;font-weight:700;letter-spacing:.2em}.schedule-panel h2{margin:4px 0 0;font-size:24px}
.schedule-panel .stage-list{position:relative;display:flex;flex-direction:column;justify-content:center}.schedule-panel .stage-item{width:100%;flex:none;transition:filter .3s ease,opacity .3s ease,transform .3s ease}.schedule-panel .stage-item:not(.current){opacity:.72}.schedule-panel .stage-item.current{filter:drop-shadow(0 0 12px rgba(43,226,177,.14));transform:translateX(4px)}.schedule-panel .stage-item.current .stage-copy strong{color:#fff}.stage-overflow{height:22px;flex:none;color:#4f86b7;font-size:10px;letter-spacing:.08em;text-align:center}.stage-overflow-top{margin-bottom:5px}.stage-overflow-bottom{margin-top:-1px}.stage-overflow::before,.stage-overflow::after{content:"";display:inline-block;width:34px;height:1px;margin:0 8px 3px;background:linear-gradient(90deg,transparent,#2772a8)}.stage-overflow::after{transform:scaleX(-1)}
.unit-card{grid-template-rows:auto 1fr auto auto}.order-no{color:#5d9dce;font-size:11px}.group-name{color:#82b5dc;font-size:11px}.status-row em{color:#6ac4fa;font-size:11px}.athlete-names b{font-size:17px}.card-meta{display:flex;align-items:center;justify-content:center;gap:7px;margin-bottom:5px;color:#69c8ec;font-size:11px;white-space:nowrap}.card-meta i{width:3px;height:3px;border-radius:50%;background:#318ec2}.team-name{padding-top:6px;color:#88b6db;font-size:11px}.unit-card-list-move{transition:transform .65s cubic-bezier(.22,1,.36,1)}.unit-card-list-enter-active,.unit-card-list-leave-active{transition:opacity .42s ease,transform .42s ease}.unit-card-list-enter-from{opacity:0;transform:translateY(30px)}.unit-card-list-leave-to{opacity:0;transform:translateY(-30px)}.unit-card-list-leave-active{position:absolute}
/* 非 16:9 屏幕延展内容区,让检录卡片随可用高度伸缩。 */
.screen-content { height: calc(100% - 214px); }
.main-board { display: flex; flex-direction: column; min-height: 0; }
.current-head, .competing-head, .competing-panel { flex: none; }
.checkin-panel { flex: 1; min-height: 0; height: auto; }
.unit-grid { height: calc(100% - 84px); min-height: 0; }
/* 双语标签统一为中文主标题、英文小字说明。 */
.event-title h1 { margin-top: 0; margin-bottom: 8px; }
.event-title > span { display: flex; flex-direction: column; align-items: center; gap: 3px; font-size: 16px; line-height: 1.15; }
.event-title > span::before, .event-title > span::after { display: none; }
.event-title > span small { display: block; color: #78bce2; font-size: 10px; font-weight: 600; letter-spacing: .24em; }
.checkin-stage .section-title h2 { margin: 0; font-size: 24px; line-height: 1.2; }
.checkin-stage .section-title small { display: block; margin-top: 5px; color: #70abd8; font-size: 10px; letter-spacing: .16em; }
.competing-mark span { margin-top: 6px; font-size: 13px; line-height: 1.2; letter-spacing: .1em; }
.competing-mark span small { display: block; margin-top: 3px; color: #72aad0; font-size: 9px; font-weight: 500; letter-spacing: .14em; }
.event-title h1 { font-size: 40px; }
.event-title > span { font-size: 19px; }
.event-title > span small { font-size: 12px; }
.time-box b { font-size: 32px; }
.time-box span { font-size: 14px; }
.checkin-stage .section-title h2 { font-size: 27px; }
.checkin-stage .section-title small { font-size: 12px; }
.stage-copy strong { font-size: 19px; }
.stage-copy > span { font-size: 13px; }
.stage-copy em { font-size: 12px; }
.group-name, .status-row em, .team-name, .card-meta { font-size: 12px; }
.athlete-names b { font-size: 20px; }
.competing-mark span { font-size: 15px; }
.competing-mark span small { font-size: 10px; }
.competition-name strong { font-size: 22px; }
.info-cell b { font-size: 18px; }
</style>

View File

@ -0,0 +1,39 @@
export const defaultScoreScreenTheme = Object.freeze({
mode: 'dark',
themeColor: '#1764BC',
textColor: '#FFFFFF',
});
export const scoreScreenModeTextColors = Object.freeze({ dark: '#FFFFFF', light: '#173252' });
const validColor = value => typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value);
export function normalizeScoreScreenTheme(value) {
return {
mode: value?.mode === 'light' ? 'light' : 'dark',
themeColor: validColor(value?.themeColor) ? value.themeColor.toUpperCase() : defaultScoreScreenTheme.themeColor,
textColor: validColor(value?.textColor) ? value.textColor.toUpperCase() : defaultScoreScreenTheme.textColor,
};
}
export function scoreScreenThemeKey(eventId) {
return `yd-event-score-theme:${eventId || 'default'}`;
}
export function scoreScreenThemeChannel(eventId) {
return `yd-event-score-theme-${eventId || 'default'}`;
}
export function readScoreScreenTheme(eventId) {
try {
return normalizeScoreScreenTheme(JSON.parse(localStorage.getItem(scoreScreenThemeKey(eventId)) || 'null'));
} catch {
return { ...defaultScoreScreenTheme };
}
}
export function saveScoreScreenTheme(eventId, theme) {
const normalized = normalizeScoreScreenTheme(theme);
localStorage.setItem(scoreScreenThemeKey(eventId), JSON.stringify(normalized));
return normalized;
}

View File

@ -1,23 +1,36 @@
<template>
<div :class="['score-screen', `view-${viewType}`, `entity-${displayKind}`, { embedded: !isBigScreen, offline }]">
<div class="design-stage" :style="{ '--screen-scale': screenScale }">
<div :class="['score-screen', `view-${viewType}`, `entity-${displayKind}`, `theme-${scoreTheme.mode}`, { embedded: !isBigScreen, offline }]" :style="screenThemeStyle">
<div class="design-stage" :style="stageStyle">
<div class="ambient ambient-one"></div><div class="ambient ambient-two"></div><div class="aurora-strip"></div><div class="screen-grid"></div><div class="horizon"></div><div class="frame-corners"><i></i><i></i><i></i><i></i></div>
<div v-if="isLocalPreview" class="preview-badge">本地样例预览 <span>DEMO DATA</span></div>
<Transition name="cast" mode="out-in">
<main v-if="effectiveCast" :key="castKey" class="screen-content">
<header class="screen-header"><div class="title-wing left"><i></i><i></i><i></i></div><div class="event-title"><span>LIVE RESULTS</span><h1>{{ title }}</h1></div><div class="title-wing right"><i></i><i></i><i></i></div></header>
<section class="context-bar"><div><span class="context-icon"><el-icon><User /></el-icon></span><small>项目 / 组别</small><b>{{ projectGroup }}</b></div><i></i><div><span class="context-icon"><el-icon><Location /></el-icon></span><small>场次 / 场地</small><b>{{ sessionVenue }}</b></div></section>
<header class="screen-header">
<div class="header-title"><div class="title-wing left"><i></i><i></i><i></i></div><div class="event-title"><h1>{{ title }}</h1><div class="event-kicker"><span>{{ viewType === 'score' ? '比赛成绩' : '比赛排名' }}</span><small>{{ viewType === 'score' ? 'LIVE RESULTS' : 'LIVE RANKING' }}</small></div></div></div>
<section class="context-bar"><div><span class="context-icon"><el-icon><User /></el-icon></span><small>项目 / 组别 <em>EVENT / CATEGORY</em></small><b>{{ projectGroup }}</b></div><i></i><div><span class="context-icon"><el-icon><Location /></el-icon></span><small>场次 / 场地 <em>SESSION / VENUE</em></small><b>{{ sessionVenue }}</b></div></section>
</header>
<section v-if="viewType === 'score'" class="score-layout">
<div class="hero-card"><div class="panel-shine"></div><div class="entity-chip"><el-icon><component :is="displayKind === 'team' ? UserFilled : User" /></el-icon><span>{{ displayKind === 'team' ? 'TEAM' : 'PLAYER' }}</span>{{ displayKindLabel }}成绩</div><div class="entity-name">{{ singleName }}</div><div v-if="singleAffiliation" class="entity-affiliation">{{ singleAffiliation }}</div><div class="hero-meta"><span v-if="payload.entryNo">出场号 <b>{{ payload.entryNo }}</b></span><span v-if="payload.rank">当前排名 <b>第 {{ payload.rank }} 名</b></span></div><div class="hero-score"><strong class="score-ticker">{{ animatedValue('hero', payload.totalScore ?? payload.score) }}</strong><small>分</small></div><div class="score-beam"><i></i></div><div v-if="payload.isDisqualified" class="dq-stamp">DQ</div></div>
<div class="score-metrics" :style="{ gridTemplateColumns: `repeat(${Math.max(scoreCards.length, 1)}, 1fr)` }"><article v-for="(item,index) in scoreCards" :key="item.key" :class="['metric-card', item.key]" :style="{ '--delay': `${index * 90}ms` }"><div class="metric-head"><span class="metric-icon"><el-icon><component :is="scoreIconComponents[item.key]" /></el-icon></span><small>{{ item.en }}</small></div><strong class="score-ticker">{{ item.field === 'deductionFactor' ? Number(payload[item.field]).toFixed(3) : animatedValue(`metric:${item.key}`, payload[item.field]) }}</strong><div class="metric-line"></div><b>{{ item.label }}</b></article></div>
<div class="hero-card">
<div class="panel-shine"></div><div class="hero-halo" aria-hidden="true"></div>
<div class="entity-chip"><el-icon><component :is="displayKind === 'team' ? UserFilled : User" /></el-icon><span>当前{{ displayKind === 'team' ? '队伍' : '选手' }}<small>CURRENT {{ displayKind === 'team' ? 'TEAM' : 'ATHLETE' }}</small></span></div>
<div class="entity-name">{{ singleName }}</div>
<div v-if="singleAffiliation" class="entity-affiliation">{{ singleAffiliation }}</div>
<div class="hero-score-label"><span>总得分</span><small>TOTAL SCORE</small></div>
<div class="hero-score"><strong class="score-ticker">{{ animatedValue('hero', payload.totalScore ?? payload.score) }}</strong><span class="score-unit"><b>分</b><small>PTS</small></span></div>
<div class="score-beam"><i></i></div>
<div class="hero-meta"><span v-if="payload.entryNo"><small>出场号<em>ENTRY NO.</em></small><b>{{ payload.entryNo }}</b></span><span v-if="payload.rank"><small>当前排名<em>CURRENT RANK</em></small><b>第 {{ payload.rank }} 名</b></span></div>
<div v-if="payload.isDisqualified" class="dq-stamp">DQ</div>
</div>
<div class="score-metrics" :style="{ gridTemplateColumns: `repeat(${Math.max(scoreCards.length, 1)}, 1fr)` }"><article v-for="(item,index) in scoreCards" :key="item.key" :class="['metric-card', item.key]" :style="{ '--delay': `${index * 105 + 340}ms` }"><div class="metric-head"><div class="metric-title"><b>{{ item.label }}</b><small>{{ item.en }}</small></div><span class="metric-icon"><el-icon><component :is="scoreIconComponents[item.key]" /></el-icon></span></div><strong class="score-ticker">{{ animatedValue(`metric:${item.key}`, payload[item.field], item.field === 'deductionFactor' ? 3 : 2) }}</strong><div class="metric-line"></div></article></div>
</section>
<section v-else class="ranking-layout">
<div class="ranking-heading"><div><span><i></i>{{ displayKind === 'team' ? 'TEAM RANKING' : 'INDIVIDUAL RANKING' }}</span><h2>{{ displayKindLabel }}排名</h2></div><div class="ranking-status"><small>共 {{ rankingTotal }} 个{{ displayKind === 'team' ? '队伍' : '参赛者' }}<template v-if="rankingPageCount > 1"> · 第 {{ rankingPageIndex + 1 }}/{{ rankingPageCount }} 页</template></small><div v-if="rankingPageCount > 1" class="page-dots"><i v-for="page in rankingPageCount" :key="page" :class="{ active: page - 1 === rankingPageIndex }"></i></div></div></div>
<div v-if="rankingRows.length" class="ranking-list"><article v-for="(row,index) in rankingRows" :key="`${row.entryNo || displayName(row)}-${index}`" :class="['rank-row', { podium: rankNumber(row,index) <= 3 }]" :style="{ '--delay': `${index * 85}ms` }"><div :class="['rank-badge', `rank-${rankNumber(row,index)}`]"><template v-if="rankNumber(row,index) <= 3"><span class="medal-ribbon left"></span><span class="medal-ribbon right"></span><el-icon><Trophy /></el-icon><b>{{ rankNumber(row,index) }}</b></template><b v-else>{{ rankNumber(row,index) }}</b></div><div class="rank-person"><div class="rank-title"><strong>{{ displayName(row) }}</strong><small v-if="displaySubName(row)">{{ displaySubName(row) }}</small></div><div class="rank-track"><i :style="{ width: rankWidth(row) }"></i></div></div><div class="rank-value"><strong class="score-ticker">{{ animatedValue(rankAnimationKey(row,index), row.totalScore ?? row.score) }}</strong><small>分</small></div></article></div>
<div class="ranking-heading"><div><h2>{{ displayKindLabel }}排名</h2><span><i></i>{{ displayKind === 'team' ? 'TEAM RANKING' : 'INDIVIDUAL RANKING' }}</span></div><div class="ranking-status"><small>共 {{ rankingTotal }} 个{{ displayKind === 'team' ? '队伍' : '参赛者' }}<template v-if="rankingPageCount > 1"> · 第 {{ rankingPageIndex + 1 }}/{{ rankingPageCount }} 页</template></small><div v-if="rankingPageCount > 1" class="page-dots"><i v-for="page in rankingPageCount" :key="page" :class="{ active: page - 1 === rankingPageIndex }"></i></div></div></div>
<div v-if="rankingRows.length" class="ranking-list"><article v-for="(row,index) in rankingRows" :key="`${row.entryNo || displayName(row)}-${index}`" :class="['rank-row', { podium: rankNumber(row,index) <= 3 }]" :style="{ '--delay': `${index * 85}ms` }"><div :class="['rank-badge', `rank-${rankNumber(row,index)}`]"><template v-if="rankNumber(row,index) <= 3"><span class="medal-ribbon left"></span><span class="medal-ribbon right"></span><el-icon><Trophy /></el-icon><b>{{ rankNumber(row,index) }}</b></template><b v-else>{{ rankNumber(row,index) }}</b></div><div class="rank-person"><div class="rank-title"><strong>{{ displayName(row) }}</strong><small v-if="displaySubName(row)">{{ displaySubName(row) }}</small></div><div class="rank-track"><i :style="{ width: rankWidth(row) }"></i></div></div><div class="rank-value"><strong class="score-ticker">{{ animatedValue(rankAnimationKey(row,index), row.totalScore ?? row.score) }}</strong><span>分<small>PTS</small></span></div></article></div>
<div v-else class="ranking-empty">当前组别暂无可展示排名</div>
</section>
<footer class="screen-footer"><span></span><b>跃动赛事 · 实时成绩系统</b><span></span></footer>
<footer class="screen-footer"><span></span><b>跃动赛事 · 实时{{ viewType === 'score' ? '成绩' : '排名' }}系统<em>YD EVENT · LIVE {{ viewType === 'score' ? 'RESULTS' : 'RANKING' }}</em></b><span></span></footer>
</main>
<section v-else key="waiting" class="waiting"><div class="waiting-orbit"><i></i><i></i><i></i><span>YD</span></div><small>LIVE RESULTS SYSTEM</small><h1>等待后台投屏</h1><p>请在赛事控制台选择组别,并投出个人、团队成绩或排名</p></section>
<section v-else key="waiting" class="waiting"><div class="waiting-orbit"><i></i><i></i><i></i><span>YD</span></div><h1>等待后台投屏</h1><small>WAITING FOR CAST</small><p>请在赛事控制台选择组别,并投出个人、团队成绩或排名</p></section>
</Transition>
</div>
</div>
@ -30,19 +43,35 @@ import { DocumentChecked, Flag, Location, Medal, Setting, Trophy, User, UserFill
import { useTagsViewRoutes } from '/@/stores/tagsViewRoutes';
import { workbenchScreenCastCurrentGet } from '/@/api/match/workbench';
import { isFactorRule, isWireRule } from '../score-format.mjs';
import { normalizeScoreScreenTheme, readScoreScreenTheme, scoreScreenThemeChannel, scoreScreenThemeKey } from '../score-screen-theme.mjs';
const route=useRoute(),storesTagsViewRoutes=useTagsViewRoutes();
let savedContext={};try{savedContext=JSON.parse(sessionStorage.getItem('scoring-context')||'{}')}catch{savedContext={}}
const eventId=Number(route.query.eventId)||Number(savedContext.eventId)||0;
const venueId=route.query.venueId?Number(route.query.venueId):(Number(savedContext.venueId)||undefined);
const isLocalPreview=import.meta.env.DEV&&['score','ranking'].includes(String(route.query.preview));
const cast=ref(null),offline=ref(false),rankPage=ref(0),viewport=ref({width:window.innerWidth,height:window.innerHeight}),animatedNumbers=ref({});
let pollTimer,rankTimer,castChannel,numberAnimationFrame;
const scoreTheme=ref(readScoreScreenTheme(eventId));
const screenThemeStyle=computed(()=>({'--screen-theme':scoreTheme.value.themeColor,'--screen-text':scoreTheme.value.textColor}));
let pollTimer,rankTimer,castChannel,themeChannel,numberAnimationFrame;
function normalizePayload(value){if(Array.isArray(value))return value.map(normalizePayload);if(!value||typeof value!=='object')return value;return Object.fromEntries(Object.entries(value).map(([key,item])=>[key.charAt(0).toLowerCase()+key.slice(1),normalizePayload(item)]))}
function unwrapApiData(response){let value=response;for(let i=0;i<3&&value&&typeof value==='object'&&Object.prototype.hasOwnProperty.call(value,'data');i++)value=value.data;return value}
function normalizeCast(value){const normalized=normalizePayload(value),castType=Number(normalized?.castType);return normalized&&(castType===1||castType===2)&&normalized.payloadJson!=null?{...normalized,castType}:null}
function cacheKey(scope=venueId){return `yd-event-screen-cast:${eventId}:${scope??'all'}`}
function readCachedCast(){try{const scoped=localStorage.getItem(cacheKey()),fallback=localStorage.getItem(cacheKey(null));const cached=normalizeCast(JSON.parse(scoped||fallback||'null'));return cached&&Date.now()-Number(cached.cachedAt||0)<30000?cached:null}catch{return null}}
function acceptPublishedCast(value){const next=normalizeCast(value);if(next){cast.value=next;offline.value=false;rankPage.value=0}else cast.value=null}
function localPreviewCast(){
const common={screenCastId:'local-screen-preview',eventName:'2026 跃动跳绳公开赛',projectName:'个人花样',groupName:'青年组',sessionNo:'1',venueName:'主赛场 A',scoreRuleCode:'FREESTYLE_SINGLE',displayKind:'individual'};
if(route.query.preview==='ranking')return {...common,castType:2,payloadJson:JSON.stringify([
{rank:1,participantName:'陈一',teamName:'跃动队',entryNo:'A-017',totalScore:85.5},
{rank:2,participantName:'林嘉怡',teamName:'星跃队',entryNo:'A-019',totalScore:82.4},
{rank:3,participantName:'周子涵',teamName:'青云队',entryNo:'A-021',totalScore:79.8},
{rank:4,participantName:'许晨',teamName:'飞跃队',entryNo:'A-024',totalScore:76.2},
{rank:5,participantName:'王心悦',teamName:'跃动队',entryNo:'A-028',totalScore:73.6},
{rank:6,participantName:'李奕辰',teamName:'星跃队',entryNo:'A-030',totalScore:71.9},
])};
return {...common,castType:1,payloadJson:JSON.stringify({participantName:'陈一',teamName:'跃动队',entryNo:'A-017',rank:1,totalScore:85.5,difficultyScore:21,performanceScore:15,requiredScore:3,deductionFactor:.89})};
}
const effectiveCast=computed(()=>cast.value);
const payload=computed(()=>{try{const raw=effectiveCast.value?.payloadJson;if(!raw)return {};return normalizePayload(typeof raw==='string'?JSON.parse(raw):raw)}catch{return {}}});
const viewType=computed(()=>effectiveCast.value?.castType===2?'ranking':'score');
@ -61,7 +90,7 @@ const scoreCards=computed(()=>{
const prescribed=String(scoreRuleCode||'').toUpperCase().includes('PRESCRIBED');
const factor=isFactorRule(scoreRuleCode);
const wire=isWireRule(scoreRuleCode);
return [{key:'difficulty',label:'难度得分',en:'DIFFICULTY',field:'difficultyScore'},{key:'performance',label:prescribed?'创意编排分':factor?'表现系数':'表现',en:'PERFORMANCE',field:'performanceScore'},{key:'required',label:prescribed?'完成质量分':factor?'规定系数':'规定',en:'REQUIRED',field:'requiredScore'},{key:'deduction',label:factor?'扣分系数 M':'扣分',en:'DEDUCTION',field:payload.value.deductionFactor!=null?'deductionFactor':'deductionScore'}]
return [{key:'difficulty',label:'难度得分',en:'DIFFICULTY SCORE',field:'difficultyScore'},{key:'performance',label:prescribed?'创意编排分':factor?'表现系数':'表现',en:prescribed?'CREATIVE COMPOSITION':factor?'PERFORMANCE FACTOR':'PERFORMANCE SCORE',field:'performanceScore'},{key:'required',label:prescribed?'完成质量分':factor?'规定系数':'规定',en:prescribed?'EXECUTION QUALITY':factor?'REQUIRED FACTOR':'REQUIRED SCORE',field:'requiredScore'},{key:'deduction',label:factor?'扣分系数 M':'扣分',en:factor?'DEDUCTION FACTOR':'DEDUCTIONS',field:payload.value.deductionFactor!=null?'deductionFactor':'deductionScore'}]
.filter(item=>(item.key!=='difficulty'||!prescribed)&&(item.key!=='required'||!wire));
});
const rankingAll=computed(()=>Array.isArray(payload.value)?payload.value:[]),rankingTotal=computed(()=>rankingAll.value.length),rankingPageCount=computed(()=>Math.max(1,Math.ceil(rankingTotal.value/6))),rankingPageIndex=computed(()=>rankPage.value%rankingPageCount.value),rankingRows=computed(()=>rankingAll.value.slice(rankingPageIndex.value*6,rankingPageIndex.value*6+6)),maxRankScore=computed(()=>Math.max(...rankingAll.value.map(row=>Number(row.totalScore??row.score)||0),1));
@ -69,9 +98,9 @@ function displayName(row){return displayKind.value==='team'?(row.teamName||row.p
function displaySubName(row){if(displayKind.value==='team')return row.unitName||'';return row.teamName&&row.teamName!==displayName(row)?row.teamName:(row.unitName||'')}
function rankNumber(row,index){const rank=Number(row.rank);return Number.isFinite(rank)&&rank>0?rank:rankingPageIndex.value*6+index+1}
function rankWidth(row){return `${Math.max(12,Math.round((Number(row.totalScore??row.score)||0)/maxRankScore.value*100))}%`}
function value(input){if(input===undefined||input===null||input==='')return '0';const n=Number(input);return Number.isFinite(n)?n.toFixed(2):input}
function value(input,decimals=2){if(input===undefined||input===null||input==='')return '0';const n=Number(input);return Number.isFinite(n)?String(Number(n.toFixed(decimals))):input}
function rankAnimationKey(row,index){return `rank:${row.entryNo||displayName(row)||rankingPageIndex.value*6+index}`}
function animatedValue(key,fallback){return Object.prototype.hasOwnProperty.call(animatedNumbers.value,key)?value(animatedNumbers.value[key]):value(fallback)}
function animatedValue(key,fallback,decimals=2){return Object.prototype.hasOwnProperty.call(animatedNumbers.value,key)?value(animatedNumbers.value[key],decimals):value(fallback,decimals)}
function animateScoreNumbers(){
window.cancelAnimationFrame(numberAnimationFrame);
const targets={};
@ -93,13 +122,20 @@ function animateScoreNumbers(){
numberAnimationFrame=window.requestAnimationFrame(tick);
}
const isBigScreen=computed(()=>storesTagsViewRoutes.isTagsViewCurrenFull);
const screenScale=computed(()=>Math.min(viewport.value.width/1920,viewport.value.height/1080));
// 以设计稿尺寸决定等比缩放,再延展画布的另一轴,避免非 16:9 屏幕出现留白。
const screenScale=computed(()=>Math.max(.01,Math.min(viewport.value.width/1920,viewport.value.height/1080)));
const stageStyle=computed(()=>({
'--screen-scale':screenScale.value,
'--stage-width':`${viewport.value.width/screenScale.value}px`,
'--stage-height':`${viewport.value.height/screenScale.value}px`,
}));
let castRequesting=false;
async function loadCast(){if(!eventId||castRequesting)return;castRequesting=true;try{const current=normalizeCast(unwrapApiData(await workbenchScreenCastCurrentGet({eventId,venueId,_t:Date.now()})));if(current)acceptPublishedCast({...current,displayKind:current.displayKind||route.query.displayKind});else if(!cast.value||Date.now()-Number(cast.value.cachedAt||0)>=30000)cast.value=null;offline.value=false}catch{offline.value=true}finally{castRequesting=false}}
function onViewportResize(){viewport.value={width:window.innerWidth,height:window.innerHeight}}
function onThemeStorage(event){if(event.key===scoreScreenThemeKey(eventId)||event.key===null)scoreTheme.value=readScoreScreenTheme(eventId)}
watch([castKey,rankingPageIndex],()=>nextTick(animateScoreNumbers),{immediate:true,flush:'post'});
onMounted(()=>{storesTagsViewRoutes.setCurrenFullscreen(true);acceptPublishedCast(readCachedCast());if('BroadcastChannel' in window){castChannel=new BroadcastChannel(`yd-event-score-screen-${eventId}`);castChannel.onmessage=event=>acceptPublishedCast(event.data)}loadCast();pollTimer=window.setInterval(()=>document.visibilityState==='visible'&&loadCast(),3000);rankTimer=window.setInterval(()=>rankPage.value++,9000);window.addEventListener('resize',onViewportResize);onViewportResize()});
onBeforeUnmount(()=>{window.clearInterval(pollTimer);window.clearInterval(rankTimer);window.cancelAnimationFrame(numberAnimationFrame);castChannel?.close();window.removeEventListener('resize',onViewportResize)});
onMounted(()=>{storesTagsViewRoutes.setCurrenFullscreen(true);if(isLocalPreview)acceptPublishedCast(localPreviewCast());else{acceptPublishedCast(readCachedCast());if('BroadcastChannel' in window){castChannel=new BroadcastChannel(`yd-event-score-screen-${eventId}`);castChannel.onmessage=event=>acceptPublishedCast(event.data)}loadCast();pollTimer=window.setInterval(()=>document.visibilityState==='visible'&&loadCast(),3000);rankTimer=window.setInterval(()=>rankPage.value++,9000)}if('BroadcastChannel' in window){themeChannel=new BroadcastChannel(scoreScreenThemeChannel(eventId));themeChannel.onmessage=event=>{scoreTheme.value=normalizeScoreScreenTheme(event.data)}}window.addEventListener('storage',onThemeStorage);window.addEventListener('resize',onViewportResize);onViewportResize()});
onBeforeUnmount(()=>{window.clearInterval(pollTimer);window.clearInterval(rankTimer);window.cancelAnimationFrame(numberAnimationFrame);castChannel?.close();themeChannel?.close();window.removeEventListener('storage',onThemeStorage);window.removeEventListener('resize',onViewportResize)});
</script>
<style scoped lang="scss">
@ -114,7 +150,7 @@ onBeforeUnmount(()=>{window.clearInterval(pollTimer);window.clearInterval(rankTi
.cast-enter-active,.cast-leave-active{transition:opacity .34s ease,transform .42s cubic-bezier(.2,.8,.2,1);transform-origin:center}.cast-enter-from{opacity:0;transform:scale(.975) translateY(12px)}.cast-leave-to{opacity:0;transform:scale(1.015)}@keyframes metric-in{from{opacity:0;transform:translateY(18px) scale(.97)}to{opacity:1;transform:none}}@keyframes rank-in{from{opacity:0;transform:translateX(20px)}to{opacity:1;transform:none}}@keyframes orbit{to{transform:rotate(360deg)}}
@media(max-width:1100px){.context-bar{width:92%}.ranking-list{grid-template-columns:1fr}.rank-row:nth-child(n+4){display:none}.event-title h1{max-width:78vw}.title-wing{display:none}}@media(max-height:720px){.screen-content{padding-top:10px;padding-bottom:7px}.context-bar{margin-top:7px;margin-bottom:8px}.score-layout{grid-template-rows:minmax(0,1fr) 126px;gap:9px}.entity-chip{top:7px}.entity-name{top:20%}.entity-affiliation{top:33%}.hero-score{top:37%}.metric-card{padding-top:8px;padding-bottom:6px}.screen-footer{height:18px}.ranking-layout{padding-top:12px;padding-bottom:12px}.ranking-heading{margin-bottom:8px}}
/* Pixso 大屏画板:以 1920 × 1080 为唯一设计基准,外层只做等比例缩放。 */
/* 1920 × 1080 是排版基准;画布按实际屏幕比例延展,内容仍等比缩放。 */
.score-screen {
display: grid;
place-items: center;
@ -124,8 +160,8 @@ onBeforeUnmount(()=>{window.clearInterval(pollTimer);window.clearInterval(rankTi
position: absolute;
left: 50%;
top: 50%;
width: 1920px;
height: 1080px;
width: var(--stage-width);
height: var(--stage-height);
overflow: hidden;
transform: translate(-50%, -50%) scale(var(--screen-scale));
transform-origin: center;
@ -144,11 +180,11 @@ onBeforeUnmount(()=>{window.clearInterval(pollTimer);window.clearInterval(rankTi
.design-stage .screen-grid { background-size: 96px 96px; }
.design-stage .horizon { left: 70px; right: 70px; bottom: -150px; height: 430px; }
.screen-content {
width: 1920px;
height: 1080px;
width: 100%;
height: 100%;
padding: 28px 50px 20px;
}
.screen-header { height: 74px; gap: 42px; }
.screen-header { height: 90px; gap: 42px; }
.event-title span { margin-bottom: 2px; font-size: 11px; letter-spacing: 0.46em; }
.event-title h1 { max-width: 1260px; font-size: 42px; line-height: 1.2; }
.title-wing { display: flex; }
@ -238,7 +274,7 @@ onBeforeUnmount(()=>{window.clearInterval(pollTimer);window.clearInterval(rankTi
.rank-value small { font-size: 13px; }
.screen-footer { height: 27px; font-size: 11px; }
.screen-footer span { width: 110px; }
.waiting { width: 1920px; height: 1080px; }
.waiting { width: 100%; height: 100%; }
.waiting-orbit { width: 170px; height: 170px; margin-bottom: 38px; }
.waiting-orbit span { font-size: 44px; }
.waiting > small { font-size: 13px; }
@ -390,8 +426,275 @@ onBeforeUnmount(()=>{window.clearInterval(pollTimer);window.clearInterval(rankTi
@keyframes beam-breathe { 0%, 100% { opacity: 0.7; transform: scaleX(0.94); } 50% { opacity: 1; transform: scaleX(1.03); } }
@keyframes live-pulse { 0%, 100% { opacity: 0.58; transform: scale(0.78); } 50% { opacity: 1; transform: scale(1.18); } }
/* 成绩投屏:双语信息按“赛事 → 选手 → 总分 → 分项”的阅读顺序组织。 */
.view-score .design-stage {
background: radial-gradient(ellipse at 50% 14%, #1764bc 0, #0b397c 35%, #071d4a 67%, #050f2e 100%);
}
.view-score .screen-header { animation: header-reveal .7s cubic-bezier(.2,.8,.2,1) both; }
.view-score .event-title h1 { font-size: 42px; text-shadow: 0 8px 28px rgba(2,10,34,.32); }
.event-kicker { display: flex; flex-direction: column; align-items: center; gap: 3px; margin-top: 4px; }
.event-kicker span { color: #b9eff7; font-size: 13px; font-weight: 700; letter-spacing: .3em; }
.event-kicker small { color: #72bdd9; font-size: 10px; font-weight: 600; letter-spacing: .24em; }
.context-bar small { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }
.context-bar small em { color: #9cb9d5; font-size: 10px; font-style: normal; letter-spacing: .12em; }
.context-bar b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.view-score .context-bar {
border-color: rgba(128,205,238,.26);
background: linear-gradient(90deg,rgba(7,38,88,.76),rgba(16,63,120,.64),rgba(7,38,88,.76));
box-shadow: inset 0 1px rgba(255,255,255,.08),0 10px 32px rgba(1,11,39,.16);
animation: context-reveal .65s .08s both;
}
.view-score .score-layout { grid-template-rows: minmax(0,1fr) 236px; gap: 20px; }
.view-score .hero-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
isolation: isolate;
border-color: rgba(115,198,236,.35);
border-radius: 22px;
background:
radial-gradient(circle at 8% 82%, rgba(45, 161, 213, .15), transparent 32%),
radial-gradient(circle at 92% 16%, rgba(66, 124, 215, .15), transparent 32%),
radial-gradient(ellipse at 50% 74%, rgba(37, 122, 200, .27), transparent 56%),
linear-gradient(145deg, rgba(12, 57, 119, .91), rgba(5, 26, 72, .96));
box-shadow: inset 0 1px rgba(255,255,255,.11),0 28px 70px rgba(0,8,37,.27);
animation: hero-reveal .8s .12s cubic-bezier(.2,.8,.2,1) both;
}
.view-score .hero-card::before {
content: '';
position: absolute;
inset: 14px;
border: 1px solid rgba(129,206,239,.09);
border-radius: 15px;
pointer-events: none;
}
.view-score .hero-halo {
position: absolute;
z-index: -1;
top: 14%;
left: 50%;
width: 900px;
height: 620px;
transform: translateX(-50%);
background: radial-gradient(ellipse,rgba(81,188,240,.18),rgba(60,143,220,.06) 42%,transparent 72%);
pointer-events: none;
animation: halo-breathe 6s ease-in-out infinite alternate;
}
.view-score .panel-shine { pointer-events: none; }
.view-score .entity-chip {
position: relative;
left: auto;
top: auto;
min-width: 0;
margin-top: 31px;
padding: 8px 20px;
transform: none;
border-color: rgba(110,225,235,.38);
background: rgba(20,104,146,.28);
font-size: 16px;
letter-spacing: .08em;
animation: content-rise .6s .3s both;
}
.view-score .entity-chip span { display: flex; flex-direction: column; gap: 3px; margin: 0; color: #e7fbff; font-size: 16px; line-height: 1.1; letter-spacing: .1em; }
.view-score .entity-chip small { color: #9fd7e3; font-size: 10px; font-weight: 600; letter-spacing: .14em; }
.view-score .entity-name {
position: relative;
left: auto;
right: auto;
top: auto;
max-width: 90%;
margin-top: 19px;
font-size: 58px;
line-height: 1.16;
letter-spacing: .05em;
text-shadow: 0 8px 30px rgba(0,7,35,.35);
animation: content-rise .65s .39s both;
}
.view-score .entity-affiliation { position: relative; left: auto; right: auto; top: auto; max-width: 85%; margin-top: 6px; color: #afc9dd; font-size: 17px; }
.view-score .hero-score-label { display: flex; flex-direction: column; align-items: center; gap: 3px; margin-top: 22px; color: #b5eefa; font-size: 20px; font-weight: 700; letter-spacing: .2em; }
.view-score .hero-score-label small { color: #8fc1d8; font-size: 11px; font-weight: 600; letter-spacing: .2em; }
.view-score .hero-score { position: relative; left: auto; right: auto; top: auto; align-items: baseline; margin-top: 0; }
.view-score .hero-score strong { font-size: 166px; letter-spacing: -.055em; text-shadow: 0 14px 38px rgba(0,8,44,.43),0 0 36px rgba(130,228,255,.18); animation-delay: .46s; }
.view-score .score-unit { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; margin-left: 16px; color: #c6ebf4; line-height: 1.1; }
.view-score .score-unit b { font-size: 22px; }
.view-score .score-unit small { margin: 0; color: #8fbcd0; font-size: 13px; letter-spacing: .14em; }
.view-score .score-beam { left: 29%; right: 29%; bottom: 77px; height: 1px; background: linear-gradient(90deg,transparent,#c7f3f5,transparent); box-shadow: 0 0 18px 2px rgba(87,206,237,.45); }
.view-score .score-beam i { width: 10px; height: 10px; border-width: 1px; background: #8de4eb; box-shadow: 0 0 12px #7fdce9; }
.view-score .hero-meta { left: 50%; bottom: 18px; gap: 14px; transform: translateX(-50%); white-space: nowrap; }
.view-score .hero-meta span { display: flex; align-items: center; gap: 12px; padding: 9px 15px; border-color: rgba(119,196,228,.2); border-radius: 8px; background: rgba(4,32,78,.6); }
.view-score .hero-meta small { display: flex; flex-direction: column; gap: 2px; color: #c0dcec; font-size: 11px; font-weight: 700; letter-spacing: .08em; }
.view-score .hero-meta small em { color: #83a9c4; font-size: 9px; font-style: normal; font-weight: 500; letter-spacing: .1em; }
.view-score .hero-meta b { margin: 0; color: #e4f7ff; font-size: 15px; }
.view-score .score-metrics { gap: 16px; }
.view-score .metric-card,
.view-score .metric-card.performance,
.view-score .metric-card.required,
.view-score .metric-card.deduction {
--metric-accent: #7bdcf2;
grid-template-columns: 1fr;
grid-template-rows: 58px minmax(0,1fr) 1px;
gap: 0;
padding: 20px 25px 22px;
border: 1px solid color-mix(in srgb,var(--metric-accent) 30%,rgba(137,195,226,.27));
border-radius: 16px;
background: radial-gradient(circle at 85% 8%,color-mix(in srgb,var(--metric-accent) 13%,transparent),transparent 48%),linear-gradient(145deg,rgba(16,62,119,.94),rgba(7,35,86,.97));
box-shadow: inset 0 1px rgba(255,255,255,.09),0 14px 32px rgba(0,9,40,.18);
animation-duration: .65s;
}
.view-score .metric-card.performance { --metric-accent: #72e2ce; }
.view-score .metric-card.required { --metric-accent: #f6cf85; }
.view-score .metric-card.deduction { --metric-accent: #c7b2f9; }
.view-score .metric-card::before { background: radial-gradient(circle at 10% 0,color-mix(in srgb,var(--metric-accent) 14%,transparent),transparent 55%); }
.view-score .metric-card::after { top: 0; right: 26px; left: 26px; width: auto; height: 2px; border: 0; border-radius: 0; background: linear-gradient(90deg,transparent,var(--metric-accent),transparent); }
.view-score .metric-head { grid-row: 1; grid-column: 1; justify-content: space-between; gap: 12px; }
.view-score .metric-title { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.view-score .metric-title b { color: #e7f7fc; font-size: 21px; line-height: 1.15; letter-spacing: .08em; white-space: nowrap; }
.view-score .metric-icon { width: 48px; height: 48px; border-color: color-mix(in srgb,var(--metric-accent) 52%,transparent); color: var(--metric-accent); background: rgba(255,255,255,.035); font-size: 25px; box-shadow: none; }
.view-score .metric-icon .el-icon { font-size: 25px; }
.view-score .metric-head small { display: block; color: #9ebdd0; font-size: 10px; font-weight: 600; letter-spacing: .1em; white-space: nowrap; }
.view-score .metric-card > strong { grid-row: 2; grid-column: 1; align-self: center; color: color-mix(in srgb,var(--metric-accent) 20%,#fff); font-size: 62px; line-height: 1; letter-spacing: -.035em; text-shadow: 0 0 28px color-mix(in srgb,var(--metric-accent) 18%,transparent); }
.view-score .metric-line { grid-row: 3; grid-column: 1; height: 1px; margin: 0; background: linear-gradient(90deg,color-mix(in srgb,var(--metric-accent) 60%,transparent),rgba(255,255,255,.07)); }
.screen-footer { height: 35px; }
.screen-footer b { display: flex; flex-direction: column; align-items: center; gap: 3px; line-height: 1; }
.screen-footer em { color: #91b9ce; font-size: 9px; font-style: normal; font-weight: 500; letter-spacing: .16em; }
.ranking-heading h2 { margin: 0 0 6px; }
.ranking-heading span { font-size: 10px; }
.rank-value { align-items: center; }
.rank-value > span { display: flex; flex-direction: column; gap: 1px; margin-left: 7px; color: #b9e0f3; font-size: 14px; font-weight: 700; line-height: 1; }
.rank-value > span small { margin: 0; color: #82aec9; font-size: 9px; font-weight: 500; letter-spacing: .08em; }
.waiting h1 { margin-bottom: 4px; }
.waiting > small { font-size: 11px; }
.waiting p { margin-top: 22px; }
/* 顶部采用左侧赛事标题、右侧项目和场次信息;放大中英文的投屏阅读尺寸。 */
.score-screen .screen-header { justify-content: space-between; gap: 30px; height: 132px; min-width: 0; }
.header-title { display: flex; align-items: center; gap: 24px; min-width: 0; flex: 1; }
.header-title .title-wing { flex: none; }
.header-title .event-title { min-width: 0; text-align: left; }
.header-title .event-title h1 { max-width: none; overflow: hidden; font-size: 46px; text-overflow: ellipsis; white-space: nowrap; }
.header-title .event-kicker { align-items: flex-start; margin-top: 9px; }
.header-title .event-kicker span { font-size: 18px; letter-spacing: .16em; }
.header-title .event-kicker small { font-size: 12px; letter-spacing: .2em; }
.score-screen .screen-header .context-bar { width: 700px; min-width: 700px; height: 100px; min-height: 100px; margin: 0; padding: 0 20px; flex: none; border-radius: 18px; }
.score-screen .context-bar > div { min-width: 0; grid-template-columns: auto minmax(0,1fr); column-gap: 10px; }
.score-screen .context-bar > i { height: 54px; margin: 0 18px; }
.score-screen .context-bar small { gap: 3px; font-size: 15px; font-weight: 700; }
.score-screen .context-bar small em { font-size: 11px; font-weight: 500; }
.score-screen .context-bar b { font-size: 21px; }
.score-screen .context-icon { width: 42px; height: 42px; }
.view-score .entity-chip span { font-size: 20px; }
.view-score .entity-chip small { font-size: 12px; }
.view-score .entity-name { font-size: 66px; }
.view-score .entity-affiliation { font-size: 21px; }
.view-score .hero-score-label { font-size: 24px; }
.view-score .hero-score-label small { font-size: 13px; }
.view-score .hero-score strong { font-size: 178px; }
.view-score .score-unit b { font-size: 24px; }
.view-score .score-unit small { font-size: 15px; }
.view-score .hero-meta small { font-size: 13px; }
.view-score .hero-meta small em { font-size: 10px; }
.view-score .hero-meta b { font-size: 17px; }
.view-score .metric-title b { font-size: 25px; }
.view-score .metric-head small { font-size: 12px; }
.view-score .metric-card > strong { font-size: 70px; }
.ranking-heading h2 { font-size: 38px; }
.ranking-heading span { font-size: 12px; }
.rank-title strong { font-size: 25px; }
.rank-title small { font-size: 16px; }
.rank-value strong { font-size: 40px; }
.rank-value > span { font-size: 16px; }
.rank-value > span small { font-size: 10px; }
.screen-footer { font-size: 13px; }
.screen-footer em { font-size: 10px; }
/* 大屏配色由控制台保存并广播;保持底色足够深,让自选文字颜色有清晰的承载面。 */
.score-screen { --cyan: color-mix(in srgb,var(--screen-theme) 65%,white); color: var(--screen-text); }
.score-screen .design-stage { background: radial-gradient(ellipse at 50% 28%,color-mix(in srgb,var(--screen-theme) 78%,#08143c),transparent 52%),linear-gradient(145deg,color-mix(in srgb,var(--screen-theme) 68%,#061437),color-mix(in srgb,var(--screen-theme) 26%,#030b2a) 68%,#030b25); }
.score-screen.view-score .design-stage { background: radial-gradient(ellipse at 50% 14%,color-mix(in srgb,var(--screen-theme) 92%,#07193e),transparent 64%),linear-gradient(145deg,color-mix(in srgb,var(--screen-theme) 68%,#07183e),color-mix(in srgb,var(--screen-theme) 26%,#050e2c) 70%,#030b25); }
.score-screen.view-ranking .design-stage { background: radial-gradient(ellipse at 50% 18%,color-mix(in srgb,var(--screen-theme) 94%,#07183d),transparent 63%),linear-gradient(145deg,color-mix(in srgb,var(--screen-theme) 72%,#07183e),color-mix(in srgb,var(--screen-theme) 25%,#050d2a) 72%,#030b25); }
.score-screen .context-bar { border-color: color-mix(in srgb,var(--screen-theme) 42%,#bdeaff); background: linear-gradient(100deg,color-mix(in srgb,var(--screen-theme) 34%,#071838),color-mix(in srgb,var(--screen-theme) 45%,#061333)); }
.score-screen.view-score .hero-card { border-color: color-mix(in srgb,var(--screen-theme) 44%,#c3e7f3); background: radial-gradient(ellipse at 50% 72%,color-mix(in srgb,var(--screen-theme) 32%,transparent),transparent 60%),linear-gradient(145deg,color-mix(in srgb,var(--screen-theme) 48%,#071735),color-mix(in srgb,var(--screen-theme) 18%,#04102d)); }
.score-screen.view-score .metric-card { background: radial-gradient(circle at 85% 8%,color-mix(in srgb,var(--metric-accent) 15%,transparent),transparent 48%),linear-gradient(145deg,color-mix(in srgb,var(--screen-theme) 43%,#061733),color-mix(in srgb,var(--screen-theme) 18%,#04102c)); }
.score-screen .ranking-layout { border-color: color-mix(in srgb,var(--screen-theme) 44%,#b7dff2); background: linear-gradient(145deg,color-mix(in srgb,var(--screen-theme) 54%,#06153c),color-mix(in srgb,var(--screen-theme) 18%,#030d2c)); }
.score-screen .rank-row { background: linear-gradient(90deg,color-mix(in srgb,var(--screen-theme) 60%,#07183d),color-mix(in srgb,var(--screen-theme) 25%,#061135)); }
.score-screen .title-wing i { background: linear-gradient(90deg,transparent,var(--cyan)); }
.score-screen .context-icon { color: var(--cyan); border-color: color-mix(in srgb,var(--screen-theme) 45%,#b1f2ff); }
.score-screen :is(.event-title h1,.context-bar b,.entity-name,.hero-score-label,.hero-score strong,.score-unit b,.hero-meta b,.metric-title b,.metric-card > strong,.ranking-heading h2,.rank-title strong,.rank-value strong,.rank-value > span,.waiting h1) { color: var(--screen-text); }
.score-screen :is(.event-kicker span,.context-bar small,.entity-affiliation,.hero-score-label small,.score-unit small,.hero-meta small,.metric-head small,.ranking-status small,.rank-title small,.rank-value > span small,.screen-footer,.waiting p) { color: color-mix(in srgb,var(--screen-text) 78%,transparent); }
.score-screen :is(.event-kicker small,.context-bar small em,.entity-chip small,.hero-meta small em,.screen-footer em,.waiting > small) { color: color-mix(in srgb,var(--screen-text) 62%,transparent); }
.score-screen.view-score .entity-chip span { color: var(--screen-text); }
.score-screen.view-score .metric-card > strong { color: var(--screen-text); }
/* 亮色主题使用独立的浅色舞台和卡片,不只是替换文字颜色。 */
.score-screen.theme-light { --cyan: color-mix(in srgb,var(--screen-theme) 80%,#123a65); background: #eaf2fa; }
.score-screen.theme-light .design-stage,
.score-screen.theme-light.view-score .design-stage,
.score-screen.theme-light.view-ranking .design-stage {
background: radial-gradient(ellipse at 50% -12%,color-mix(in srgb,var(--screen-theme) 20%,#fff),transparent 62%),linear-gradient(150deg,#f9fcff 0%,color-mix(in srgb,var(--screen-theme) 9%,#f3f8fe) 55%,#e5eef9 100%);
}
.score-screen.theme-light .ambient { opacity: .09; }
.score-screen.theme-light .screen-grid { opacity: .08; }
.score-screen.theme-light .horizon { opacity: .32; }
.score-screen.theme-light .aurora-strip { opacity: .16; }
.score-screen.theme-light .title-wing i { background: linear-gradient(90deg,transparent,var(--screen-theme)); }
.score-screen.theme-light .event-title h1 { text-shadow: none; }
.score-screen.theme-light .context-bar {
border-color: color-mix(in srgb,var(--screen-theme) 22%,#dce7f1);
background: rgba(255,255,255,.8);
box-shadow: 0 12px 34px rgba(30,65,105,.07),inset 0 1px #fff;
}
.score-screen.theme-light .context-bar > i { background: linear-gradient(transparent,#c2d3e4,transparent); }
.score-screen.theme-light .context-icon { color: var(--screen-theme); border-color: color-mix(in srgb,var(--screen-theme) 28%,#dae8f4); background: color-mix(in srgb,var(--screen-theme) 9%,#fff); box-shadow: none; }
.score-screen.theme-light.view-score .hero-card {
border-color: color-mix(in srgb,var(--screen-theme) 24%,#d8e5f1);
background: radial-gradient(ellipse at 50% 76%,color-mix(in srgb,var(--screen-theme) 10%,transparent),transparent 60%),linear-gradient(145deg,#fff,color-mix(in srgb,var(--screen-theme) 7%,#f8fbff));
box-shadow: 0 24px 56px rgba(35,75,120,.11),inset 0 1px #fff;
}
.score-screen.theme-light.view-score .hero-card::before { border-color: color-mix(in srgb,var(--screen-theme) 12%,#e6edf4); }
.score-screen.theme-light.view-score .hero-halo { opacity: .4; }
.score-screen.theme-light.view-score .panel-shine { opacity: .24; }
.score-screen.theme-light.view-score .entity-chip { color: var(--screen-text); border-color: color-mix(in srgb,var(--screen-theme) 23%,#dfe9f3); background: color-mix(in srgb,var(--screen-theme) 8%,#fff); box-shadow: none; }
.score-screen.theme-light.view-score .entity-chip .el-icon { color: var(--screen-theme); }
.score-screen.theme-light.view-score .entity-name { text-shadow: none; }
.score-screen.theme-light.view-score .hero-score strong { text-shadow: 0 12px 26px rgba(30,80,130,.14); }
.score-screen.theme-light.view-score .score-beam { background: linear-gradient(90deg,transparent,var(--screen-theme),transparent); box-shadow: 0 0 16px color-mix(in srgb,var(--screen-theme) 25%,transparent); }
.score-screen.theme-light.view-score .score-beam i { background: var(--screen-theme); box-shadow: 0 0 10px color-mix(in srgb,var(--screen-theme) 35%,transparent); }
.score-screen.theme-light.view-score .hero-meta span { border-color: #dbe7f2; background: rgba(246,250,254,.9); }
.score-screen.theme-light.view-score .metric-card {
border-color: color-mix(in srgb,var(--metric-accent) 25%,#dae6f0);
background: radial-gradient(circle at 90% 0,color-mix(in srgb,var(--metric-accent) 15%,transparent),transparent 55%),linear-gradient(145deg,#fff,#f6faff);
box-shadow: 0 13px 30px rgba(34,72,115,.09),inset 0 1px #fff;
}
.score-screen.theme-light.view-score .metric-icon { color: color-mix(in srgb,var(--metric-accent) 65%,#274666); border-color: color-mix(in srgb,var(--metric-accent) 40%,#d9e5f0); background: color-mix(in srgb,var(--metric-accent) 12%,#fff); }
.score-screen.theme-light.view-score .metric-line { background: linear-gradient(90deg,color-mix(in srgb,var(--metric-accent) 50%,transparent),#dbe6ef); }
.score-screen.theme-light .ranking-layout {
border-color: color-mix(in srgb,var(--screen-theme) 23%,#dae6f0);
background: linear-gradient(145deg,rgba(255,255,255,.94),color-mix(in srgb,var(--screen-theme) 6%,#f8fbff));
box-shadow: 0 24px 56px rgba(35,75,120,.1),inset 0 1px #fff;
}
.score-screen.theme-light .rank-row,
.score-screen.theme-light .rank-row.podium { border-color: color-mix(in srgb,var(--rank-color) 22%,#dce6ef); background: linear-gradient(90deg,#fff,color-mix(in srgb,var(--rank-color) 6%,#f7faff)); box-shadow: inset 4px 0 var(--rank-color),0 7px 18px rgba(30,70,115,.06); }
.score-screen.theme-light .rank-track { background: #e7eff7; box-shadow: inset 0 1px 3px rgba(40,80,120,.1); }
.score-screen.theme-light .rank-track i { background: linear-gradient(90deg,var(--screen-theme),var(--rank-color)); box-shadow: none; }
.score-screen.theme-light .rank-badge:not(.rank-1):not(.rank-2):not(.rank-3) { color: var(--screen-text); border-color: color-mix(in srgb,var(--screen-theme) 22%,#d9e6f1); background: #edf5fd; }
.score-screen.theme-light .ranking-empty { color: color-mix(in srgb,var(--screen-text) 70%,transparent); }
.score-screen.theme-light .waiting-orbit { border-color: color-mix(in srgb,var(--screen-theme) 30%,transparent); box-shadow: 0 0 70px color-mix(in srgb,var(--screen-theme) 10%,transparent); }
.score-screen.theme-light .waiting-orbit span { color: var(--screen-text); }
.score-screen.theme-light .waiting-orbit::before,
.score-screen.theme-light .waiting-orbit::after { border-color: color-mix(in srgb,var(--screen-theme) 20%,transparent); }
.score-screen.theme-light .screen-footer > span { background: linear-gradient(90deg,transparent,color-mix(in srgb,var(--screen-theme) 35%,transparent),transparent); }
.preview-badge { position: absolute; z-index: 5; top: 31px; right: 51px; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 7px 12px; border: 1px solid rgba(245,200,108,.5); border-radius: 6px; color: #ffe3a5; background: rgba(42,31,33,.75); font-size: 12px; font-weight: 700; letter-spacing: .06em; }
.preview-badge span { color: #d8ba8d; font-size: 9px; font-weight: 500; letter-spacing: .12em; }
@keyframes header-reveal { from { opacity: 0; transform: translateY(-16px); } to { opacity: 1; transform: none; } }
@keyframes context-reveal { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: none; } }
@keyframes hero-reveal { from { opacity: 0; transform: translateY(18px) scale(.985); } to { opacity: 1; transform: none; } }
@keyframes content-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
@keyframes halo-breathe { from { opacity: .56; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.aurora-strip, .ambient, .screen-grid, .panel-shine::after, .score-ticker, .hero-score strong, .score-beam, .ranking-heading span > i, .metric-card, .rank-row, .waiting-orbit::before, .waiting-orbit::after { animation: none !important; }
.aurora-strip, .ambient, .screen-grid, .panel-shine::after, .score-ticker, .hero-score strong, .score-beam, .ranking-heading span > i, .metric-card, .rank-row, .waiting-orbit::before, .waiting-orbit::after, .view-score .screen-header, .view-score .context-bar, .view-score .hero-card, .view-score .hero-halo, .view-score .entity-chip, .view-score .entity-name { animation: none !important; }
.rank-track i, .page-dots i, .cast-enter-active, .cast-leave-active { transition: none !important; }
}
</style>

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 }]">● {{ syncStatusText }}</span><el-button v-if="offline" link type="primary" :loading="retryLoading" @click="retrySync">重试同步</el-button><el-button v-if="activeTab !== 'screen'" class="top-checkin-button" @click="openCheckinScreen">检录大屏</el-button><el-button @click="goLauncher">切换赛事</el-button></div>
<div class="top-actions"><span :class="['live-dot', { offline }]">● {{ syncStatusText }}</span><el-button v-if="offline" link type="primary" :loading="retryLoading" @click="retrySync">重试同步</el-button><el-button v-if="activeTab !== 'corrections'" @click="themeSettingsVisible=true">大屏配色</el-button><el-button v-if="activeTab !== 'screen'" class="top-checkin-button" @click="openCheckinScreen">检录大屏</el-button><el-button @click="goLauncher">切换赛事</el-button></div>
</header>
<template v-if="activeTab !== 'corrections'">
@ -27,6 +27,13 @@
<div class="control-field"><label>展示对象</label><el-radio-group v-model="castEntityKind" size="default"><el-radio-button label="individual">个人</el-radio-button><el-radio-button label="team">团队</el-radio-button></el-radio-group></div>
<div class="control-field group-field"><label>项目 / 组别 / 场次</label><el-select :model-value="selectedSession?.sessionId" placeholder="请选择需要控制的组别" filterable @change="selectSessionById"><el-option v-for="(session,index) in sessions" :key="session.sessionId" :label="`${session.projectName} / ${session.groupName || '混合组别'} / ${formatSessionName(session,index)}`" :value="session.sessionId"/></el-select></div>
<div class="control-buttons"><el-button @click="openScoreScreen">显示成绩大屏</el-button><el-button class="checkin-button" @click="openCheckinScreen">切换检录大屏</el-button><el-button type="primary" :disabled="!selectedSession" @click="openRanking">投出{{ castEntityLabel }}排名</el-button></div>
<div class="screen-theme-control">
<div class="theme-control-copy"><b>大屏配色</b><small>成绩和排名大屏实时生效,设置保存在本机</small></div>
<el-radio-group :model-value="scoreTheme.mode" size="small" aria-label="大屏主题" @change="updateScoreThemeMode"><el-radio-button label="light">亮色</el-radio-button><el-radio-button label="dark">暗色</el-radio-button></el-radio-group>
<div class="theme-color-fields"><label>主题色<el-color-picker :model-value="scoreTheme.themeColor" :predefine="themeColorPresets" @change="color => updateScoreTheme('themeColor', color)" /></label><label>文字颜色<el-color-picker :model-value="scoreTheme.textColor" :predefine="textColorPresets" @change="color => updateScoreTheme('textColor', color)" /></label></div>
<div :class="['theme-preview', `theme-preview-${scoreTheme.mode}`]" :style="{ '--preview-theme': scoreTheme.themeColor, '--preview-text': scoreTheme.textColor }"><strong>85.5</strong><span>比赛成绩<small>LIVE RESULTS</small></span></div>
<el-button text type="primary" @click="resetScoreTheme">恢复默认</el-button>
</div>
</section>
<div v-if="selectedProject" class="result-heading">
<div><b>{{ selectedProject.projectName }}</b><span>{{ selectedProject.groupCount }} 个组别</span></div>
@ -110,6 +117,13 @@
</el-dialog>
<el-dialog v-model="rankingVisible" :title="`投出${castEntityLabel}排名`" width="540px"><el-alert type="info" :closable="false" title="大屏将仅展示后台选中的项目组别排名"/><el-form label-position="top" class="ranking-form"><el-form-item label="展示对象"><el-radio-group v-model="castEntityKind"><el-radio-button label="individual">个人排名</el-radio-button><el-radio-button label="team">团队排名</el-radio-button></el-radio-group></el-form-item><el-form-item label="项目 / 组别" required><el-select v-model="rankingKey" placeholder="选择项目 / 组别" style="width:100%"><el-option v-for="x in rankingOptions" :key="x.key" :label="`${x.projectName} / ${x.groupName}`" :value="x.key"/></el-select></el-form-item></el-form><template #footer><el-button @click="rankingVisible=false">取消</el-button><el-button type="primary" :loading="actionLoading" :disabled="!rankingKey" @click="castRanking">确定投屏</el-button></template></el-dialog>
<el-dialog v-model="themeSettingsVisible" title="成绩大屏配色" width="440px" append-to-body>
<p class="theme-dialog-hint">修改后,已打开的成绩和排名大屏会同步更新。</p>
<div class="theme-dialog-mode"><span>主题模式</span><el-radio-group :model-value="scoreTheme.mode" aria-label="大屏主题" @change="updateScoreThemeMode"><el-radio-button label="light">亮色主题</el-radio-button><el-radio-button label="dark">暗色主题</el-radio-button></el-radio-group></div>
<div class="theme-dialog-fields"><label>主题色<el-color-picker :model-value="scoreTheme.themeColor" :predefine="themeColorPresets" @change="color => updateScoreTheme('themeColor', color)" /></label><label>文字颜色<el-color-picker :model-value="scoreTheme.textColor" :predefine="textColorPresets" @change="color => updateScoreTheme('textColor', color)" /></label></div>
<div :class="['theme-preview', 'theme-dialog-preview', `theme-preview-${scoreTheme.mode}`]" :style="{ '--preview-theme': scoreTheme.themeColor, '--preview-text': scoreTheme.textColor }"><strong>85.5</strong><span>比赛成绩<small>LIVE RESULTS</small></span></div>
<template #footer><el-button @click="resetScoreTheme">恢复默认</el-button><el-button type="primary" @click="themeSettingsVisible=false">完成</el-button></template>
</el-dialog>
<el-dialog v-model="reasonVisible" :title="reasonAction === 'rematch' ? '安排重赛' : '作废成绩'" width="520px"><el-form label-position="top" class="reason-form"><el-form-item :label="reasonAction === 'rematch' ? '重赛原因' : '作废原因'" required><el-input v-model="reasonText" type="textarea" :rows="4" maxlength="200" show-word-limit placeholder="请填写 2-200 个字符"/></el-form-item></el-form><template #footer><el-button @click="reasonVisible=false">取消</el-button><el-button type="danger" :loading="actionLoading" @click="submitReason">{{ reasonAction === 'rematch' ? '确认重赛' : '确认作废' }}</el-button></template></el-dialog>
@ -124,6 +138,7 @@ import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { ArrowDown, Picture } from '@element-plus/icons-vue';
import { useTagsViewRoutes } from '/@/stores/tagsViewRoutes';
import { defaultScoreScreenTheme, normalizeScoreScreenTheme, readScoreScreenTheme, saveScoreScreenTheme, scoreScreenModeTextColors, scoreScreenThemeChannel, scoreScreenThemeKey } from '../score-screen-theme.mjs';
import {
workbenchSessionsGet, workbenchSessionEntriesGet, workbenchScreenCastPost,
workbenchScreenCastCurrentGet, workbenchScreenCastWithdrawPost, workbenchCorrectionsGet,
@ -143,7 +158,7 @@ const loadingSessions=ref(false), loadingEntries=ref(false), entriesSyncError=re
const offline=computed(()=>entriesSyncError.value||castSyncError.value);
const syncStatusText=computed(()=>entriesSyncError.value&&castSyncError.value?'成绩与投屏状态同步异常':entriesSyncError.value?'成绩同步异常':castSyncError.value?'投屏状态同步异常':'实时同步');
const workbenchTableRef=ref(null), batchConfirmDialogOpen=ref(false);
const detailVisible=ref(false), detailRow=ref(null), judgeDetailVisible=ref(false), selectedJudgeSheet=ref(null), annotationVisible=ref(false), annotationUrl=ref(''), rankingVisible=ref(false), reasonVisible=ref(false), reasonAction=ref(''), reasonText=ref(''), reasonRow=ref(null), actionLoading=ref(false), rowActionId=ref(null);
const detailVisible=ref(false), detailRow=ref(null), judgeDetailVisible=ref(false), selectedJudgeSheet=ref(null), annotationVisible=ref(false), annotationUrl=ref(''), rankingVisible=ref(false), themeSettingsVisible=ref(false), reasonVisible=ref(false), reasonAction=ref(''), reasonText=ref(''), reasonRow=ref(null), actionLoading=ref(false), rowActionId=ref(null);
const correctionVisible=ref(false), correctionRow=ref(null), correctionForm=ref({judgeRoleType:null,scoreSheetId:null,reason:''});
const corrections=ref([]), loadingCorrections=ref(false), correctionKeyword=ref(''), correctionStatus=ref(null);
let pollTimer, entriesController=null, castController=null;
@ -152,6 +167,7 @@ let mountedReady=false, componentActive=false, destroyed=false;
let entriesFailureCount=0, castFailureCount=0, entriesNextRetryAt=0, castNextRetryAt=0;
const retryDelays=[10000,20000,40000,60000];
let scoreScreenWindow=null;
let themeChannel=null;
const scoreScreenWindowName='yd-event-score-screen';
const scoreScreenChannelName=computed(()=>`yd-event-score-screen-${eventId.value}`);
@ -175,6 +191,22 @@ const selectedProject=computed(()=>sessionGroups.value.find(project=>project.key
const selectedProjectSessions=computed(()=>selectedProject.value?.sessions||[]);
const castEntityKind=ref(localStorage.getItem(`yd-event-cast-kind:${eventId.value}`)||'individual');
const castEntityLabel=computed(()=>castEntityKind.value==='team'?'团队':'个人');
const scoreTheme=ref(readScoreScreenTheme(eventId.value));
const themeColorPresets=['#1764BC','#087F8C','#6046A5','#B33551','#9A6521'];
const textColorPresets=computed(()=>scoreTheme.value.mode==='light'?['#173252','#233C55','#35465C','#38423D','#493E50']:['#FFFFFF','#E8F3FF','#FFF3D6','#D7FBF3','#F7E7FF']);
function publishScoreTheme(next){
scoreTheme.value=normalizeScoreScreenTheme(next);
try{saveScoreScreenTheme(eventId.value,scoreTheme.value)}catch{ElMessage.warning('大屏配色无法保存到本机,刷新页面后可能恢复默认')}
themeChannel?.postMessage(scoreTheme.value);
}
function updateScoreTheme(field,color){
if(color)publishScoreTheme({...scoreTheme.value,[field]:color});
}
function updateScoreThemeMode(mode){
if(mode!==scoreTheme.value.mode)publishScoreTheme({...scoreTheme.value,mode,textColor:scoreScreenModeTextColors[mode]});
}
function resetScoreTheme(){publishScoreTheme({...defaultScoreScreenTheme})}
function onThemeStorage(event){if(event.key===scoreScreenThemeKey(eventId.value)||event.key===null)scoreTheme.value=readScoreScreenTheme(eventId.value)}
const rankingKey=ref('');
const rankingOptions=computed(()=>{const map=new Map();for(const x of sessions.value){if(x.eventProjectId&&x.eventProjectGroupId){const key=`${x.eventProjectId}:${x.eventProjectGroupId}`;if(!map.has(key))map.set(key,{...x,key})}}return [...map.values()]});
const rankingSelection=computed(()=>rankingOptions.value.find(x=>x.key===rankingKey.value));
@ -677,6 +709,8 @@ onMounted(async ()=>{
if (storesTagsViewRoutes.isTagsViewCurrenFull) storesTagsViewRoutes.setCurrenFullscreen(false);
activeTab.value=route.path==='/scoring/cast'?'screen':'results';
componentActive=true;
if('BroadcastChannel' in window){themeChannel=new BroadcastChannel(scoreScreenThemeChannel(eventId.value));themeChannel.onmessage=event=>{scoreTheme.value=normalizeScoreScreenTheme(event.data)}}
window.addEventListener('storage',onThemeStorage);
document.addEventListener('visibilitychange',onVisibilityChange);
await loadSessions();
mountedReady=true;
@ -684,9 +718,9 @@ onMounted(async ()=>{
void loadCurrentCast();
startPolling();
});
onActivated(()=>{componentActive=true;if(mountedReady)startPolling(true)});
onActivated(()=>{componentActive=true;scoreTheme.value=readScoreScreenTheme(eventId.value);if(mountedReady)startPolling(true)});
onDeactivated(()=>{componentActive=false;stopPolling()});
onBeforeUnmount(()=>{destroyed=true;componentActive=false;stopPolling();document.removeEventListener('visibilitychange',onVisibilityChange)});
onBeforeUnmount(()=>{destroyed=true;componentActive=false;stopPolling();themeChannel?.close();window.removeEventListener('storage',onThemeStorage);document.removeEventListener('visibilitychange',onVisibilityChange)});
</script>
<style scoped lang="scss">
@ -915,6 +949,29 @@ onBeforeUnmount(()=>{destroyed=true;componentActive=false;stopPolling();document
.control-buttons :deep(.el-button) { margin-left: 0; border-radius: 8px; white-space: nowrap; }
.control-buttons :deep(.checkin-button) { border-color: #9fded7; color: #168779; background: #f1fbf9; }
.control-buttons :deep(.checkin-button:hover) { border-color: #36b9aa; color: #087568; background: #e7f8f5; }
.screen-theme-control { grid-column: 1 / -1; display: flex; align-items: center; gap: 18px; min-width: 0; padding-top: 13px; border-top: 1px solid #dceaf6; }
.theme-control-copy { min-width: 0; }
.theme-control-copy b, .theme-control-copy small { display: block; }
.theme-control-copy b { color: #24425f; font-size: 13px; }
.theme-control-copy small { margin-top: 4px; color: #8497aa; font-size: 11px; white-space: nowrap; }
.theme-color-fields { display: flex; align-items: center; gap: 16px; }
.theme-color-fields label { display: flex; align-items: center; gap: 8px; color: #526d87; font-size: 12px; font-weight: 600; white-space: nowrap; }
.theme-color-fields :deep(.el-color-picker__trigger) { width: 34px; height: 34px; border-radius: 8px; }
.theme-preview { display: flex; align-items: center; gap: 9px; margin-left: auto; padding: 7px 12px; border: 1px solid color-mix(in srgb, var(--preview-theme) 55%, #fff); border-radius: 8px; color: var(--preview-text); background: linear-gradient(135deg, color-mix(in srgb, var(--preview-theme) 70%, #051534), color-mix(in srgb, var(--preview-theme) 28%, #06132f)); white-space: nowrap; }
.theme-preview-light { border-color: color-mix(in srgb, var(--preview-theme) 25%, #dbe6f0); background: linear-gradient(135deg, color-mix(in srgb, var(--preview-theme) 12%, #fff), #f8fbff); }
.theme-preview strong { font-size: 19px; font-variant-numeric: tabular-nums; }
.theme-preview span, .theme-preview small { display: block; }
.theme-preview span { font-size: 11px; font-weight: 700; }
.theme-preview small { margin-top: 1px; font-size: 8px; font-weight: 500; letter-spacing: .06em; }
.screen-theme-control > :deep(.el-button) { margin-left: 0; white-space: nowrap; }
.theme-dialog-hint { margin: 0 0 20px; color: #6f8399; font-size: 12px; }
.theme-dialog-mode { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; color: #35516d; font-size: 13px; font-weight: 600; }
.theme-dialog-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.theme-dialog-fields label { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; border: 1px solid #e1e9f2; border-radius: 9px; color: #35516d; background: #f8fbff; font-size: 13px; font-weight: 600; }
.theme-dialog-preview { justify-content: center; margin: 18px 0 0; padding: 18px; }
.theme-dialog-preview strong { font-size: 36px; }
.theme-dialog-preview span { font-size: 16px; }
.theme-dialog-preview small { font-size: 10px; }
.ranking-form { margin-top: 18px; }
.ranking-form :deep(.el-form-item:last-child) { margin-bottom: 0; }
.result-heading {
@ -963,6 +1020,8 @@ onBeforeUnmount(()=>{destroyed=true;componentActive=false;stopPolling();document
.screen-control-panel { grid-template-columns: 1fr auto minmax(250px, 1fr); }
.control-copy { grid-column: 1 / -1; }
.control-copy small { display: none; }
.screen-theme-control { flex-wrap: wrap; gap: 10px 18px; }
.theme-preview { margin-left: 0; }
.filterbar { align-items: flex-start; flex-wrap: wrap; }
.filter-spacer { display: none; }
.toolbar-actions { margin-left: auto; }
@ -977,6 +1036,7 @@ onBeforeUnmount(()=>{destroyed=true;componentActive=false;stopPolling();document
.session-panel { width: 220px; padding: 14px 12px; }
.screen-control-panel { grid-template-columns: auto minmax(220px, 1fr); gap: 10px; padding: 13px; }
.control-buttons { grid-column: 1 / -1; justify-content: flex-end; }
.theme-control-copy { width: 100%; }
.result-heading small { display: none; }
.filter-fields { flex-wrap: wrap; }
.toolbar-actions { width: 100%; padding: 8px 0 0; border-left: 0; border-top: 1px solid #edf1f6; }