From be8bafe1e040fe0474f84228242d144d67281268 Mon Sep 17 00:00:00 2001 From: ltx <1006158920@qq.com> Date: Wed, 4 Nov 2026 16:24:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(scoring):=20=E5=AE=8C=E5=96=84=E6=88=90?= =?UTF-8?q?=E7=BB=A9=E5=B7=A5=E4=BD=9C=E5=8F=B0=E4=B8=8E=E6=8E=92=E5=90=8D?= =?UTF-8?q?=E6=8A=95=E5=B1=8F=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 成绩工作台移除顶部标题和当前投屏状态栏,将实时同步、大屏配色与检录入口移到项目栏;展示接口返回的当前赛事名称,移除切换赛事按钮。 - 排名大屏展示前三名领奖台与第 4—8 名列表;使用排名行序号区分共用出场编号的选手,修复姓名和分数动画串位,改善长姓名布局。 - 排名持续展示期间增加领奖台光晕、台阶流光及列表扫光,并遵从系统的减少动态效果设置。 - 裁判分配保存及提交按钮增加加载状态和重复点击保护。 - 增加限定 QA 测试库的赛事 59 成绩补齐脚本,仅插入缺失成绩及对应版本,保留已有裁判评分单。 验证:npm run build:local 通过;git diff --check 无异常。 --- scripts/dev/seed-event-59-results.sql | 100 ++++++++++++++ .../match/manage/JudgeAssignmentPanel.vue | 29 +++- src/views/scoring/screen/index.vue | 128 +++++++++++++++--- src/views/scoring/workbench/index.vue | 105 +++++--------- 4 files changed, 262 insertions(+), 100 deletions(-) create mode 100644 scripts/dev/seed-event-59-results.sql diff --git a/scripts/dev/seed-event-59-results.sql b/scripts/dev/seed-event-59-results.sql new file mode 100644 index 0000000..132c54c --- /dev/null +++ b/scripts/dev/seed-event-59-results.sql @@ -0,0 +1,100 @@ +-- 仅为测试库中的赛事“59”补齐正式成绩,用于工作台和排名大屏调试。 +-- 只插入尚无有效成绩的出场记录;不修改裁判评分单及已有成绩。 +SET XACT_ABORT ON; +SET NOCOUNT ON; + +BEGIN TRY + BEGIN TRANSACTION; + + DECLARE @EventId bigint = 491967903178757; + IF DB_NAME() <> N'EventServerDB_QA' + THROW 51000, N'仅允许在 EventServerDB_QA 执行', 1; + IF NOT EXISTS (SELECT 1 FROM dbo.EventInfo WHERE Id = @EventId AND EventName = N'59' AND DeleteMark = 0) + THROW 51001, N'目标赛事不存在或名称不符', 1; + + DECLARE @EntryCount int = ( + SELECT COUNT(*) FROM dbo.EventSessionEntry e + JOIN dbo.EventSession s ON s.Id = e.SessionId + WHERE s.EventId = @EventId AND s.DeleteMark = 0 AND e.DeleteMark = 0 + ); + IF @EntryCount <> 10 + THROW 51002, N'赛事出场人数已变化,请先复核脚本', 1; + + -- 与应用相同的 2023-01-01 UTC 基准和 12 位尾段;预留调试专用 worker 63。 + DECLARE @IdBase bigint = DATEDIFF_BIG(millisecond, '2023-01-01T00:00:00', SYSUTCDATETIME()) * 4096 + 63 * 64; + + ;WITH OrderedEntries AS ( + SELECT e.Id AS EntryId, e.SessionId, s.EventProjectId, s.ScoreRuleId, + e.ParticipantName, e.GroupName, e.ParticipationRecordId, + ROW_NUMBER() OVER (PARTITION BY s.EventProjectId, e.GroupName ORDER BY s.CreatedTime, s.Id, e.PlayOrder, e.Id) AS GroupRank, + s.CreatedTime AS SessionCreatedTime, s.Id AS SessionOrderId, e.PlayOrder + FROM dbo.EventSessionEntry e + JOIN dbo.EventSession s ON s.Id = e.SessionId AND s.EventId = @EventId AND s.DeleteMark = 0 + WHERE e.DeleteMark = 0 AND e.IsVoided = 0 + ), Missing AS ( + SELECT o.*, ROW_NUMBER() OVER (ORDER BY o.SessionCreatedTime, o.SessionOrderId, o.PlayOrder, o.EntryId) AS SeedNo + FROM OrderedEntries o + WHERE NOT EXISTS (SELECT 1 FROM dbo.EventResult r WHERE r.SessionEntryId = o.EntryId AND r.DeleteMark = 0) + ), Factors AS ( + SELECT m.*, CAST(9.75 - (m.GroupRank - 1) * 0.60 AS decimal(18,4)) AS D, + CAST(0.64 - (m.GroupRank - 1) * 0.02 AS decimal(18,4)) AS P, + CAST(0.97 - (m.GroupRank - 1) * 0.01 AS decimal(18,4)) AS Q, + CAST(0.08 + (m.GroupRank - 1) * 0.02 AS decimal(18,4)) AS Deduction + FROM Missing m + ) + SELECT f.*, ep.ProjectId, sr.RuleVersion, + CAST(ROUND(f.D * (1 + f.P) * f.Q * (1 - f.Deduction), 2) AS decimal(18,2)) AS FinalScore, + @IdBase + f.SeedNo AS ResultId, + @IdBase + 32 + f.SeedNo AS VersionId + INTO #Seed FROM Factors f + JOIN dbo.EventProject ep ON ep.Id = f.EventProjectId + JOIN dbo.ScoreRule sr ON sr.Id = f.ScoreRuleId; + + IF (SELECT COUNT(*) FROM #Seed) > 10 + THROW 51003, N'待补成绩数量异常', 1; + IF EXISTS (SELECT 1 FROM #Seed WHERE D <= 0 OR P < 0 OR Q <= 0 OR Deduction >= 1) + THROW 51004, N'模拟成绩因子超出允许范围', 1; + IF EXISTS (SELECT 1 FROM dbo.EventResult WHERE Id IN (SELECT ResultId FROM #Seed)) + OR EXISTS (SELECT 1 FROM dbo.EventResultVersion WHERE Id IN (SELECT VersionId FROM #Seed)) + THROW 51005, N'生成的主键发生冲突,请重试', 1; + + INSERT dbo.EventResult ( + Id, ParticipationRecordId, RegistrationId, RegistrationPersonId, EventId, ProjectId, + ParticipantName, GroupName, Score, Rank, AwardLevel, AwardStatus, CreatedTime, + ProjectTypeName, DeleteMark, CreatedUserId, Status, SessionId, SessionEntryId, + DifficultyScore, PerformanceScore, RequiredScore, DeductionScore, TotalScore, + IsDisqualified, ResultStatus, ResultVersion, CalculationJson + ) + SELECT ResultId, COALESCE(ParticipationRecordId, 0), 0, 0, @EventId, ProjectId, + ParticipantName, GroupName, CONVERT(varchar(32), FinalScore), GroupRank, 0, 0, GETDATE(), + N'个人花样', 0, 0, 0, SessionId, EntryId, + D, P, Q, Deduction, FinalScore, 0, 20, 1, + CONCAT('{"ruleType":"FREESTYLE_SINGLE","totalMode":"factor","formula":"D × (1 + P) × Q × M","scoreDecimals":2,"debugSeed":true,"totalScore":', + CONVERT(varchar(32), FinalScore), ',"factors":{"d":', CONVERT(varchar(32), D), + ',"p":', CONVERT(varchar(32), P), ',"q":', CONVERT(varchar(32), Q), + ',"m":', CONVERT(varchar(32), CAST(1 - Deduction AS decimal(18,4))), '}}') + FROM #Seed; + + INSERT dbo.EventResultVersion ( + Id, EventResultId, EventId, SessionEntryId, ResultVersion, + DifficultyScore, PerformanceScore, RequiredScore, DeductionScore, TotalScore, Rank, + IsDisqualified, ResultStatus, SourceType, ScoreRuleId, RuleVersion, CalculationJson, + Remark, DeleteMark, CreatedUserId, CreatedTime + ) + SELECT VersionId, ResultId, @EventId, EntryId, 1, + D, P, Q, Deduction, FinalScore, GroupRank, + 0, 20, 0, ScoreRuleId, RuleVersion, + CONCAT('{"ruleType":"FREESTYLE_SINGLE","totalMode":"factor","formula":"D × (1 + P) × Q × M","scoreDecimals":2,"debugSeed":true,"totalScore":', + CONVERT(varchar(32), FinalScore), ',"factors":{"d":', CONVERT(varchar(32), D), + ',"p":', CONVERT(varchar(32), P), ',"q":', CONVERT(varchar(32), Q), + ',"m":', CONVERT(varchar(32), CAST(1 - Deduction AS decimal(18,4))), '}}'), + N'仅供调试的模拟成绩', 0, 0, SYSDATETIME() + FROM #Seed; + + SELECT COUNT(*) AS InsertedResults FROM #Seed; + COMMIT TRANSACTION; +END TRY +BEGIN CATCH + IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; + THROW; +END CATCH; diff --git a/src/views/match/manage/JudgeAssignmentPanel.vue b/src/views/match/manage/JudgeAssignmentPanel.vue index b37fc74..3b60754 100644 --- a/src/views/match/manage/JudgeAssignmentPanel.vue +++ b/src/views/match/manage/JudgeAssignmentPanel.vue @@ -24,7 +24,7 @@ -
刷新{{ board.submitted ? '已提交' : '提交' }}
+
刷新{{ board.submitted ? '已提交' : '提交' }}
@@ -58,7 +58,7 @@ - + @@ -76,6 +76,8 @@ import { judgeAssignmentBoardGet, judgeAssignmentSavePost, judgeAssignmentSubmit const props = defineProps({ eventId: { type: [String, Number], required: true }, active: Boolean }); const mode = ref('eventReferees'); const loading = ref(false); +const saveLoading = ref(false); +const submitLoading = ref(false); const eventReferees = ref([]); const board = reactive({ arrangementConfirmed: false, submitted: false, projects: [] }); const assignVisible = ref(false); @@ -143,15 +145,30 @@ function chooseCandidate(item) { } function removeReferee(code, id) { const list = assignmentMap[code]; const index = list?.indexOf(id) ?? -1; if (index >= 0) list.splice(index, 1); } async function saveAssignments() { + if (saveLoading.value) return; const incomplete = allSlots.value.find(slot => picked(slot.code).length < minOf(slot)); if (incomplete) return ElMessage.warning(`${incomplete.name}至少需要分配 ${minOf(incomplete)} 名裁判`); const assignments = allSlots.value.flatMap(slot => picked(slot.code).map(id => ({ judgeRole: slot.code, eventRefereeId: id, refereeId: (candidates.value.find(item => item.eventRefereeId === id) || currentSession.value.assignments?.find(item => item.eventRefereeId === id))?.refereeId || 0 }))); - const result = await judgeAssignmentSavePost({ eventId: Number(props.eventId), sessionId: currentSession.value.sessionId, assignments }); - if (!result?.succeeded) return; - ElMessage.success('分配已保存'); assignVisible.value = false; load(); + saveLoading.value = true; + try { + const result = await judgeAssignmentSavePost({ eventId: Number(props.eventId), sessionId: currentSession.value.sessionId, assignments }); + if (!result?.succeeded) return; + ElMessage.success('分配已保存'); assignVisible.value = false; await load(); + } finally { saveLoading.value = false; } +} +async function submitAll() { + if (submitLoading.value) return; + submitLoading.value = true; + try { + await ElMessageBox.confirm('提交后分配任务即可同步到裁判端,是否继续?', '提交确认', { type: 'warning' }); + const result = await judgeAssignmentSubmitPost({ eventId: Number(props.eventId) }); + if (!result?.succeeded) return; + ElMessage.success('提交成功'); await load(); + } catch (error) { + if (error !== 'cancel' && error !== 'close') throw error; + } finally { submitLoading.value = false; } } -async function submitAll() { await ElMessageBox.confirm('提交后分配任务即可同步到裁判端,是否继续?', '提交确认', { type: 'warning' }); const result = await judgeAssignmentSubmitPost({ eventId: Number(props.eventId) }); if (!result?.succeeded) return; ElMessage.success('提交成功'); load(); } async function generateCode() { codeLoading.value = true; try { diff --git a/src/views/scoring/screen/index.vue b/src/views/scoring/screen/index.vue index 87c1da6..83cf3db 100644 --- a/src/views/scoring/screen/index.vue +++ b/src/views/scoring/screen/index.vue @@ -23,8 +23,16 @@
{{ item.label }}{{ item.en }}
{{ animatedValue(`metric:${item.key}`, metricDisplayValue(item), item.field === 'deductionFactor' ? 3 : 2) }}
-

{{ displayKindLabel }}排名

{{ displayKind === 'team' ? 'TEAM RANKING' : 'INDIVIDUAL RANKING' }}
共 {{ rankingTotal }} 个{{ displayKind === 'team' ? '队伍' : '参赛者' }}
-
{{ rankNumber(row,index) }}
{{ displayName(row) }}{{ displaySubName(row) }}
{{ animatedValue(rankAnimationKey(row,index), row.totalScore ?? row.score) }}分PTS
+

{{ displayKindLabel }}排名

{{ displayKind === 'team' ? 'TEAM RANKING' : 'INDIVIDUAL RANKING' }}
展示前 {{ rankingTopEight.length }} 名 · 共 {{ rankingTotal }} 个{{ displayKind === 'team' ? '队伍' : '参赛者' }}TOP {{ String(rankingTopEight.length).padStart(2,'0') }}
+
+
+
+
{{ entry.place === 1 ? '冠军' : entry.place === 2 ? '亚军' : '季军' }}{{ entry.place === 1 ? 'CHAMPION' : entry.place === 2 ? 'RUNNER-UP' : 'THIRD PLACE' }}{{ displayName(entry.row) }}{{ displaySubName(entry.row) || ' ' }}
{{ animatedValue(rankAnimationKey(entry.row,entry.index), entry.row.totalScore ?? entry.row.score) }}分PTS
+
0{{ entry.place }}
+
+
+
第 4—{{ rankingTopEight.length }} 名RANK 04—{{ String(rankingTopEight.length).padStart(2,'0') }}
{{ rankNumber(row,index+3) }}
{{ displayName(row) }}{{ displaySubName(row) }}
{{ animatedValue(rankAnimationKey(row,index+3), row.totalScore ?? row.score) }}分 PTS
+
当前组别暂无可展示排名
@@ -49,10 +57,10 @@ let savedContext={};try{savedContext=JSON.parse(sessionStorage.getItem('scoring- 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({}); +const cast=ref(null),offline=ref(false),viewport=ref({width:window.innerWidth,height:window.innerHeight}),animatedNumbers=ref({}); const scoreTheme=ref(readScoreScreenTheme(eventId)); const screenThemeStyle=computed(()=>({'--screen-theme':scoreTheme.value.themeColor,'--screen-text':scoreTheme.value.textColor})); -let pollTimer,rankTimer,castChannel,themeChannel,numberAnimationFrame; +let pollTimer,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} @@ -61,23 +69,24 @@ function readCachedCast(){try{const scoped=localStorage.getItem(cacheKey()),fall function castIdentity(value){const id=value?.screenCastId||value?.castTime;return id==null?null:String(id)} function acceptPublishedCast(value){ const next=normalizeCast(value); - if(!next){cast.value=null;rankPage.value=0;return} + if(!next){cast.value=null;return} offline.value=false; - // 同一次投屏的轮询回包不替换画面,也不重播成绩动画或重置排名页码。 + // 同一次投屏的轮询回包不替换画面,也不重播成绩动画。 const sameCast=cast.value&&castIdentity(cast.value)&&castIdentity(cast.value)===castIdentity(next)&&cast.value.castType===next.castType; if(sameCast)return; cast.value=next; - rankPage.value=0; } 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}, + {rank:2,participantName:'林嘉怡',teamName:'星跃队',entryNo:'A-017',totalScore:82.4}, + {rank:3,participantName:'周子涵',teamName:'青云队',entryNo:'A-017',totalScore:79.8}, + {rank:4,participantName:'许晨',teamName:'飞跃队',entryNo:'A-017',totalScore:76.2}, + {rank:5,participantName:'王心悦',teamName:'跃动队',entryNo:'A-017',totalScore:73.6}, + {rank:6,participantName:'李奕辰',teamName:'星跃队',entryNo:'A-017',totalScore:71.9}, + {rank:7,participantName:'宋佳宁',teamName:'青云队',entryNo:'A-017',totalScore:69.4}, + {rank:8,participantName:'赵嘉禾',teamName:'飞跃队',entryNo:'A-017',totalScore:67.8}, ])}; 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})}; } @@ -109,13 +118,17 @@ function metricDisplayValue(item){ const value=Number(raw); return Number.isFinite(value)?value+1:raw; } -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)); +const rankingAll=computed(()=>Array.isArray(payload.value)?payload.value:[]); +const rankingTotal=computed(()=>rankingAll.value.length); +const rankingTopEight=computed(()=>rankingAll.value.slice(0,8)); +const podiumDisplay=computed(()=>[1,0,2].filter(index=>rankingTopEight.value[index]).map(index=>({row:rankingTopEight.value[index],index,place:index+1}))); +const otherRankingRows=computed(()=>rankingTopEight.value.slice(3)); function displayName(row){return displayKind.value==='team'?(row.teamName||row.participantName||'未命名队伍'):(row.participantName||row.teamName||'未命名选手')} 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 rankNumber(row,index){const rank=Number(row.rank);return Number.isFinite(rank)&&rank>0?rank:index+1} 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 rankAnimationKey(_row,index){return `rank:${index}`} 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); @@ -124,14 +137,14 @@ function animateScoreNumbers(){ targets.hero=payload.value.totalScore??payload.value.score; for(const item of scoreCards.value)targets[`metric:${item.key}`]=metricDisplayValue(item); }else{ - rankingRows.value.forEach((row,index)=>{targets[rankAnimationKey(row,index)]=row.totalScore??row.score}); + rankingTopEight.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); + const progress=Math.max(0,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); }; @@ -149,9 +162,9 @@ 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);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(()=>{if(document.visibilityState==='visible'&&viewType.value==='ranking'&&rankingPageCount.value>1)rankPage.value=(rankPage.value+1)%rankingPageCount.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)}); +watch(castKey,()=>nextTick(animateScoreNumbers),{immediate:true,flush:'post'}); +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)}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.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 ba0148b..f47744a 100644 --- a/src/views/scoring/workbench/index.vue +++ b/src/views/scoring/workbench/index.vue @@ -1,14 +1,10 @@
+
当前赛事{{ eventName }}
查询
- +
@@ -144,7 +134,7 @@ import { useTagsViewRoutes } from '/@/stores/tagsViewRoutes'; import { defaultScoreScreenTheme, normalizeScoreScreenTheme, readScoreScreenTheme, saveScoreScreenTheme, scoreScreenModeTextColors, scoreScreenThemeChannel, scoreScreenThemeKey } from '../score-screen-theme.mjs'; import { workbenchSessionsGet, workbenchProjectEntriesGet, workbenchEntryDetailGet, workbenchScreenCastPost, - workbenchScreenCastCurrentGet, workbenchScreenCastWithdrawPost, workbenchCorrectionsGet, + workbenchCorrectionsGet, workbenchCorrectionDetailGet, workbenchCorrectionApprovePost, workbenchResultConfirmPost, workbenchEntryRematchPost, workbenchCorrectionInitiatePost, workbenchResultsExportGet, workbenchScoreSheetsExportGet, @@ -154,25 +144,25 @@ const route=useRoute(), router=useRouter(); const storesTagsViewRoutes = useTagsViewRoutes(); let savedContext={};try{savedContext=JSON.parse(sessionStorage.getItem('scoring-context')||'{}')}catch{savedContext={}} const eventId=ref(Number(route.query.eventId)||Number(savedContext.eventId)||0); -const eventName=ref(String(route.query.eventName||savedContext.eventName||'未选择赛事')); +const eventName=ref(String(route.query.eventName||(Number(savedContext.eventId)===eventId.value?savedContext.eventName:'')||`赛事 ${eventId.value}`)); if(eventId.value)sessionStorage.setItem('scoring-context',JSON.stringify({...savedContext,eventId:eventId.value,eventName:eventName.value,mode:'freestyle'})); -const activeTab=ref('screen'), sessions=ref([]), selectedProjectId=ref(''), selectedSession=ref(null), entries=ref([]), currentCast=ref(null); -const loadingSessions=ref(false), loadingEntries=ref(false), entriesSyncError=ref(false), castSyncError=ref(false), retryLoading=ref(false), entryKeyword=ref(''), resultStatus=ref(null); +const activeTab=ref('screen'), sessions=ref([]), selectedProjectId=ref(''), selectedSession=ref(null), entries=ref([]); +const loadingSessions=ref(false), loadingEntries=ref(false), entriesSyncError=ref(false), retryLoading=ref(false), entryKeyword=ref(''), resultStatus=ref(null); const page=ref(1),jumpPage=ref(1),pageSize=ref(20),entryTotal=ref(0),pageRanges=ref([]); const pageCount=computed(()=>Math.max(1,Math.ceil(entryTotal.value/pageSize.value))); const visiblePageRanges=computed(()=>{ const start=Math.max(1,Math.min(page.value-2,pageCount.value-4)); return pageRanges.value.slice(start-1,start+4); }); -const offline=computed(()=>entriesSyncError.value||castSyncError.value); -const syncStatusText=computed(()=>entriesSyncError.value&&castSyncError.value?'成绩与投屏状态同步异常':entriesSyncError.value?'成绩同步异常':castSyncError.value?'投屏状态同步异常':'实时同步'); +const offline=computed(()=>entriesSyncError.value); +const syncStatusText=computed(()=>entriesSyncError.value?'成绩同步异常':'实时同步'); const detailVisible=ref(false), detailRow=ref(null), detailLoadingId=ref(null), judgeDetailVisible=ref(false), selectedJudgeSheet=ref(null), annotationVisible=ref(false), annotationUrl=ref(''), rankingVisible=ref(false), themeSettingsVisible=ref(false), reasonVisible=ref(false), 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; -let entriesRequestId=0, castRequestId=0; +let pollTimer, entriesController=null; +let entriesRequestId=0; let mountedReady=false, componentActive=false, destroyed=false; -let entriesFailureCount=0, castFailureCount=0, entriesNextRetryAt=0, castNextRetryAt=0; +let entriesFailureCount=0, entriesNextRetryAt=0; const retryDelays=[10000,20000,40000,60000]; let scoreScreenWindow=null; let themeChannel=null; @@ -297,17 +287,6 @@ const correctionGroups=computed(()=>{const map=new Map();for(const s of correcti const correctionSheetOptions=computed(()=>(correctionRow.value?.judgeScores||[]).filter(s=>s.scoreSheetId>0&&s.sheetStatus>=10&&s.judgeRoleType===correctionForm.value.judgeRoleType)); function isCorrectionSheetAvailable(x){return Boolean(x&&x.sheetStatus>=10&&x.sheetStatus<90&&!x.hasPendingCorrection)} function onCorrectionRoleChange(){correctionForm.value.scoreSheetId=null} -const tabTitle=computed(()=>({screen:'成绩投屏',results:'成绩管理',corrections:'成绩修正申请'})[activeTab.value]); -const tabDescription=computed(()=>({screen:'选择已完成成绩投到现场大屏',results:'复核成绩、查看评分单并处理异常比赛',corrections:'审批裁判发起的修正申请并追踪处理进度'})[activeTab.value]); -const currentCastPayload=computed(()=>{try{const value=JSON.parse(currentCast.value?.payloadJson||'{}');return Array.isArray(value)?value.map(normalizeCastValue):normalizeCastValue(value)}catch{return {}}}); -const currentCastSummary=computed(()=>{ - if(!currentCast.value)return '—'; - if(currentCast.value.castType===2)return `${currentCast.value.projectName||'当前项目'} / ${currentCast.value.groupName||'当前组别'}排名`; - const data=currentCastPayload.value; - const name=data.participantName||data.teamName||'未命名选手'; - return `${name} · ${score(data.totalScore??data.score)}分`; -}); - function normalizeCastValue(value){if(!value||typeof value!=='object')return value;return Object.fromEntries(Object.entries(value).map(([key,item])=>[key.charAt(0).toLowerCase()+key.slice(1),item]))} function rememberCastKind(){localStorage.setItem(`yd-event-cast-kind:${eventId.value}`,castEntityKind.value)} 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} @@ -322,12 +301,6 @@ function publishScreenCast(value,venueId){ }catch{} return snapshot; } -function clearPublishedScreenCast(venueId){ - try{ - localStorage.removeItem(screenCastCacheKey(venueId));localStorage.removeItem(screenCastCacheKey(null)); - const channel=new BroadcastChannel(scoreScreenChannelName.value);channel.postMessage(null);channel.close(); - }catch{} -} function buildSingleCastSnapshot(response,row,rowSession){ const result=normalizeCastValue(unwrapApiData(response)||{}),session=rowSession||row?._session||selectedSession.value||{}; return {...result,castType:1,displayKind:castEntityKind.value,eventName:eventName.value,projectName:session.projectName||session.eventProjectName||'',groupName:session.groupName||session.eventProjectGroupName||row.groupName||'',venueName:session.venueName||session.venueNo||'',sessionNo:session.sessionNo||'',scoreRuleCode:session.scoreRuleCode||'',payloadJson:result.payloadJson||JSON.stringify({...row,...row.result})}; @@ -351,6 +324,8 @@ async function loadSessions(){ const res=await workbenchSessionsGet({eventId:eventId.value}); const sessionRows=validResponse(res); if(!Array.isArray(sessionRows))throw new Error('比赛场次数据格式错误'); + // 以接口返回的赛事名称为准,避免 URL 只带 eventId 时沿用上一个赛事的名称。 + if(sessionRows[0]?.eventName){eventName.value=sessionRows[0].eventName;sessionStorage.setItem('scoring-context',JSON.stringify({...savedContext,eventId:eventId.value,eventName:eventName.value,mode:'freestyle'}))} // 刷新全部场次时保留当前场次;场次消失才回退到列表首项。 const previousSessionId=selectedSession.value?.sessionId,previousProjectId=selectedProjectId.value; sessions.value=sessionRows; @@ -385,7 +360,6 @@ function jumpToPage(){const target=Number(jumpPage.value);if(!Number.isInteger(t function changePageSize(){page.value=1;jumpPage.value=1;void loadEntries()} function retryDelay(failureCount){return retryDelays[Math.min(failureCount-1,retryDelays.length-1)]} function cancelEntriesRequest(){++entriesRequestId;entriesController?.abort();entriesController=null;loadingEntries.value=false} -function cancelCastRequest(){++castRequestId;castController?.abort();castController=null} function validResponse(res){if(res?.statusCode!==200)throw new Error('同步接口返回失败');return res.data} async function loadEntries(silent=false,{background=false}={}){ const projectSessions=[...selectedProjectSessions.value],eventProjectId=selectedSession.value?.eventProjectId; @@ -418,27 +392,10 @@ async function loadEntries(silent=false,{background=false}={}){ return false; }finally{if(requestId===entriesRequestId){entriesController=null;loadingEntries.value=false}} } -async function loadCurrentCast({background=false}={}){ - if(!eventId.value)return false; - if(background&&castController)return false; - if(!background)cancelCastRequest(); - const requestId=++castRequestId,controller=new AbortController(); - castController=controller; - try{ - const res=await workbenchScreenCastCurrentGet({eventId:eventId.value,venueId:selectedSession.value?.venueId},{signal:controller.signal,silentError:background,timeout:background?15000:50000}); - const cast=validResponse(res); - if(requestId!==castRequestId||controller.signal.aborted)return false; - currentCast.value=cast||null;castSyncError.value=false;castFailureCount=0;castNextRetryAt=0; - return true; - }catch(error){ - if(requestId===castRequestId&&!destroyed){castSyncError.value=true;castFailureCount++;castNextRetryAt=Date.now()+retryDelay(castFailureCount)} - return false; - }finally{if(requestId===castRequestId)castController=null} -} async function retrySync(){ if(retryLoading.value)return; retryLoading.value=true; - try{await Promise.all([entriesSyncError.value?(sessions.value.length?loadEntries(true):loadSessions()):Promise.resolve(),castSyncError.value?loadCurrentCast():Promise.resolve()])} + try{if(entriesSyncError.value)await (sessions.value.length?loadEntries(true):loadSessions())} finally{retryLoading.value=false} } async function loadCorrections(){if(!eventId.value)return;loadingCorrections.value=true;try{const res=await workbenchCorrectionsGet({eventId:eventId.value,keyword:correctionKeyword.value||undefined,correctionStatus:correctionStatus.value??undefined});corrections.value=res?.data||[]}finally{loadingCorrections.value=false}} @@ -539,11 +496,10 @@ async function showDetail(row){ } function handleRowCommand(command,row){if(command==='correction')openCorrection(row);else if(command==='rematch')openReason(row);else if(command==='export')exportSheets(row)} -async function castScore(row){if(actionLoading.value)return;const session=row._session||selectedSession.value;if(!session)return ElMessage.warning('未找到成绩所属场次');const venueId=session.venueId;rememberCastKind();const presentation=prepareScoreScreen(venueId);rowActionId.value=row.sessionEntryId;actionLoading.value=true;try{const response=ensureCastSucceeded(await workbenchScreenCastPost({eventId:eventId.value,castType:1,venueId,sessionId:session.sessionId,sessionEntryId:row.sessionEntryId,eventResultId:row.result.resultId,resultVersion:row.result.resultVersion}));publishScreenCast(buildSingleCastSnapshot(response,row,session),venueId);showScoreScreen(presentation,venueId,castEntityKind.value);if(row.result){row.result.resultStatus=30;row.result.resultStatusName='已投屏'}ElMessage.success(`${castEntityLabel.value}成绩已投出`);await loadCurrentCast()}catch(error){closeUnusedScreen(presentation);throw error}finally{actionLoading.value=false;rowActionId.value=null}} +async function castScore(row){if(actionLoading.value)return;const session=row._session||selectedSession.value;if(!session)return ElMessage.warning('未找到成绩所属场次');const venueId=session.venueId;rememberCastKind();const presentation=prepareScoreScreen(venueId);rowActionId.value=row.sessionEntryId;actionLoading.value=true;try{const response=ensureCastSucceeded(await workbenchScreenCastPost({eventId:eventId.value,castType:1,venueId,sessionId:session.sessionId,sessionEntryId:row.sessionEntryId,eventResultId:row.result.resultId,resultVersion:row.result.resultVersion}));publishScreenCast(buildSingleCastSnapshot(response,row,session),venueId);showScoreScreen(presentation,venueId,castEntityKind.value);if(row.result){row.result.resultStatus=30;row.result.resultStatusName='已投屏'}ElMessage.success(`${castEntityLabel.value}成绩已投出`)}catch(error){closeUnusedScreen(presentation);throw error}finally{actionLoading.value=false;rowActionId.value=null}} async function castDetailScore(){const row=detailRow.value;if(!row)return;await castScore(row);detailVisible.value=false} function openRanking(){rankingKey.value=selectedSession.value?.eventProjectGroupId?`${selectedSession.value.eventProjectId}:${selectedSession.value.eventProjectGroupId}`:'';rankingVisible.value=true} -async function castRanking(){const target=rankingSelection.value;if(!target)return ElMessage.warning('请选择项目和组别');if(actionLoading.value)return;const venueId=target.venueId??selectedSession.value?.venueId;rememberCastKind();const presentation=prepareScoreScreen(venueId);actionLoading.value=true;try{const response=ensureCastSucceeded(await workbenchScreenCastPost({eventId:eventId.value,castType:2,venueId,eventProjectId:target.eventProjectId,eventProjectGroupId:target.eventProjectGroupId}));publishScreenCast(buildRankingCastSnapshot(response,target),venueId);showScoreScreen(presentation,venueId,castEntityKind.value);rankingVisible.value=false;ElMessage.success(`${castEntityLabel.value}排名已投出`);await Promise.all([loadEntries(true),loadCurrentCast()])}catch(error){closeUnusedScreen(presentation);throw error}finally{actionLoading.value=false}} -async function withdrawCast(){if(actionLoading.value)return;await ElMessageBox.confirm('撤回后大屏将回到等待状态,是否继续?','撤回投屏',{type:'warning'});actionLoading.value=true;try{await workbenchScreenCastWithdrawPost(currentCast.value.screenCastId);clearPublishedScreenCast(selectedSession.value?.venueId);currentCast.value=null;ElMessage.success('已撤回');await Promise.all([loadEntries(true),loadCurrentCast()])}finally{actionLoading.value=false}} +async function castRanking(){const target=rankingSelection.value;if(!target)return ElMessage.warning('请选择项目和组别');if(actionLoading.value)return;const venueId=target.venueId??selectedSession.value?.venueId;rememberCastKind();const presentation=prepareScoreScreen(venueId);actionLoading.value=true;try{const response=ensureCastSucceeded(await workbenchScreenCastPost({eventId:eventId.value,castType:2,venueId,eventProjectId:target.eventProjectId,eventProjectGroupId:target.eventProjectGroupId}));publishScreenCast(buildRankingCastSnapshot(response,target),venueId);showScoreScreen(presentation,venueId,castEntityKind.value);rankingVisible.value=false;ElMessage.success(`${castEntityLabel.value}排名已投出`);await loadEntries(true)}catch(error){closeUnusedScreen(presentation);throw error}finally{actionLoading.value=false}} async function confirmOne(row){if(actionLoading.value)return;rowActionId.value=row.sessionEntryId;actionLoading.value=true;try{await workbenchResultConfirmPost(row.result.resultId);ElMessage.success('成绩已确认');await loadEntries()}finally{actionLoading.value=false;rowActionId.value=null}} function openReason(row){reasonRow.value=row;reasonText.value='';reasonVisible.value=true} async function submitReason(){const text=reasonText.value.trim();if(text.length<2)return ElMessage.warning('请填写至少 2 个字符的原因');actionLoading.value=true;try{await workbenchEntryRematchPost({originalSessionEntryId:reasonRow.value.sessionEntryId,rematchReason:text});reasonVisible.value=false;ElMessage.success('重赛已安排到场次末尾');await loadEntries()}finally{actionLoading.value=false}} @@ -661,17 +617,15 @@ function showScoreScreen(presentation,venueId,displayKind=castEntityKind.value){ function closeUnusedScreen(presentation){if(presentation?.created&&presentation.popup&&!presentation.popup.closed)presentation.popup.close()} function openScoreScreen(){rememberCastKind();const presentation=prepareScoreScreen(selectedSession.value?.venueId);if(presentation)showScoreScreen(presentation,selectedSession.value?.venueId,castEntityKind.value)} function openCheckinScreen(){const venueId=selectedSession.value?.venueId;const eventProjectId=selectedSession.value?.eventProjectId;const presentation=prepareScoreScreen();if(presentation)showPresentationScreen(presentation,scoreScreenHref(venueId,castEntityKind.value,'checkin',eventProjectId))} -function goLauncher(){router.push({path:'/scoring/launcher'})} function toggleCorrections(){activeTab.value=activeTab.value==='corrections'?'results':'corrections';if(activeTab.value==='corrections'){cancelEntriesRequest();void loadCorrections()}else pollNow(true)} function pollNow(force=false){ if(!componentActive||document.visibilityState!=='visible')return; const now=Date.now(); - if((force||now>=castNextRetryAt)&&!castController)void loadCurrentCast({background:true}); if(activeTab.value!=='corrections'&&selectedProjectSessions.value.length&&(force||now>=entriesNextRetryAt)&&!entriesController)void loadEntries(true,{background:true}); } function stopPolling(){ if(pollTimer){window.clearInterval(pollTimer);pollTimer=null} - cancelEntriesRequest();cancelCastRequest(); + cancelEntriesRequest(); } function startPolling(refreshNow=false){ if(pollTimer||!componentActive||document.visibilityState!=='visible')return; @@ -682,7 +636,6 @@ function onVisibilityChange(){ if(document.visibilityState==='visible')startPolling(true); else stopPolling(); } -watch(()=>selectedSession.value?.venueId,()=>{if(componentActive&&document.visibilityState==='visible')void loadCurrentCast()}); watch(castEntityKind,rememberCastKind); watch(()=>route.path,(path)=>{activeTab.value=path==='/scoring/cast'?'screen':'results'}); onMounted(async ()=>{ @@ -695,7 +648,6 @@ onMounted(async ()=>{ await loadSessions(); mountedReady=true; if(!componentActive||document.visibilityState!=='visible')return; - void loadCurrentCast(); startPolling(); }); onActivated(()=>{componentActive=true;scoreTheme.value=readScoreScreenTheme(eventId.value);if(mountedReady)startPolling(true)}); @@ -857,7 +809,16 @@ onBeforeUnmount(()=>{destroyed=true;componentActive=false;stopPolling();themeCha .session-panel { width: 292px; padding: 18px 16px; } .session-head { margin-bottom: 14px; } .session-head b { color: var(--wb-text); font-size: 15px; } -.session-list { margin-top: 14px; padding-right: 3px; } +.session-list { flex: 1; margin-top: 14px; padding-right: 3px; } +.session-utilities { flex: none; display: grid; gap: 10px; padding-top: 14px; border-top: 1px solid #e8eef5; } +.sync-line { display: flex; align-items: center; justify-content: space-between; min-height: 24px; } +.sync-line .live-dot { font-weight: 600; } +.utility-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.utility-buttons :deep(.el-button) { width: 100%; margin: 0; border-radius: 8px; } +.current-event { display: grid; gap: 5px; min-width: 0; margin-bottom: 16px; padding: 12px 13px; border: 1px solid #dbe9fa; border-radius: 10px; background: linear-gradient(120deg, #f1f7ff, #f8fbff); } +.current-event span { color: #607c9d; font-size: 11px; } +.current-event strong { overflow: hidden; color: #173b66; font-size: 15px; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; } +.correction-event { margin-bottom: 14px; } .session-list > .session-switch { grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center;