update:

成绩列表样式和原型保持一致
桌面端添加测试&正式服切换按钮
This commit is contained in:
ltx 2026-09-18 17:59:47 +08:00
parent 3460944825
commit c62fa0b56d
4 changed files with 271 additions and 52 deletions

View File

@ -29,6 +29,7 @@ pnpm electron:build:dir
选择结果保存在本机,下次启动会继续使用上一次的环境。切换环境时应用会清除当前登录状态,防止跨环境复用 Token。
Electron 的登录 Token 使用窗口级 `sessionStorage` 保存,以兼容 `ydevent://` 自定义协议;关闭应用后需要重新登录。
接口环境面板位于右下角,可点击“收起”隐藏为贴边小按钮;收起状态会被保存。
启动应用前也可以通过环境变量覆盖地址:

View File

@ -6,16 +6,33 @@
<CloseFull v-if="!themeConfig.isLockScreen" />
<Upgrade v-if="getVersion" />
<Sponsors />
<div v-if="showApiEnvironmentSwitcher" class="electron-environment-switcher">
<span>接口环境</span>
<el-select v-model="selectedApiEnvironment" size="small" :loading="apiEnvironmentChanging" @change="changeApiEnvironment">
<el-option
v-for="item in apiEnvironmentOptions"
:key="item.value"
:label="`${item.label} · ${item.origin}`"
:value="item.value"
/>
</el-select>
<div
v-if="showApiEnvironmentSwitcher"
:class="['electron-environment-switcher', { collapsed: apiEnvironmentCollapsed }]"
>
<button
v-if="apiEnvironmentCollapsed"
class="environment-reopen-button"
type="button"
:title="`当前:${currentApiEnvironmentLabel},点击展开接口环境设置`"
@click="toggleApiEnvironmentSwitcher"
>
环境
</button>
<template v-else>
<span>接口环境</span>
<el-select v-model="selectedApiEnvironment" size="small" :loading="apiEnvironmentChanging" @change="changeApiEnvironment">
<el-option
v-for="item in apiEnvironmentOptions"
:key="item.value"
:label="`${item.label} · ${item.origin}`"
:value="item.value"
/>
</el-select>
<button class="environment-collapse-button" type="button" title="隐藏接口环境设置" @click="toggleApiEnvironmentSwitcher">
收起
</button>
</template>
</div>
</el-config-provider>
</template>
@ -51,10 +68,19 @@ const selectedApiEnvironment = ref<'test' | 'production'>('production');
const currentApiEnvironment = ref<'test' | 'production'>('production');
const apiEnvironmentOptions = ref<ApiEnvironmentState['environments']>([]);
const apiEnvironmentChanging = ref(false);
const apiEnvironmentCollapsed = ref(Local.get('electronApiEnvironmentCollapsed') === true);
const currentApiEnvironmentLabel = computed(
() => apiEnvironmentOptions.value.find((item) => item.value === currentApiEnvironment.value)?.label || '正式服'
);
const showApiEnvironmentSwitcher = computed(
() => Boolean(window.electron?.isDesktop) && route.meta.isPublic !== true && route.query.presentation !== '1'
);
const toggleApiEnvironmentSwitcher = () => {
apiEnvironmentCollapsed.value = !apiEnvironmentCollapsed.value;
Local.set('electronApiEnvironmentCollapsed', apiEnvironmentCollapsed.value);
};
const loadApiEnvironment = async () => {
if (!window.electron?.apiEnvironment) return;
const state = await window.electron.apiEnvironment.get();
@ -154,7 +180,7 @@ watch(
position: fixed;
z-index: 9999;
right: 18px;
top: 14px;
bottom: 18px;
display: flex;
align-items: center;
gap: 9px;
@ -168,7 +194,49 @@ watch(
backdrop-filter: blur(10px);
}
.electron-environment-switcher.collapsed {
right: 0;
bottom: 72px;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
}
.electron-environment-switcher .el-select {
width: 240px;
}
.environment-collapse-button,
.environment-reopen-button {
border: 1px solid var(--el-border-color-light);
color: var(--el-text-color-regular);
background: var(--el-bg-color);
cursor: pointer;
}
.environment-collapse-button {
padding: 5px 8px;
border-radius: 6px;
font-size: 12px;
}
.environment-collapse-button:hover,
.environment-reopen-button:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary-light-5);
}
.environment-reopen-button {
width: 30px;
min-height: 58px;
padding: 7px 5px;
border-right: 0;
border-radius: 8px 0 0 8px;
box-shadow: 0 5px 18px rgba(20, 45, 80, 0.12);
font-size: 12px;
line-height: 1.4;
writing-mode: vertical-rl;
}
</style>

View File

