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 d3be242..72f4787 100644 Binary files a/console/installer/art/1x/brand.bmp and b/console/installer/art/1x/brand.bmp differ diff --git a/console/installer/art/2x/brand.bmp b/console/installer/art/2x/brand.bmp index 207bf87..aee4811 100644 Binary files a/console/installer/art/2x/brand.bmp and b/console/installer/art/2x/brand.bmp differ 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) + } +})