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 @@
- {{ viewOnly ? '关闭' : '取消' }}确定
+ {{ viewOnly ? '关闭' : '取消' }}确定
@@ -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' ? '队伍' : '参赛者' }} · 第 {{ rankingPageIndex + 1 }}/{{ rankingPageCount }} 页
- {{ rankNumber(row,index) }}{{ 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 @@
-
-
@@ -26,14 +26,7 @@
大屏控制选择展示对象与比赛组别单条成绩从下方列表投出;排名按当前选择的项目组别投出
个人团队
- 显示成绩大屏切换检录大屏投出{{ castEntityLabel }}排名
-
-
大屏配色成绩和排名大屏实时生效,设置保存在本机
-
亮色暗色
-
-
85.5比赛成绩LIVE RESULTS
-
恢复默认
-
+ 显示成绩大屏投出{{ castEntityLabel }}排名
{{ selectedProject.projectName }}{{ selectedProject.groupCount }} 个组别
@@ -48,10 +41,6 @@
-
-
LIVE当前正在展示:{{ currentCastSummary }}{{ formatTime(currentCast.castTime) }}
查看大屏撤回投屏
-
-
{{ rowSessionDisplay(row) }}
{{ row._session?.venueName || '—' }}
@@ -80,8 +69,9 @@
+ 当前赛事{{ eventName }}
查询
- {{formatSessionText(row.sessionNo)}}{{formatTime(row.createdTime)}}{{row.correctionStatusName}}详情批准驳回
+ {{formatSessionText(row.sessionNo)}}{{formatTime(row.createdTime)}}{{row.correctionStatusName}}详情批准驳回
@@ -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;