@ -1,19 +1,18 @@
<template>
<div :class="['score-screen', `view-${viewType}`, `entity-${displayKind}`, { embedded: !isBigScreen, offline }]" @mousemove="showControls">
<div class="screen-controls" :class="{ visible: controlsVisible }"><span :class="['connection-dot', { offline }]"></span><b>{{ offline ? '连接异常' : '实时同步' }}</b><button @click="loadCast"><el-icon><Refresh /></el-icon>刷新数据</button><button @click="toggleFullscreen"><el-icon><FullScreen /></el-icon>{{ fullscreenButtonText }}</button></div>
<div :class="['score-screen', `view-${viewType}`, `entity-${displayKind}`, { embedded: !isBigScreen, offline }]">
<div class="design-stage" :style="{ '--screen-scale': screenScale }">
<div class="ambient ambient-one"></div><div class="ambient ambient-two"></div><div class="screen-grid"></div><div class="horizon"></div>
<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>
<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>
<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-score"><strong>{{ value(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>{{ value(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="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">{{ animatedValue(`metric:${item.key}`, payload[item.field]) }}</strong><div class="metric-line"></div><b>{{ item.label }}</b></article></div>
</section>
<section v-else class="ranking-layout">
<div class="ranking-heading"><div><span>{{ displayKind === 'team' ? 'TEAM RANKING' : 'INDIVIDUAL RANKING' }}</span><h2>{{ displayKindLabel }}排名</h2></div><small>共 {{ rankingTotal }} 个{{ displayKind === 'team' ? '队伍' : '参赛者' }}<template v-if="rankingPageCount > 1"> · 第 {{ rankingPageIndex + 1 }}/{{ rankingPageCount }} 页</template></small></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>{{ value(row.totalScore ?? row.score) }}</strong><small>分</small></div></article></div>
<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 v-else class="ranking-empty">当前组别暂无可展示排名</div>
</section>
<footer class="screen-footer"><span></span><b>跃动赛事 · 实时成绩系统</b><span></span></footer>
@ -25,9 +24,9 @@
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { DocumentChecked, Flag, FullScreen, Location, Medal, Refresh, Setting, Trophy, User, UserFilled } from '@element-plus/icons-vue';
import { DocumentChecked, Flag, Location, Medal, Setting, Trophy, User, UserFilled } from '@element-plus/icons-vue';
import { useTagsViewRoutes } from '/@/stores/tagsViewRoutes';
import { workbenchScreenCastCurrentGet } from '/@/api/match/workbench';
@ -35,9 +34,8 @@ 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 fullscreenDocument=route.query.presentation==='1'&&window.parent!==window?window.parent.document:document;
const cast=ref(null),offline=ref(false),controlsVisible=ref(true),rankPage=ref(0),fullscreenActive=ref(Boolean(fullscreenDocument.fullscreenElement)),viewport=ref({width:window.innerWidth,height:window.innerHeight});
let pollTimer,controlTimer,rankTimer,castChannel;
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;
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}
@ -64,16 +62,36 @@ function displaySubName(row){if(displayKind.value==='team')return 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).replace(/\.00$/,'').replace(/(\.\d)0$/,'$1'):input}
const isBigScreen=computed(()=>storesTagsViewRoutes.isTagsViewCurrenFull),fullscreenButtonText=computed(()=>fullscreenActive.value?'退出全屏':'全屏显示');
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 animateScoreNumbers(){
window.cancelAnimationFrame(numberAnimationFrame);
const targets={};
if(viewType.value==='score'){
targets.hero=payload.value.totalScore??payload.value.score;
for(const item of scoreCards.value)targets[`metric:${item.key}`]=payload.value[item.field];
}else{
rankingRows.value.forEach((row,index)=>{targets[rankAnimationKey(row,index)]=row.totalScore??row.score});
}
if(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches){animatedNumbers.value=targets;return}
const numericTargets=Object.fromEntries(Object.entries(targets).filter(([,target])=>Number.isFinite(Number(target))).map(([key,target])=>[key,Number(target)]));
const staticTargets=Object.fromEntries(Object.entries(targets).filter(([,target])=>!Number.isFinite(Number(target))));
const startedAt=performance.now(),duration=viewType.value==='score'?1280:960;
const tick=(now)=>{
const progress=Math.min(1,(now-startedAt)/duration),eased=1-Math.pow(1-progress,3);
animatedNumbers.value={...staticTargets,...Object.fromEntries(Object.entries(numericTargets).map(([key,target])=>[key,target*eased]))};
if(progress<1)numberAnimationFrame=window.requestAnimationFrame(tick);
};
numberAnimationFrame=window.requestAnimationFrame(tick);
}
const isBigScreen=computed(()=>storesTagsViewRoutes.isTagsViewCurrenFull);
const screenScale=computed(()=>Math.min(viewport.value.width/1920,viewport.value.height/1080));
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 toggleFullscreen(){if(fullscreenDocument.fullscreenElement)fullscreenDocument.exitFullscreen?.().catch(()=>{});else fullscreenDocument.documentElement.requestFullscreen?.({navigationUI:'hide'}).catch(()=>{})}
function onFullscreenChange(){fullscreenActive.value=Boolean(fullscreenDocument.fullscreenElement)}
function onViewportResize(){viewport.value={width:window.innerWidth,height:window.innerHeight}}
function showControls(){controlsVisible.value=true;window.clearTimeout(controlTimer);controlTimer=window.setTimeout(()=>controlsVisible.value=false,3200)}
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);fullscreenDocument.addEventListener('fullscreenchange',onFullscreenChange);window.addEventListener('resize',onViewportResize);onViewportResize();showControls()});
onBeforeUnmount(()=>{window.clearInterval(pollTimer);window.clearInterval(rankTimer);window.clearTimeout(controlTimer);castChannel?.close();fullscreenDocument.removeEventListener('fullscreenchange',onFullscreenChange);window.removeEventListener('resize',onViewportResize)});
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)});
</script>
<style scoped lang="scss">
@ -85,7 +103,6 @@ onBeforeUnmount(()=>{window.clearInterval(pollTimer);window.clearInterval(rankTi
.ranking-layout{display:flex;flex-direction:column;min-height:0;flex:1;padding:clamp(18px,2.2vh,38px) clamp(18px,2vw,52px);border:1px solid rgba(85,193,255,.38);border-radius:clamp(17px,1.5vw,40px);background:linear-gradient(145deg,rgba(8,54,151,.72),rgba(3,22,82,.82));box-shadow:inset 0 1px rgba(255,255,255,.07),0 22px 65px rgba(0,4,36,.3)}.ranking-heading{display:flex;align-items:end;justify-content:space-between;flex:none;margin-bottom:clamp(12px,1.8vh,30px);padding:0 clamp(5px,.5vw,14px)}.ranking-heading span{color:var(--cyan);font-size:clamp(9px,.65vw,17px);font-weight:700;letter-spacing:.3em}.ranking-heading h2{margin:.2vh 0 0;font-size:clamp(23px,2vw,52px);letter-spacing:.08em}.ranking-heading>small{color:#8fbddb;font-size:clamp(11px,.8vw,20px)}.ranking-list{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:repeat(3,minmax(0,1fr));gap:clamp(8px,1.1vh,20px) clamp(14px,1.5vw,38px);min-height:0;flex:1}.rank-row{--rank-color:#28bdf3;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:clamp(10px,1vw,26px);min-height:0;padding:clamp(8px,.8vh,15px) clamp(11px,1.05vw,27px);border:1px solid rgba(75,164,235,.28);border-radius:clamp(12px,1vw,26px);background:linear-gradient(90deg,rgba(10,58,151,.72),rgba(5,31,101,.46));animation:rank-in .55s both;animation-delay:var(--delay)}.rank-row.podium{border-color:rgba(84,220,255,.5);box-shadow:inset 4px 0 var(--rank-color),0 8px 26px rgba(0,7,48,.2)}.rank-row:has(.rank-1){--rank-color:#ffd65c}.rank-row:has(.rank-2){--rank-color:#bde4ff}.rank-row:has(.rank-3){--rank-color:#f3a769}.rank-badge{display:grid;place-items:center;width:clamp(42px,3.5vw,90px);height:clamp(42px,3.5vw,90px);border:1px solid rgba(80,207,255,.62);border-radius:50%;color:#d5f7ff;background:rgba(5,39,119,.78);font-size:clamp(18px,1.5vw,38px);font-weight:800}.rank-badge.rank-1,.rank-badge.rank-2,.rank-badge.rank-3{color:#071743;border-color:rgba(255,255,255,.4);background:linear-gradient(145deg,var(--rank-color),#9d6b18);box-shadow:0 0 24px rgba(255,215,92,.25)}.rank-person{min-width:0}.rank-title{display:flex;align-items:center;gap:.8vw;margin-bottom:clamp(5px,.6vh,11px)}.rank-title strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:clamp(14px,1.1vw,28px)}.rank-title small{overflow:hidden;color:#82b6dc;font-size:clamp(9px,.7vw,17px);text-overflow:ellipsis;white-space:nowrap}.rank-track{height:clamp(7px,.75vh,14px);overflow:hidden;border-radius:999px;background:rgba(2,15,61,.72);box-shadow:inset 0 2px 6px rgba(0,0,0,.28)}.rank-track i{display:block;height:100%;border-radius:999px;background:linear-gradient(90deg,#0879ff,var(--rank-color));box-shadow:0 0 14px rgba(40,189,243,.4);transition:width .8s cubic-bezier(.2,.8,.2,1)}.rank-value{display:flex;align-items:baseline;justify-content:end;width:clamp(70px,5.4vw,145px)}.rank-value strong{font-size:clamp(25px,2.25vw,58px);font-variant-numeric:tabular-nums;letter-spacing:-.04em}.rank-value small{margin-left:.25em;color:#8ebbd8;font-size:clamp(9px,.65vw,17px)}.ranking-empty{display:grid;place-items:center;flex:1;color:#8ebbd8;font-size:clamp(17px,1.4vw,36px)}
.screen-footer{display:flex;align-items:center;justify-content:center;gap:clamp(10px,1.2vw,28px);height:clamp(20px,2.8vh,46px);flex:none;color:#609ac6;font-size:clamp(9px,.6vw,16px);letter-spacing:.16em}.screen-footer span{width:clamp(40px,5vw,130px);height:1px;background:linear-gradient(90deg,transparent,#277ebc)}.screen-footer span:last-child{transform:scaleX(-1)}
.waiting{position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;height:100%;text-align:center}.waiting-orbit{position:relative;display:grid;place-items:center;width:clamp(100px,9vw,220px);height:clamp(100px,9vw,220px);margin-bottom:clamp(26px,3vh,50px);border:1px solid rgba(86,222,255,.26);border-radius:50%;box-shadow:0 0 70px rgba(32,154,255,.2)}.waiting-orbit::before,.waiting-orbit::after{content:"";position:absolute;border:1px solid rgba(83,222,255,.18);border-radius:50%;animation:orbit 6s linear infinite}.waiting-orbit::before{inset:-18%}.waiting-orbit::after{inset:18%;animation-direction:reverse;animation-duration:4s}.waiting-orbit span{color:#d7fbff;font-size:clamp(28px,2.4vw,58px);font-weight:900;letter-spacing:.08em}.waiting-orbit i{position:absolute;width:9px;height:9px;border-radius:50%;background:var(--cyan);box-shadow:0 0 14px var(--cyan)}.waiting-orbit i:first-child{top:-5px}.waiting-orbit i:nth-child(2){right:2%;bottom:19%}.waiting-orbit i:nth-child(3){left:8%;bottom:8%}.waiting>small{color:var(--cyan);font-size:clamp(10px,.75vw,18px);font-weight:700;letter-spacing:.42em}.waiting h1{margin:clamp(11px,1.4vh,24px) 0 clamp(7px,.8vh,14px);font-size:clamp(34px,3.2vw,82px);letter-spacing:.1em}.waiting p{margin:0;color:#78aace;font-size:clamp(13px,1vw,25px)}
.screen-controls{position:absolute;z-index:20;right:16px;top:14px;display:flex;align-items:center;gap:8px;padding:7px 8px;border:1px solid rgba(91,199,255,.2);border-radius:11px;background:rgba(2,17,58,.74);box-shadow:0 8px 28px rgba(0,3,30,.32);opacity:0;transform:translateY(-10px);transition:.25s;backdrop-filter:blur(14px)}.screen-controls.visible{opacity:1;transform:none}.screen-controls b{margin-right:4px;color:#80d9bd;font-size:11px}.connection-dot{width:7px;height:7px;border-radius:50%;background:#31db91;box-shadow:0 0 9px #31db91}.connection-dot.offline{background:#ff6d7c;box-shadow:0 0 9px #ff6d7c}.screen-controls button{padding:6px 10px;border:1px solid rgba(255,255,255,.15);border-radius:7px;color:#d8efff;background:rgba(255,255,255,.04);cursor:pointer}.screen-controls button:hover{border-color:#54dcff;background:rgba(57,173,255,.16)}
.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}}
@ -221,8 +238,6 @@ onBeforeUnmount(()=>{window.clearInterval(pollTimer);window.clearInterval(rankTi
.waiting p { font-size: 18px; }
/* 统一 SVG 图标语言与奖牌细节,避免依赖系统字体符号。 */
.screen-controls button { display: inline-flex; align-items: center; gap: 6px; }
.screen-controls button .el-icon { font-size: 14px; }
.title-wing { filter: drop-shadow(0 0 8px rgba(59, 225, 255, 0.55)); }
.title-wing i {
border-radius: 2px;
@ -302,4 +317,73 @@ onBeforeUnmount(()=>{window.clearInterval(pollTimer);window.clearInterval(rankTi
.rank-row:has(.rank-1) { border-color: rgba(255, 215, 91, 0.45); }
.rank-row:has(.rank-2) { border-color: rgba(189, 228, 255, 0.42); }
.rank-row:has(.rank-3) { border-color: rgba(243, 167, 105, 0.42); }
/* 更有层次的动态舞台;所有动效只改变合成层,确保大屏持续轮播时保持流畅。 */
.aurora-strip {
position: absolute;
left: 12%;
right: 12%;
top: -145px;
height: 300px;
border-radius: 50%;
background: conic-gradient(from 90deg at 50% 100%, transparent, rgba(78, 235, 255, 0.42), rgba(40, 110, 255, 0.18), transparent 72%);
filter: blur(48px);
opacity: 0.58;
animation: aurora-breathe 8s ease-in-out infinite alternate;
}
.frame-corners { position: absolute; z-index: 1; inset: 22px; pointer-events: none; }
.frame-corners i { position: absolute; width: 54px; height: 54px; opacity: 0.52; }
.frame-corners i::before,
.frame-corners i::after { content: ''; position: absolute; background: linear-gradient(90deg, #63ebff, rgba(99, 235, 255, 0.08)); box-shadow: 0 0 10px rgba(69, 219, 255, 0.35); }
.frame-corners i::before { width: 54px; height: 2px; }
.frame-corners i::after { width: 2px; height: 54px; }
.frame-corners i:nth-child(1) { left: 0; top: 0; }
.frame-corners i:nth-child(2) { right: 0; top: 0; transform: rotate(90deg); }
.frame-corners i:nth-child(3) { right: 0; bottom: 0; transform: rotate(180deg); }
.frame-corners i:nth-child(4) { left: 0; bottom: 0; transform: rotate(270deg); }
.design-stage .ambient-one { animation: ambient-float-one 12s ease-in-out infinite alternate; }
.design-stage .ambient-two { animation: ambient-float-two 14s ease-in-out infinite alternate; }
.design-stage .screen-grid { animation: grid-flow 18s linear infinite; }
.panel-shine::after {
content: '';
position: absolute;
top: -35%;
bottom: -35%;
left: -24%;
width: 14%;
transform: rotate(15deg);
background: linear-gradient(90deg, transparent, rgba(132, 239, 255, 0.12), transparent);
animation: panel-sweep 7s ease-in-out infinite;
}
.hero-meta { position: absolute; z-index: 2; left: 28px; bottom: 20px; display: flex; gap: 10px; }
.hero-meta span { padding: 7px 12px; border: 1px solid rgba(100, 220, 255, 0.22); border-radius: 999px; color: #78b9e7; background: rgba(3, 28, 91, 0.5); font-size: 12px; backdrop-filter: blur(8px); }
.hero-meta b { margin-left: 5px; color: #d7f9ff; font-size: 13px; }
.score-ticker { display: inline-block; will-change: transform, contents; }
.hero-score strong { animation: score-arrive 0.72s cubic-bezier(0.18, 0.82, 0.3, 1) both; }
.metric-card .score-ticker { animation: number-pop 0.72s cubic-bezier(0.18, 0.9, 0.32, 1.18) both; animation-delay: calc(var(--delay) + 100ms); }
.rank-row .score-ticker { animation: number-pop 0.62s cubic-bezier(0.18, 0.9, 0.32, 1.18) both; animation-delay: var(--delay); }
.score-beam { animation: beam-breathe 2.8s ease-in-out infinite; }
.ranking-heading span { display: flex; align-items: center; gap: 9px; }
.ranking-heading span > i { width: 7px; height: 7px; border-radius: 50%; background: #49e6ff; box-shadow: 0 0 12px #49e6ff; animation: live-pulse 1.8s ease-in-out infinite; }
.ranking-status { display: flex; flex-direction: column; align-items: flex-end; gap: 9px; }
.ranking-status small { color: #8fbddb; font-size: 14px; }
.page-dots { display: flex; gap: 6px; }
.page-dots i { width: 18px; height: 3px; border-radius: 99px; background: rgba(111, 190, 238, 0.28); transition: width 0.35s ease, background 0.35s ease, box-shadow 0.35s ease; }
.page-dots i.active { width: 34px; background: #56e7ff; box-shadow: 0 0 10px rgba(86, 231, 255, 0.6); }
.rank-row { transition: border-color 0.3s ease, background 0.3s ease, transform 0.3s ease; }
@keyframes aurora-breathe { from { opacity: 0.36; transform: scaleX(0.9); } to { opacity: 0.68; transform: scaleX(1.08); } }
@keyframes ambient-float-one { from { transform: translate3d(0, 0, 0) scale(1); } to { transform: translate3d(90px, -35px, 0) scale(1.12); } }
@keyframes ambient-float-two { from { transform: translate3d(0, 0, 0) scale(1); } to { transform: translate3d(-75px, -25px, 0) scale(1.1); } }
@keyframes grid-flow { to { background-position: 96px 96px; } }
@keyframes panel-sweep { 0%, 18% { transform: translateX(0) rotate(15deg); opacity: 0; } 38% { opacity: 1; } 68%, 100% { transform: translateX(980%) rotate(15deg); opacity: 0; } }
@keyframes score-arrive { from { opacity: 0; transform: translateY(22px) scale(0.9); filter: blur(7px); } to { opacity: 1; transform: none; filter: blur(0); } }
@keyframes number-pop { 0% { opacity: 0; transform: translateY(15px) scale(0.86); } 68% { opacity: 1; transform: translateY(-4px) scale(1.045); } 100% { opacity: 1; transform: none; } }
@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); } }
@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; }
.rank-track i, .page-dots i, .cast-enter-active, .cast-leave-active { transition: none !important; }
}
</style>

View File

@ -47,17 +47,25 @@
<span>LIVE</span><div><b>当前正在展示:{{ currentCastSummary }}</b><small>{{ formatTime(currentCast.castTime) }}</small></div><el-button text type="primary" @click="openScoreScreen">查看大屏</el-button><el-button text type="danger" @click="withdrawCast">撤回投屏</el-button>
</div>
<div class="table-wrap"><el-table ref="workbenchTableRef" v-loading="loadingEntries" :data="entries" row-key="sessionEntryId" height="100%" class="workbench-table" highlight-current-row @selection-change="onSelection">
<el-table-column v-if="activeTab === 'results'" type="selection" width="44" :selectable="canConfirm" :reserve-selection="true" />
<el-table-column prop="playOrder" label="序号" width="64" align="center"><template #default="{ row }"><b class="order">{{ row.playOrder }}</b><i v-if="row.isRematch" class="rematch-tag">重赛</i></template></el-table-column>
<el-table-column label="选手 / 队伍" min-width="190"><template #default="{ row }"><strong>{{ row.teamName || row.participantName }}</strong><small class="subtext">{{ row.entryNo }} · {{ row.groupName }}<template v-if="row.unitName"> · {{row.unitName}}</template></small></template></el-table-column>
<el-table-column label="裁判提交" width="125" align="center"><template #default="{ row }"><span :class="['submit-progress', { done: allSubmitted(row) }]">{{ submittedCount(row) }}/{{ row.judgeScores?.length || 0 }}</span></template></el-table-column>
<el-table-column v-if="showDifficulty" label="难度" width="88" align="center"><template #default="{ row }">{{ score(row.result?.difficultyScore) }}</template></el-table-column>
<el-table-column label="表现" width="88" align="center"><template #default="{ row }">{{ score(row.result?.performanceScore) }}</template></el-table-column>
<el-table-column label="规定" width="88" align="center"><template #default="{ row }">{{ score(row.result?.requiredScore) }}</template></el-table-column>
<el-table-column label="扣分" width="88" align="center"><template #default="{ row }">{{ score(row.result?.deductionScore) }}</template></el-table-column>
<el-table-column label="总分" width="110" align="center"><template #default="{ row }"><b class="total" :class="{ dq: row.result?.isDisqualified }">{{ row.result?.isDisqualified ? 'DQ' : score(row.result?.totalScore ?? row.result?.scoreText) }}</b></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag v-if="row.result" :type="resultTag(row.result.resultStatus)" effect="light">{{ row.result.resultStatusName }}</el-tag><span v-else class="muted">计算中</span></template></el-table-column>
<div class="table-wrap"><el-table ref="workbenchTableRef" v-loading="loadingEntries" :data="entries" row-key="sessionEntryId" height="100%" border stripe scrollbar-always-on class="workbench-table score-matrix-table" highlight-current-row @selection-change="onSelection">
<el-table-column v-if="activeTab === 'results'" type="selection" width="44" fixed="left" :selectable="canConfirm" :reserve-selection="true" />
<el-table-column label="场次" width="78" fixed="left" align="center"><template #default>{{ selectedSessionDisplay }}</template></el-table-column>
<el-table-column label="场地" width="76" fixed="left" align="center"><template #default>{{ selectedSession?.venueName || '—' }}</template></el-table-column>
<el-table-column label="运动员姓名" min-width="132" fixed="left"><template #default="{ row }"><strong class="athlete-name">{{ row.teamName || row.participantName }}</strong><small class="subtext">{{ row.entryNo }} · {{ row.groupName }}</small></template></el-table-column>
<el-table-column label="单位" min-width="180" fixed="left"><template #default="{ row }"><span class="unit-name">{{ row.unitName || row.teamName || '—' }}</span></template></el-table-column>
<el-table-column v-for="group in judgeMatrixGroups" :key="group.key" :label="group.label" align="center" header-align="center">
<el-table-column v-for="judge in group.judges" :key="judge.key" :label="judge.label" width="112" align="center">
<template #default="{ row }"><span v-if="judgeScoreFor(row, judge.key)" :class="['judge-score', { pending: judgeScoreFor(row, judge.key).sheetStatus < 10 }]">{{ judgeScoreFor(row, judge.key).sheetStatus >= 10 ? score(judgeScoreFor(row, judge.key).totalScore) : '待提交' }}</span><span v-else class="muted">—</span></template>
</el-table-column>
</el-table-column>
<el-table-column label="裁判提交" width="92" align="center"><template #default="{ row }"><span :class="['submit-progress', { done: allSubmitted(row) }]">{{ submittedCount(row) }}/{{ row.judgeScores?.length || 0 }}</span></template></el-table-column>
<el-table-column v-if="showDifficulty" label="难度分" width="88" align="center"><template #default="{ row }">{{ score(row.result?.difficultyScore) }}</template></el-table-column>
<el-table-column label="表现分" width="88" align="center"><template #default="{ row }">{{ score(row.result?.performanceScore) }}</template></el-table-column>
<el-table-column label="规定分" width="88" align="center"><template #default="{ row }">{{ score(row.result?.requiredScore) }}</template></el-table-column>
<el-table-column label="扣分" width="78" align="center"><template #default="{ row }">{{ score(row.result?.deductionScore) }}</template></el-table-column>
<el-table-column label="总分" width="96" align="center"><template #default="{ row }"><b class="total" :class="{ dq: row.result?.isDisqualified }">{{ row.result?.isDisqualified ? 'DQ' : score(row.result?.totalScore ?? row.result?.scoreText) }}</b></template></el-table-column>
<el-table-column label="排名" width="72" fixed="right" align="center"><template #default="{ row }"><b :class="['rank-number', `rank-${row.result?.rank || 0}`]">{{ row.result?.rank || '—' }}</b></template></el-table-column>
<el-table-column label="状态" width="96" fixed="right" align="center"><template #default="{ row }"><el-tag v-if="row.result" :type="resultTag(row.result.resultStatus)" effect="light">{{ row.result.resultStatusName }}</el-tag><span v-else class="muted">计算中</span></template></el-table-column>
<el-table-column label="操作" :width="activeTab === 'screen' ? 190 : 250" fixed="right" align="center"><template #default="{ row }">
<div v-if="activeTab === 'screen'" class="row-actions"><el-tooltip :content="castDisabledReason(row)" :disabled="canCastForKind(row)"><span><el-button size="small" type="primary" :loading="actionLoading && rowActionId === row.sessionEntryId" :disabled="!canCastForKind(row)" @click="castScore(row)">投出{{ castEntityLabel }}成绩</el-button></span></el-tooltip><el-button size="small" @click="showDetail(row)">评分详情</el-button></div>
<div v-else class="row-actions"><el-button v-if="canConfirm(row)" size="small" type="success" :loading="actionLoading && rowActionId === row.sessionEntryId" @click="confirmOne(row)">确认成绩</el-button><el-button v-else-if="canCast(row)" size="small" type="primary" :loading="actionLoading && rowActionId === row.sessionEntryId" @click="castScore(row)">投出成绩</el-button><el-button v-else size="small" disabled>等待成绩</el-button><el-button size="small" @click="showDetail(row)">详情</el-button><el-dropdown trigger="click" @command="command => handleRowCommand(command, row)"><el-button size="small" class="more-button"><span class="more-button-content">更多<el-icon><ArrowDown /></el-icon></span></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="correction" :disabled="!row.judgeScores?.length">发起成绩修正</el-dropdown-item><el-dropdown-item command="rematch" divided>安排重赛</el-dropdown-item><el-dropdown-item command="export">导出本场评分单</el-dropdown-item><el-dropdown-item command="void" divided class="danger-menu-item">作废成绩</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div>
@ -123,6 +131,22 @@ 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));
const showDifficulty=computed(()=>!String(selectedSession.value?.scoreRuleCode||'').toUpperCase().includes('PRESCRIBED'));
const selectedSessionDisplay=computed(()=>{
if(!selectedSession.value)return '—';
return formatSessionName(selectedSession.value,sessions.value.findIndex(item=>item.sessionId===selectedSession.value.sessionId)).replace(/\s+/g,'');
});
const judgeMatrixGroups=computed(()=>{
const groups=new Map();
for(const row of entries.value){
for(const judge of row.judgeScores||[]){
const groupKey=String(judge.judgeRoleType||judge.judgeRoleName||'其他');
if(!groups.has(groupKey))groups.set(groupKey,{key:groupKey,label:judge.judgeRoleName||groupKey,judges:new Map()});
const judgeKey=`${groupKey}:${judge.judgeRole||judge.scoreSheetId}`;
if(!groups.get(groupKey).judges.has(judgeKey))groups.get(groupKey).judges.set(judgeKey,{key:judgeKey,role:judge.judgeRole||'',label:`${judge.judgeRole||'裁判'}${judge.judgeName?`(${judge.judgeName})`:''}`});
}
}
return [...groups.values()].map(group=>({...group,judges:[...group.judges.values()].sort((a,b)=>a.role.localeCompare(b.role,'zh-CN',{numeric:true}))}));
});
const detailColumns=computed(()=>{const map=new Map();for(const sheet of detailRow.value?.judgeScores||[])for(const item of sheet.scoreItems||[]){const key=`${item.itemCode}:${item.subjectCode||''}`;if(!map.has(key))map.set(key,{key,label:item.subjectCode?`${item.itemName}-${item.subjectCode}`:item.itemName,sort:item.sort})}return [...map.values()].sort((a,b)=>a.sort-b.sort)});
const detailRows=computed(()=>(detailRow.value?.judgeScores||[]).map(sheet=>({...sheet,itemValues:Object.fromEntries((sheet.scoreItems||[]).map(item=>[`${item.itemCode}:${item.subjectCode||''}`,item.inputValue??item.countValue??item.subtotal]))})));
// const correctionGroups=computed(()=>{const groups=new Map();for(const sheet of correctionRow.value?.judgeScores||[]){const key=sheet.judgeRoleType||sheet.judgeRoleName;if(key&&!groups.has(key))groups.set(key,{judgeRoleType:key,judgeRoleName:sheet.judgeRoleName||key})}return [...groups.values()]});
@ -205,6 +229,7 @@ function resetFilters(){entryKeyword.value='';resultStatus.value=null;loadEntrie
function progress(x){return x.totalEntries?Math.round(x.completedEntries*100/x.totalEntries):0}
function submittedCount(row){return (row.judgeScores||[]).filter(x=>x.sheetStatus>=10&&x.sheetStatus<90).length}
function allSubmitted(row){return !!row.judgeScores?.length&&submittedCount(row)===row.judgeScores.length}
function judgeScoreFor(row,key){return (row.judgeScores||[]).find(judge=>`${judge.judgeRoleType||judge.judgeRoleName||'其他'}:${judge.judgeRole||judge.scoreSheetId}`===key)}
function canCast(row){return !!row.result&&allSubmitted(row)&&row.result.resultStatus>=20&&row.result.resultStatus<90&&!row.result.isDisqualified}
function canCastForKind(row){return canCast(row)&&(castEntityKind.value!=='team'||Boolean(row.teamName))}
function castDisabledReason(row){if(!row.result)return '尚未生成成绩';if(!allSubmitted(row))return '裁判成绩尚未全部提交';if(row.result.isDisqualified)return 'DQ 成绩不可投屏';if(row.result.resultStatus<20)return '成绩尚未确认';if(castEntityKind.value==='team'&&!row.teamName)return '该记录没有团队名称,请切换为个人展示';return '该成绩不可投屏'}
@ -312,20 +337,12 @@ function ensurePresentationShell(popup){
frame.title='赛事投屏内容';
frame.allow='fullscreen';
frame.style.cssText='position:fixed;inset:0;width:100%;height:100%;border:0;background:#020936';
const fullscreenButton=doc.createElement('button');
fullscreenButton.id='yd-event-presentation-fullscreen';
fullscreenButton.textContent='进入全屏';
fullscreenButton.style.cssText='position:fixed;right:18px;top:18px;z-index:9;padding:10px 18px;border:1px solid rgba(95,220,255,.55);border-radius:10px;color:#d9faff;background:rgba(3,31,91,.88);font:600 14px Microsoft YaHei;cursor:pointer;box-shadow:0 8px 24px rgba(0,0,0,.28)';
const syncFullscreenButton=()=>{fullscreenButton.style.display=doc.fullscreenElement?'none':'block'};
fullscreenButton.addEventListener('click',()=>requestPopupFullscreen(popup).then(syncFullscreenButton));
doc.addEventListener('fullscreenchange',syncFullscreenButton);
frame.addEventListener('load',()=>{
loading.remove();
const mode=frame.dataset.presentationMode;
if(mode)frame.contentWindow?.postMessage({type:'yd-event-presentation-mode',mode},window.location.origin);
});
doc.body.append(loading,frame,fullscreenButton);
syncFullscreenButton();
doc.body.append(loading,frame);
return frame;
}
function reusablePresentationWindow(){
@ -477,6 +494,55 @@ onBeforeUnmount(()=>window.clearInterval(pollTimer));
.filter-fields { flex-wrap: wrap; }
.toolbar-actions { width: 100%; padding: 8px 0 0; border-left: 0; border-top: 1px solid #edf1f6; }
}
/* 成绩矩阵:固定基础信息,按裁判类型分组展示每个席位的实时分数。 */
.score-matrix-table {
--el-table-border-color: #cfd8e5;
--el-table-header-bg-color: #edf5ff;
font-variant-numeric: tabular-nums;
}
.score-matrix-table :deep(.el-table__inner-wrapper::before) { background-color: #cfd8e5; }
.score-matrix-table :deep(.el-table__header-wrapper thead tr:first-child th) {
height: 42px;
border-bottom-color: #aebfd3;
color: #173656;
background: linear-gradient(180deg, #dcecff 0%, #cfe3f8 100%);
font-size: 12px;
font-weight: 700;
}
.score-matrix-table :deep(.el-table__header-wrapper thead tr:nth-child(2) th) {
height: 39px;
color: #244968;
background: #f1f6fc;
font-size: 11px;
}
.score-matrix-table :deep(.el-table__header-wrapper .cell) { padding: 0 7px; white-space: nowrap; }
.score-matrix-table :deep(.el-table__body td) { height: 49px; color: #1c2f45; }
.score-matrix-table :deep(.el-table__body .cell) { line-height: 1.3; }
.score-matrix-table :deep(.el-table__fixed),
.score-matrix-table :deep(.el-table__fixed-right) { box-shadow: 8px 0 20px rgba(31, 55, 88, 0.08); }
.score-matrix-table :deep(.el-table__fixed-right) { box-shadow: -8px 0 20px rgba(31, 55, 88, 0.08); }
.score-matrix-table :deep(.el-table__fixed-column--left),
.score-matrix-table :deep(.el-table__fixed-column--right) { background: inherit; }
.athlete-name { display: block; overflow: hidden; color: #152b45; text-overflow: ellipsis; white-space: nowrap; }
.unit-name { display: block; overflow: hidden; color: #425872; text-overflow: ellipsis; white-space: nowrap; }
.judge-score { color: #102f4e; font-size: 14px; font-weight: 700; }
.judge-score.pending { color: #c28724; font-size: 11px; font-weight: 600; }
.rank-number {
display: inline-grid;
place-items: center;
min-width: 30px;
height: 30px;
border-radius: 9px;
color: #1369b9;
background: #e9f4ff;
font-size: 14px;
}
.rank-number.rank-1 { color: #8a5a00; background: linear-gradient(145deg, #fff5bd, #ffd76b); box-shadow: 0 4px 10px rgba(218, 160, 23, 0.16); }
.rank-number.rank-2 { color: #49667d; background: linear-gradient(145deg, #f2f8fc, #cfe2ee); }
.rank-number.rank-3 { color: #8b4c25; background: linear-gradient(145deg, #ffe1c5, #f1b47c); }
.score-matrix-table :deep(.el-scrollbar__bar.is-horizontal) { height: 10px; }
.score-matrix-table :deep(.el-scrollbar__thumb) { background: #8aa8c8; opacity: 0.55; }
</style>
<style scoped lang="scss">