From 19e5630b3981b784e39366cffff1f98ee195745f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 17 Sep 2026 02:02:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(display):=20=E5=A4=A7=E5=B1=8F=E6=95=B0?= =?UTF-8?q?=E5=AD=97=E4=B8=8D=E5=86=8D=E4=BE=9D=E8=B5=96=E5=B8=A7=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=EF=BC=88=E8=A2=AB=E9=81=AE=E6=8C=A1=E6=97=B6=E5=8D=A1?= =?UTF-8?q?=E4=BD=8F=EF=BC=89+=20Console=20=E5=85=B3=E9=81=AE=E6=8C=A1?= =?UTF-8?q?=E8=8A=82=E6=B5=81=20+=20resync=20=E5=86=B7=E5=8D=B4=E6=9C=9F?= =?UTF-8?q?=E6=8E=92=E9=98=9F=EF=BC=9B=E7=89=88=E6=9C=AC=20dev=5F0.5.13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现场:清空本组后,大屏上的「单位总分」要等几十秒才降下来。 根因不在「清空」这条路:服务端在同一个事务里就把流水作废重写、算出整张榜单并推送, 与「撤销发布」逐条同构(results_clear.go / scoring.go:1057)。真正卡住的是那一格数字的 画法 —— 它用 requestAnimationFrame 逐帧滚动,而 Chromium 对被遮挡 / 后台的窗口会停掉 帧生产:数字停在旧值,卡多久 = 被遮挡多久,与后台算分无关。全场只有「单位总分」是滚动 显示的,奖牌榜 / 分项成绩 / 实时赛况都是纯文本,所以只有它看起来会延迟。 - 新增 web/src/display/animatedNumber.ts:缓出插值、兜底时长、是否直接对齐抽成纯函数; 进度越界的晚到单帧直接落到目标值(对应「窗口恢复可见瞬间跳到新值」) - AnimatedNumber.vue:加 duration+400ms 的兜底计时器(帧一个都不来也最迟约 1.3s 对齐到 新值);visibilitychange 变可见 / window focus 时立刻对齐,不再补滚一遍过时动画; 卸载时摘掉监听(大屏是长跑窗口) - console/main/index.js:主进程在 app ready 之前追加 disable-backgrounding-occluded-windows / disable-renderer-backgrounding; 两个 Console 窗口补 backgroundThrottling: false,与大屏窗口同口径 - web/src/shared/realtime.ts:requestResync() 冷却期内排队而不是丢弃 —— 原先丢掉之后 本地版本已经推进,心跳的 data.version > currentVersion 再也发现不了缺口, 那一版该做的重拉会永久缺失 测试 +11 例(display 9 / shared 2),含「命令行开关必须位于 app.whenReady() 之前」的位置断言。 四套自检 ×2 全绿(finalcheck_0513.log:前端 985 例 / Console 96 例 / 服务端 14 包,EXIT=0)。 无数据库迁移;已有成绩、名次、积分、榜单与导出件一个数字都不变。 文档:docs/升级说明.md 新增 dev_0.5.13 一节;docs/常见问题.md 新增 Q103。 --- VERSION | 2 +- console/installer/art/1x/brand.bmp | Bin 316854 -> 316854 bytes console/installer/art/2x/brand.bmp | Bin 1267254 -> 1267254 bytes console/installer/art/version.txt | 2 +- console/main/index.js | 18 ++- console/package.json | 2 +- docs/升级说明.md | 37 ++++++ docs/常见问题.md | 16 +++ web/src/display/animatedNumber.ts | 59 +++++++++ web/src/display/components/AnimatedNumber.vue | 73 ++++++++--- web/src/display/tests/animated-number.test.ts | 121 ++++++++++++++++++ web/src/shared/realtime.ts | 42 +++++- web/src/shared/tests/realtime.test.ts | 55 ++++++++ 13 files changed, 401 insertions(+), 26 deletions(-) create mode 100644 web/src/display/animatedNumber.ts create mode 100644 web/src/display/tests/animated-number.test.ts diff --git a/VERSION b/VERSION index f0f789c..b08fbd9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.5.12 +dev_0.5.13 diff --git a/console/installer/art/1x/brand.bmp b/console/installer/art/1x/brand.bmp index d3be242ee3319e7c04491d8ee226016aba097935..72f478721c69eadfa2ed39c39249b26f86fd0d42 100644 GIT binary patch delta 385 zcmdnCT6o)P;SE|j%>Ft8oA*voWttq6x0D~q@z&t+ROR$m=MK;n*c`?v#xz-`aG8jS zgr7FQry5t3g-nj8Zi0=1w+1&v_D%5$2_H?~nkc*JrBQ9k9lxk zQPo?W3#7=uY6p{$uNGf~iA15FQD>TWW1N$(7VqZHRvD(rruB4V zke@c+=APD_OycU?*`B&HN~2nm+~!urSB6=6f!rbhl$2zeT-vpW-%Fi4+*A@68gbSj q3xS~lG}NhgCATIoFaUjE0VvPZEIOfGbOIv~GXXL4cF_qe3ta)Z({M5X delta 396 zcmdnCT6o)P;SE|jo4pt{m?j71Efw<7Nx{jDwdN7f^qIt^m*uF{a7=l}n`qbOnKC0PUGo9#axv z>Y>68ln&7056}_V>|eEmNz`9Qz+0U=+*o{mZBm@I9MEi_?B**4%<`B(JwgA?U9A#KlP|R_6-{%| zh%l9Olx56v*PdM&3-mk0=$%aB8azb-CeunHTa(@A)g2sabSFyXXW)AZ7w$=Ix>rSQfeh E0L7GX9RL6T diff --git a/console/installer/art/2x/brand.bmp b/console/installer/art/2x/brand.bmp index 207bf8740ed3c8e21b5fa7a33abfba7f98eb9b9e..aee48112f04836c800c1d1dbaef87aef6863f8a4 100644 GIT binary patch delta 943 zcmYjP$xjnu9L+aQftI$kf`Cwnh0>NTlr1bm5M=FMprjQ_QA&^{>VkT0nO}uCJvH}zhn1~7+_FJ=d=DYV- zM_x?cSnhVL^we$5b{?#aZjO3fxp7vFh?PuI2AaA5g;;0e0oILt;E1xpwoCJReFrVM0!J>C9y3e4mw$)}kdAyr8fY$L%-gC`fa zO3s_|lpU`7$6ZB6WdwQ&2~MOT0WJ|IW1G=S&8o;gb0!imwwp{Uwn&DHG#p2j5f!Kq zc#AF}n^7s?Zf2#NhisVj%=HSRlK)Q=lf-IFsz_g{V(-D=Qm4&Rn1nIITX&6ad!_>) z+sjqv|Iax{p(;qSGwZ@!Yc+ZU<5i#yi9fE~Li7oDF>VuE&K;f{V}gg4VtJssY^~3^ z+FL*Ez1mumiVSugnuua9DySlwInz^aLi6!?!C|0)a{vwSfG{8&hyeJ20AP7SKm>?^ QC_n;8f%A;R5dB^G2Yj{-TmS$7 delta 909 zcmY*XOHUJF6wNmdNQcr+3y3@I`v{a!&2rp5@kOHMZP*4k2 zf(o_@?u-jx5sk5h@dvPG!^)K#<3cx*uyf;Sht!yy(=?fL?sxCGcXwOZ-4^a7NzY!n z$HHbHS=fKDvXYgR!+YdjR)x!$dkh2PGBEB8OpMFO2N3S|};&UI?f-jdwUfdpf zy*#GiRpk2n)rpBto714mxYoWBfmFCn>bw1IpB_(dh63I7MIN5@*A#Tw^1EtMkg9cP ztd5rXZ)nUY@>&H>1GCuMl8DVd5BK_PdbcrGG~<&7@bDUCmi_Orlx8d)m8!+QmSk)W z;~*o^tdS)f)k@2gKcPFV=ny1IuMD>*<8!N{Zq!W#sq?g~`AsBJim$K%$O3QA_!^v* zDybUrYvuVv@*t}aicXU{U@ykRZ|{%yHI{W&spV5#s)qj$Qi}Ie*?O;4*TLn4yyud! z+0{`OqEsY1M^sv&D#RPUYCR%+7ahOSP%2cZQ>!fFs76|TnoSAmxjej9vg*)6WWa$+ zlVTm>P3dkOk_K9?S5pqM)w@TS5?-6qVzx@vv}8vHNa8>3feu;@yTJ6v$HZ&VCJdpp zVJTx!ng|t9t;7h!KyyXd-v}H2;}JBD(H`Cq6HmfHvG({8#Zzzw9uCsT;KeVS3(*m0 zz+NKpEGC`rZuXoF0x6d)I$Y!DjTzh(5_z*ShU}5=XdB*7 zCx`pS$)fOZ3l|x5U_0wKy$e0f*n`{1Ad%F9l#)kQs)x`yLRDTA+Xqj8G?)cugD1fp aPz5qzE~o}mdJU)rSx^V+aY6Y%41WQF(FFhi diff --git a/console/installer/art/version.txt b/console/installer/art/version.txt index 9d6c175..964783a 100644 --- a/console/installer/art/version.txt +++ b/console/installer/art/version.txt @@ -1 +1 @@ -0.5.12 +0.5.13 diff --git a/console/main/index.js b/console/main/index.js index fb3b50d..0baf28a 100644 --- a/console/main/index.js +++ b/console/main/index.js @@ -23,6 +23,14 @@ const { createTrayIcon } = require('./tray-icon') const { isAutostartEnabled, setAutostart } = require('./autostart') const { validateAccountField, buildPasswdArgs, runPasswd } = require('./account') +// 大屏常年挂在扩展屏上,窗口一旦被别的窗口盖住、或那块屏的输入源被切走,Chromium 会按 +// 「被遮挡 / 后台」处理并停掉该窗口的帧生产:requestAnimationFrame 回调不再到来,大屏上的 +// 数字动画就卡在旧值上(数据其实早就更新了,只是画不出来,一卡就是几十秒)。 +// 这两个开关关掉渲染进程的后台/遮挡节流,让帧回调继续产生,动画才能按时走完。 +// 必须在 app.whenReady() 之前追加,ready 之后再 appendSwitch 就不生效了。 +app.commandLine.appendSwitch('disable-backgrounding-occluded-windows') +app.commandLine.appendSwitch('disable-renderer-backgrounding') + // 现场只允许一个实例:两个 Console 会互相抢同一个服务端端口和同一块大屏 if (!app.requestSingleInstanceLock()) { app.quit() @@ -144,7 +152,10 @@ function main() { preload: path.join(__dirname, '..', 'renderer', 'preload.js'), contextIsolation: true, nodeIntegration: false, - sandbox: true + sandbox: true, + // 与大屏窗口(displays.js)同口径:窗口被盖住或最小化时不要节流渲染进程, + // 否则回到前台要等一拍界面才更新(状态、日志、大屏实例看板都画在这一层) + backgroundThrottling: false } }) @@ -251,7 +262,10 @@ function main() { preload: path.join(__dirname, '..', 'renderer', 'shell-preload.js'), contextIsolation: true, nodeIntegration: false, - sandbox: true + sandbox: true, + // 与大屏窗口(displays.js)同口径:窗口被盖住或最小化时不要节流渲染进程, + // 否则回到前台要等一拍界面才更新(服务端管理面板的轮询与刷新都画在这一层) + backgroundThrottling: false } }) diff --git a/console/package.json b/console/package.json index 747deaf..036a241 100644 --- a/console/package.json +++ b/console/package.json @@ -1,7 +1,7 @@ { "name": "scoreboard-console", "productName": "Scoreboard Console", - "version": "0.5.12", + "version": "0.5.13", "private": true, "author": "AskaEth", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", diff --git a/docs/升级说明.md b/docs/升级说明.md index 009ab23..9c86cf1 100644 --- a/docs/升级说明.md +++ b/docs/升级说明.md @@ -6,6 +6,43 @@ --- +## dev_0.5.13 + +### 一句话 + +**纯界面 / 宿主程序的小修,没有任何数据迁移:升级后不用重配、不用重新导入规则包**,已有的成绩、名次、积分、榜单与导出件**一个数字都不变**。这一版修一处**现场看得见的延迟**:**大屏上的「单位总分」要等几十秒才更新**(清空本组、发布、调整之后都可能遇到)。 + +### ① 修:「大屏上的单位总分等几十秒才降」 + +**先说清楚它不是什么**:不是「清空没生效」、也不是「分数算错了」——**分数在服务端是当场就变了的**,数据库、榜单接口、导出件从你点下确认的那一刻就已经是新值。**卡住的只是大屏上那一格数字的画法。** + +**真正的原因**:大屏上「单位总分」那一格是**逐帧滚动**过去的(一帧一帧把数字从旧值滚到新值),而浏览器对**被别的窗口盖住的窗口**会**停掉逐帧绘制**——数字就停在旧值上,**卡多久取决于被盖住多久**,跟后台算分一点关系都没有。所以你会看到「等几十秒才降」,而且**把盖住大屏的窗口挪开以后,数字是「啪」地一下跳到新值**,不是慢慢滚过去。 + +> 为什么只有这一格?因为**全场只有「单位总分」这个数字是滚动显示的**,奖牌榜、分项成绩、实时赛况里的数字都是直接写上去的——这也是为什么现场感觉「别的都立刻变、只有总分不动」。 + +**现在会怎样**: + +1. 就算一帧都画不出来,**最迟约 1.3 秒**数字也会直接落到新值(不再无限期停在旧值); +2. 大屏窗口**重新露出来 / 重新被点活**时,数字**立刻**是正确值,**不会再补滚一遍过时动画**; +3. 宿主程序(Console)同时关掉了浏览器对「被遮挡 / 后台」窗口的降频,大屏更不容易掉帧。 + +### ② 顺带修:偶发「某一版变更漏掉、之后一直没补上」 + +大屏与管理面板在**短时间内连续发生多次变化**时,会向服务端要一次「补数据」。原先这个请求**在 3 秒冷却期内会被直接丢掉**,而界面此时已经认为「这一版处理过了」——于是该补的那一次**不会再补**,要等下一条无关的变化才顺带修正(现场表现可能是某块榜单的内容停在几秒前的样子)。现在改成**排队**:冷却一过立刻补发,不会再漏。 + +### ③ 升级后 5 分钟核对 + +1. 打开大屏的**积分榜**页,随便做一次会让分数变化的操作(例如「清空本组」,或发布一批成绩),**清空后不要动鼠标**:那一格数字应当在 **1~2 秒内**落到新值; +2. 故意拿一个窗口**把大屏窗口完全盖住 10 秒**,再挪开:数字应当**立刻就是正确值**(不是从旧值滚一遍过来); +3. 奖牌榜、分项成绩、实时赛况本来就是直接显示,行为与升级前一致; +4. 其余照旧:**不需要重新导入规则包、不需要重配任何东西**;已有成绩、榜单与导出件**一个字不变**。 + +### ④ 详细说明在哪 + +《常见问题》Q103(大屏上的分数为什么过一会儿才变 / 后来一直不变了)。 + +--- + ## dev_0.5.12 ### 一句话 diff --git a/docs/常见问题.md b/docs/常见问题.md index 530a234..cc8aa04 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -715,4 +715,20 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 **怎么办**:从 `dev_0.5.12` 起,录入页会**记住你上次录的那个小项**,离开再回来还是它,按钮就在。想确认自己看的是哪个小项,看**右侧表格上方选中的那个小项名**、以及表里的人是不是你刚才那一组;也可以从仪表盘「当前赛程」点那一项的 **「录入」** 直接进到指定小项。记忆失效时(那个小项被删了、或浏览器不允许存本机数据,例如隐私模式)会**自动退回原来的默认挑法**,不影响使用。详见使用手册 4.1 / 4.7。 +**Q103:大屏上的分数为什么过一会儿才变?有时候干脆一直不变?** + +**先说结论:分早就变了,卡住的只是屏幕上那一格数字的画法。** + +服务端在你点下确认的那一刻就把分算完并写库了——**积分榜接口、导出件、别的电脑上打开同一个榜单,看到的都是新值**。大屏上「**单位总分**」那一格是**逐帧滚动**过去的,而浏览器对**被别的窗口完全盖住的窗口**会**停掉逐帧绘制**,于是数字停在旧值上,**卡多久 = 被盖住多久**。 + +**怎么一眼认出来**:把盖住大屏的那个窗口挪开(或者按一下 Alt+Tab)——数字会**「啪」地一下跳到新值**,而不是慢慢滚过去。 + +**为什么只有这一格**:全场只有「单位总分」是**滚动显示**的,**单位奖牌榜、分项成绩、实时赛况里的数字都是直接写上去的**,所以它们一直是立刻变的。 + +**怎么办**: + +- 从 `dev_0.5.13` 起已经修好:就算一帧都画不出来,**最迟约 1.3 秒**也会直接落到新值;大屏窗口**重新露出来 / 被点活**时立刻是正确值,不会再补滚一遍过时动画; +- 日常使用上:**别用别的窗口把大屏盖住**(尤其别把管理面板拖到接大屏的那块显示器上);大屏那块屏播放中**不要切走输入源或关屏**,那也会让它判定为「看不见」而停止绘制; +- 升级前的旧版本遇到这个现象时,**挪一下盖在它上面的窗口**即可立刻恢复显示,**不需要重新发布成绩、也不需要重算**。详见《升级说明》的 `dev_0.5.13` 一节。 + > 顺带分清另一件事:「**撤销发布**」**保留**成绩内容(只把分收回来,改完重新发布即可);要**连成绩内容一起清掉**,那是工具栏上另一对按钮「**清空本组 / 清空本批**」(危险操作,要勾选确认,清整组时还会一并清掉按单位录的团体名次)。两者的对照见使用手册 4.7。 diff --git a/web/src/display/animatedNumber.ts b/web/src/display/animatedNumber.ts new file mode 100644 index 0000000..9488c89 --- /dev/null +++ b/web/src/display/animatedNumber.ts @@ -0,0 +1,59 @@ +/** + * AnimatedNumber 的纯逻辑:缓出插值、兜底时长、以及「直接对齐不播动画」的判定。 + * + * 抽出来的理由是「窗口被遮挡 → Chromium 停掉帧生产 → 数字卡在旧值」这条路径只能在真机上复现, + * 组件里那点算术没法单测;这里全部是无副作用的纯函数。 + */ + +/** 给最后一帧留的裕量:过了 duration 再加这点时间还没等到收尾帧,就认定帧回调不来了。 */ +export const FALLBACK_MARGIN_MS = 400 + +/** 把任意进度钳到 [0, 1];非有限值(时间戳异常)当 0,免得把显示值算成 NaN。 */ +export function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0 + return Math.min(1, Math.max(0, value)) +} + +/** 缓出三次方:起步快、收尾慢。即抽取前组件里的 `1 - (1 - progress) ** 3`。 */ +export function easeOutCubic(progress: number): number { + return 1 - (1 - clamp01(progress)) ** 3 +} + +/** + * 兜底时长 = duration + 裕量。 + * + * 帧回调被 Chromium 停掉(窗口被判定为遮挡/隐藏)时,就按这个时间直接把显示值对齐到目标。 + * 计时器不受「停帧」影响(大屏窗口已设 backgroundThrottling:false,遮挡节流也在命令行里关了), + * 所以最坏也就是这个时长内对齐,而不是「遮挡持续多久就卡多久」。 + */ +export function fallbackDelayMs(durationMs: number): number { + return Math.max(0, durationMs) + FALLBACK_MARGIN_MS +} + +/** + * 这一帧该显示什么值。 + * + * progress 被钳到 1 时**直接落到目标值**:窗口被遮挡几十秒后晚到的那一帧、以及恢复可见时 + * 补上的那一帧都走这条路径——进度早已越界,继续按缓动比例插值只会把一段过时的动画重放一遍。 + */ +export function frameValue( + from: number, + target: number, + elapsedMs: number, + durationMs: number, +): number { + if (durationMs <= 0) return target + const progress = clamp01(elapsedMs / durationMs) + if (progress >= 1) return target + return from + (target - from) * easeOutCubic(progress) +} + +/** 是否跳过动画直接对齐:减少动效、时长为非正、起止同值,这三种都不必滚。 */ +export function shouldSnap( + from: number, + target: number, + durationMs: number, + reduced: boolean, +): boolean { + return reduced || durationMs <= 0 || from === target +} diff --git a/web/src/display/components/AnimatedNumber.vue b/web/src/display/components/AnimatedNumber.vue index 5fca5c4..606a03c 100644 --- a/web/src/display/components/AnimatedNumber.vue +++ b/web/src/display/components/AnimatedNumber.vue @@ -2,12 +2,18 @@ import { computed, onUnmounted, ref, watch } from 'vue' import { formatPoints } from '@/shared' import { prefersReducedMotion } from '../useReducedMotion' +import { fallbackDelayMs, frameValue, shouldSnap } from '../animatedNumber' const props = withDefaults(defineProps<{ value: number; duration?: number }>(), { duration: 900 }) const reduced = prefersReducedMotion() const shown = ref(props.value) let frame = 0 +let fallbackTimer: ReturnType | null = null +// 本轮动画的两个端点:兜底对齐与「窗口又可见」都要拿它们把显示值一步拉到位 +let from = props.value +let target = props.value +let started = 0 // 整数分数滚动时不要出现小数抖动;权重分(非整数)才保留两位。 const text = computed(() => @@ -19,33 +25,64 @@ function stop(): void { cancelAnimationFrame(frame) frame = 0 } + if (fallbackTimer !== null) { + clearTimeout(fallbackTimer) + fallbackTimer = null + } } -function run(target: number): void { +// 直接对齐到目标值:停掉帧与兜底计时器,显示值一步到位。 +function align(): void { stop() - const from = shown.value - if (reduced.value || props.duration <= 0 || from === target) { - shown.value = target - return - } + shown.value = target +} - const started = performance.now() - const step = (now: number): void => { - const progress = Math.min(1, (now - started) / props.duration) - const eased = 1 - (1 - progress) ** 3 - shown.value = from + (target - from) * eased - if (progress < 1) { - frame = requestAnimationFrame(step) - } else { - shown.value = target - frame = 0 - } +function step(now: number): void { + const value = frameValue(from, target, now - started, props.duration) + shown.value = value + if (value === target) { + align() + return } frame = requestAnimationFrame(step) } +function run(next: number): void { + stop() + const current = shown.value + target = next + if (shouldSnap(current, next, props.duration, reduced.value)) { + shown.value = next + return + } + + from = current + started = performance.now() + frame = requestAnimationFrame(step) + // 帧回调不来的兜底:被遮挡的窗口不产帧,只靠 rAF 收尾的话显示值会一直停在旧值上 + // (数据其实早就换了)。计时器不受停帧影响,到点直接对齐到目标。 + fallbackTimer = setTimeout(align, fallbackDelayMs(props.duration)) +} + +// 窗口重新可见 / 重新获得焦点时立刻对齐:遮挡期间帧回调停发,屏一亮不该再补滚一遍过时动画。 +function onVisibilityChange(): void { + if (document.visibilityState === 'visible') align() +} + +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + window.addEventListener('focus', align) + document.addEventListener('visibilitychange', onVisibilityChange) +} + watch(() => props.value, run) -onUnmounted(stop) +onUnmounted(() => { + stop() + // 大屏是长跑窗口,监听器必须随组件摘干净,否则反复重载会把闭包越攒越多 + if (typeof window !== 'undefined' && typeof document !== 'undefined') { + window.removeEventListener('focus', align) + document.removeEventListener('visibilitychange', onVisibilityChange) + } +})