+
+
+
本地样例预览 DEMO DATA
-
- 项目 / 组别{{ projectGroup }}
场次 / 场地{{ sessionVenue }}
+
- {{ displayKind === 'team' ? 'TEAM' : 'PLAYER' }}{{ displayKindLabel }}成绩
{{ singleName }}
{{ singleAffiliation }}
出场号 {{ payload.entryNo }}当前排名 第 {{ payload.rank }} 名
{{ animatedValue('hero', payload.totalScore ?? payload.score) }}分
DQ
- {{ item.en }}
{{ item.field === 'deductionFactor' ? Number(payload[item.field]).toFixed(3) : animatedValue(`metric:${item.key}`, payload[item.field]) }}{{ item.label }}
+
+
+
当前{{ displayKind === 'team' ? '队伍' : '选手' }}CURRENT {{ displayKind === 'team' ? 'TEAM' : 'ATHLETE' }}
+
{{ singleName }}
+
{{ singleAffiliation }}
+
总得分TOTAL SCORE
+
{{ animatedValue('hero', payload.totalScore ?? payload.score) }}分PTS
+
+
出场号ENTRY NO.{{ payload.entryNo }}当前排名CURRENT RANK第 {{ payload.rank }} 名
+
DQ
+
+ {{ item.label }}{{ item.en }}
{{ animatedValue(`metric:${item.key}`, payload[item.field], item.field === 'deductionFactor' ? 3 : 2) }}
- {{ displayKind === 'team' ? 'TEAM RANKING' : 'INDIVIDUAL RANKING' }}
{{ displayKindLabel }}排名
共 {{ rankingTotal }} 个{{ displayKind === 'team' ? '队伍' : '参赛者' }} · 第 {{ rankingPageIndex + 1 }}/{{ rankingPageCount }} 页
- {{ rankNumber(row,index) }}{{ rankNumber(row,index) }}
{{ displayName(row) }}{{ displaySubName(row) }}
{{ animatedValue(rankAnimationKey(row,index), row.totalScore ?? row.score) }}分
+ {{ displayKindLabel }}排名
{{ displayKind === 'team' ? 'TEAM RANKING' : 'INDIVIDUAL RANKING' }}共 {{ rankingTotal }} 个{{ displayKind === 'team' ? '队伍' : '参赛者' }} · 第 {{ rankingPageIndex + 1 }}/{{ rankingPageCount }} 页
+ {{ rankNumber(row,index) }}{{ rankNumber(row,index) }}
{{ displayName(row) }}{{ displaySubName(row) }}
{{ animatedValue(rankAnimationKey(row,index), row.totalScore ?? row.score) }}分PTS
当前组别暂无可展示排名
-
+
- YD
LIVE RESULTS SYSTEM等待后台投屏
请在赛事控制台选择组别,并投出个人、团队成绩或排名
+ YD
等待后台投屏
WAITING FOR CAST请在赛事控制台选择组别,并投出个人、团队成绩或排名
@@ -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)});
diff --git a/src/views/scoring/workbench/index.vue b/src/views/scoring/workbench/index.vue
index 4df3030..38c659a 100644
--- a/src/views/scoring/workbench/index.vue
+++ b/src/views/scoring/workbench/index.vue
@@ -3,7 +3,7 @@
{{ tabTitle }}
{{ eventName }} · {{ tabDescription }}
- ● {{ syncStatusText }}重试同步检录大屏切换赛事
+ ● {{ syncStatusText }}重试同步大屏配色检录大屏切换赛事
@@ -27,6 +27,13 @@
个人团队
显示成绩大屏切换检录大屏投出{{ castEntityLabel }}排名
+
+
大屏配色成绩和排名大屏实时生效,设置保存在本机
+
亮色暗色
+
+
85.5比赛成绩LIVE RESULTS
+
恢复默认
+
{{ selectedProject.projectName }}{{ selectedProject.groupCount }} 个组别
@@ -110,6 +117,13 @@
个人排名团队排名取消确定投屏
+
+ 修改后,已打开的成绩和排名大屏会同步更新。
+ 主题模式亮色主题暗色主题
+
+ 85.5比赛成绩LIVE RESULTS
+ 恢复默认完成
+
取消{{ reasonAction === 'rematch' ? '确认重赛' : '确认作废' }}
@@ -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)});