From 9663d5bfb03bb829d1364437e06a0959a9017608 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 14 Sep 2026 09:57:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(entry):=20=E4=BF=AE=E3=80=8C=E5=9B=A2?= =?UTF-8?q?=E4=BD=93=E5=90=8D=E6=AC=A1=20/=20=E6=89=B9=E6=AC=A1=E5=8F=B7?= =?UTF-8?q?=20/=20=E6=AF=8F=E6=89=B9=E4=BA=BA=E6=95=B0=E3=80=8D=E5=A1=AB?= =?UTF-8?q?=E6=95=B0=E5=AD=97=E5=AF=BC=E8=87=B4=E6=95=B4=E9=A1=B5=E7=99=BD?= =?UTF-8?q?=E5=B1=8F=EF=BC=88P0=EF=BC=89=EF=BC=9B=E7=89=88=E6=9C=AC=20dev?= =?UTF-8?q?=5F0.5.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现场现象 在「按单位手动录名次」的项目(例如篮球)的录成绩页,往底部「团体名次」输入框里填一个数字, **整个录入页立刻变空白**,控制台 `TypeError: e.trim is not a function`(栈里是 computed 的 getter)。 ## 根因(老 bug,不是联队引入) - 模板是 `v-model="row.input"` + `type="number"`。 - **Vue 3 的 `vModelText` 对 `type="number"` 会自动做数值转换** (`castToNumber = number || (vnode.props && vnode.props.type === 'number')`)→ 填了数字之后值是 `number`;只有**清空**时 `looseToNumber('')` 才留空串。当初的注释只写了后一半,漏了前一半。 - `teamRanks.ts` 的 `parseTeamRankInput(raw: string)` 里直接 `raw.trim()` → TypeError; 而它的三个调用点(保存载荷 / 重复名次提示 / 描红)**全在渲染期的 computed 里** → 整页白屏。 证据:0.5.2(联队之前)的 `EntryPage.vue` 里就已是这套写法,`teamRanks.ts` 本轮一个字没改过。 ## 修复 - `teamRanks.ts`:`parseTeamRankInput` 参数与 `TeamRankRow.input` 类型如实标注 `string | number`, 解析前 `String(raw).trim()`;`teamRanksDirty` 归一后比较(否则 `number 3` 与 `string '3'` 会被判成 「已修改」,保存按钮空亮着却什么都没改)。 - `EntryPage.vue`:**同类第二处** —— 「批量设置批次」的 `heatInput` / `heatSizeInput` 同样是 `type="number"` 且下游 `.trim()`,**同样会整页白屏**,一并修掉。 - 写法与仓库既有的正确实现保持一致(`CategoriesSection.vue` / `AthletesSection.vue` 的 `toAge()`)。 ## 同类隐患体检 把全仓库 **21 个 `type="number"` 的 v-model 绑定点**逐个追了下游用法(解析 / 比较 / `.trim()` / 传接口): **真正会炸的只有上面这 3 处**,其余 18 处本来就用 `Number()` 归一,安全。 ## 验证 - **先红后绿**:修复前 3 例红(`raw.trim is not a function` 原地复现:number 解析抛错、computed 调用链抛错、 脏判断误报 `true !== false`),修复后 13/13 通过。 - 前端用例 809 → **813**,0 fail;`vue-tsc` / `pnpm build` 退出码 0。 - 全量自检两轮全绿(服务端 14 包 / Console 68 例);退出码直接读,未用管道吞。 ## 版本 `dev_0.5.3` → **`dev_0.5.4`**(0.5.3 已产出并记过哈希,不重出同名包);安装器素材同步重生成 (版本号烙在位图里,实测仍只有 `brand.bmp` 的 1x/2x 两张变化)。 --- 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/package.json | 2 +- docs/升级说明.md | 25 +++++++++++++++- web/src/admin/pages/EntryPage.vue | 11 ++++--- web/src/admin/pages/teamRanks.ts | 23 +++++++++++---- web/src/admin/tests/entry-heat.test.ts | 10 +++++++ web/src/admin/tests/team-ranks.test.ts | 39 +++++++++++++++++++++++++ 10 files changed, 100 insertions(+), 14 deletions(-) diff --git a/VERSION b/VERSION index d87c9ea..7cb4ac2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.5.3 +dev_0.5.4 diff --git a/console/installer/art/1x/brand.bmp b/console/installer/art/1x/brand.bmp index 48b05ff70b3736e8f623a197374e4f9f5e89b547..fab95e9a8055bbb3d5d64f3a07551c7d06ac6607 100644 GIT binary patch delta 432 zcmdnCT6o)P;SCZwn=Kfvm?j71P2l&{LPmU>9T~NmCd(8~6Y|#Jh06sQhy>^gZgyeR zV4D11YQ4nJ)HAo5h>?#~HKc2xCLr-ZwMaNkycx&*0 z%v;-}!ZbOtrbkv+&_|OmP)~SPMQo;nuL=cCOJ zv<>LIG-nNtIG4uAPlUOb~ F0RY@pez*Vt delta 443 zcmdnCT6o)P;SCZwoPOE@K;*9@u-TE(kZE#I-UJ~a-&c#@Q;pkQh0{lq7bNGxsKGQ@ zrf{05mpTs+CDDo+_nVKeuLZpGNca&pp zh;`~n^>SC?*u1=HH zc+RUyOm$ZGQs)M02C69Pnk)pAjtIG4u9y6IeK00Z2K1yZ`_I diff --git a/console/installer/art/2x/brand.bmp b/console/installer/art/2x/brand.bmp index a36f199e9207485c0eb03aec31a6c27bb7f5a305..39c9a0b13a5be5cd2b77a897afc51c3ae9dba5e1 100644 GIT binary patch delta 1017 zcmZ8gNla5w6wP~lpmd=93^9T&g|@W6Em#oJl%ck@wiG%AWU5G+WeR1Kf`AAL5m3Z} z7*|GPBoS0>;U}(*afLCiT)A^DV!Q^#N`CN@eyfJNRLGvwXlK`II_9x%b!qcxEZ?$+P#M)h%!jZ zm3)_4cr@G^QJI1paOFP^Hgs53yf z_Pg`T6bXkZi(gSDqKY!04jg!;%~k4B zpU-waoo=r@n*uvv%!KcJHi&Z!AV~wg40d!fdn6k<9}>I1QiRG&>%a zPp`&dDawQdAT4!!pz*6#3ap-to_>toK+t)7EQ_*1aBacqjk%sDQ?0NbuPcp&f+yLZ zNt}%&j2Ibb&|65ajgM67hrETmt7FHgeu5r8#pphIr{L7Jp}J=`JEpH#U?Kr$-Z9-N zVw{FTxtO2MYHu(|Hs*VQ{_;*=;6#s+ES4)ISuJplVBTQHpm&{2#>q7LgQ^Jnk_t4y zdl9BOsYJNpqt%9~22*2+v_i#aX1_+^8?m7xEJ%z#7@M49mvfvde%OOs2zuolrclU) z<9X4)ZY>GdnSG7TxQduBFknCofLX@^;sEgg4j=)*1@HiTfB=vP5CTL15|Bg}7?QF0 EKbM;++5i9m delta 1051 zcmY*YOHUJF6wNo5(v|`(wL)486icBG5CnM?B5JU-&Snq9;;%%u- zCqh`Yi55))!loD5^`ZqkC8b-oNgyT#AI<8xDiz>S24$^AZ1>@vfKm+n@Bgdad~u4ZbEs9h8Sd|NKHu5!I!P zSWLU?yZHWb;N9%Nv!TYBF8e}%UF1>khxr>*9W{sb(_e{_=*!ZJyAMlFtA8uT!S zxENA9-$^>rLIJCo@nYW>_6_KJiewP9GTQ1mEn&LcOjrL8A|v57!s9FdEk_P*Fs`XA z4SJS`eRf?Uy}L)e99sN~$VoUtcw#v$g9uQAh>14y)?)BZLj|jV{Hb)FD%;aDUQ^sXzV5X-?zB zP?Bg9bB{v>l!hds?kEm_Q~ZNY&SYUJ1snsIJOcR?0|Fop5CZW)0+0xZ05On6 J7nVvW#b2Q>E^`0? diff --git a/console/installer/art/version.txt b/console/installer/art/version.txt index be14282..7d85683 100644 --- a/console/installer/art/version.txt +++ b/console/installer/art/version.txt @@ -1 +1 @@ -0.5.3 +0.5.4 diff --git a/console/package.json b/console/package.json index d8bef18..89550d5 100644 --- a/console/package.json +++ b/console/package.json @@ -1,7 +1,7 @@ { "name": "scoreboard-console", "productName": "Scoreboard Console", - "version": "0.5.3", + "version": "0.5.4", "private": true, "author": "AskaEth", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", diff --git a/docs/升级说明.md b/docs/升级说明.md index 38f290e..d6b3860 100644 --- a/docs/升级说明.md +++ b/docs/升级说明.md @@ -6,7 +6,30 @@ --- -## dev_0.5.3(当前版本) +## dev_0.5.4(当前版本) + +### 一句话 + +**没有数据迁移,升级后不用重配任何东西**:修掉一个会让**成绩录入页整页白屏**的问题 —— 在「按单位手动录名次」的项目(例如篮球)底部**「团体名次」输入框里填数字**,或者在顶部工具条里填**批次号 / 每批人数**,页面会整个变成空白。**已经录好的成绩、名次、榜单数据都不受影响**,这三处只是以前一填数字就崩。 + +### ① 修了什么(三处输入框,同一个根因) + +- **团体名次**(「按单位手工录名次」的项目,录成绩页底部那个区块):往某个单位的名次框里填数字 → **整页白屏**; +- **批次号 / 每批人数**(录成绩页顶部「批量设置批次」工具条):同样填数字 → **整页白屏**; +- 根因在浏览器与框架这一层:这几个框是**数字输入框**,填进去的值会被**自动转成数字**,而处理它的代码是按「文本」写的(对值调用文本方法)→ 当场抛错。又因为它发生在**页面渲染过程**里,所以表现不是「弹个错误」,而是**整页变空白**(这也是为什么它看起来比一般错误严重)。 +- 三处都改成**先归一成文本再解析**;**填同样的内容,结果与以前预期的完全一致**(团体名次填 `3` 就是第 3 名,填 `0` 或留空仍是「清空该单位」)。 +- 顺带把仓库里**其余同类输入框逐个体检了一遍**(共 21 处),确认只有上面这三处会出问题,其余的写法本来就是安全的。 + +### ② 升级后 5 分钟核对 + +1. 打开一个「按单位手工录名次」的项目(例如篮球)的录成绩页,在底部**「团体名次」**里给某个单位填一个数字:**页面应当正常显示**;名次重复或超出参赛单位数时,输入框会描红并给出提示(**只提示不拦截,仍然允许保存**); +2. 在录成绩页顶部**「批量设置批次」**里填一个数字,确认页面不白屏、按钮可点; +3. 打开一个**联队**的报名(单位编码形如 `T1+T2`),确认单位那格显示两个单位的名字与份额 —— 这是上一版(0.5.3)新增的能力; +4. 其余照旧:**不需要重新导入规则包,也不需要重配任何东西**。 + +--- + +## dev_0.5.3 ### 一句话 diff --git a/web/src/admin/pages/EntryPage.vue b/web/src/admin/pages/EntryPage.vue index b8435ba..1896f45 100644 --- a/web/src/admin/pages/EntryPage.vue +++ b/web/src/admin/pages/EntryPage.vue @@ -197,10 +197,12 @@ const tableRef = ref(null) const selectedHeat = ref('all') /** 表格行选择(批量设置批次用),存 entryId。 */ const selectedEntryIds = ref([]) +// 两个输入框都是 type="number",Vue 的 v-model 会把填进去的数字转成 number(清空才给空串), +// 如实标注 string | number:按下游当字符串用(.trim())会在渲染期抛 TypeError,整页白屏。 /** 「设为第 N 批」的批次号输入;空 = 用推荐的下一个批次号。 */ -const heatInput = ref('') +const heatInput = ref('') /** 「一键均分」的每批人数。 */ -const heatSizeInput = ref(String(DEFAULT_HEAT_SIZE)) +const heatSizeInput = ref(String(DEFAULT_HEAT_SIZE)) const heatBusy = ref(false) const splitDialog = ref(false) const splitMessage = ref('') @@ -331,7 +333,8 @@ const allVisibleSelected = computed( /** 「设为第 N 批」真正会用的批次号;填了非法值时为 null(按钮停用)。 */ const heatNumber = computed(() => { - const raw = heatInput.value.trim() + // 输入框填了数字后 v-model 给的是 number,不能直接 .trim()(会抛 TypeError → 整页白屏)。 + const raw = String(heatInput.value).trim() if (raw === '') return nextHeat.value const value = Number(raw) return Number.isInteger(value) && value >= 1 && value <= MAX_HEAT ? value : null @@ -339,7 +342,7 @@ const heatNumber = computed(() => { /** 「一键均分」的每批人数;非法时为 0,编排函数据此返回 null。 */ const heatSize = computed(() => { - const raw = heatSizeInput.value.trim() + const raw = String(heatSizeInput.value).trim() const value = Number(raw) return Number.isInteger(value) && value >= 1 ? value : 0 }) diff --git a/web/src/admin/pages/teamRanks.ts b/web/src/admin/pages/teamRanks.ts index 0ebc16d..5ca6c3f 100644 --- a/web/src/admin/pages/teamRanks.ts +++ b/web/src/admin/pages/teamRanks.ts @@ -14,8 +14,13 @@ export interface TeamRankRow { code: string name: string shortName: string | null - /** 输入框里的原文;空串 = 未填(保存时按 0 = 清空发给服务端)。 */ - input: string + /** + * 输入框里的值;空串 = 未填(保存时按 0 = 清空发给服务端)。 + * + * 输入框是 `type="number"`,Vue 的 v-model 会把填进去的数字转成 number(只有清空才给空串), + * 所以这里如实标注 `string | number`:当成 string 用(`.trim()`、`!==` 比较)会整页白屏或误判已修改。 + */ + input: string | number /** 载入时的原文:用来判断这一行有没有被改过。 */ original: string } @@ -31,13 +36,17 @@ export function hasTeamManualChannel(channels: readonly ScoreChannel[] | null | } /** - * 输入框原文 → 请求体里的名次。 + * 输入框值 → 请求体里的名次。 * * 空、0、负数、非整数一律归一为 0:服务端口径就是「rank 0 = 清空该单位」, * 页面上留空即清空,不需要再让操作员先填一个 0。 + * + * 参数类型必须是 `string | number`:输入框是 `type="number"`,Vue 的 v-model 会自动把 + * 填进去的数字转成 number(只有清空才给空串,所以只试「清空」看不出问题),直接 `.trim()` + * 会抛 TypeError;而调用点(载荷 / 提示 / 描红)全都在渲染期的 computed 里,一抛就整页白屏。 */ -export function parseTeamRankInput(raw: string): number { - const trimmed = raw.trim() +export function parseTeamRankInput(raw: string | number): number { + const trimmed = String(raw).trim() if (trimmed === '') return 0 const value = Number(trimmed) if (!Number.isInteger(value) || value < 1) return 0 @@ -75,7 +84,9 @@ export function teamRankRowsOf(view: TeamRanksView | null, teams: readonly Team[ } export function teamRanksDirty(rows: readonly TeamRankRow[]): boolean { - return rows.some((row) => row.input !== row.original) + // input 可能是 number(`type="number"` 的 v-model 转出来的),original 恒为字符串, + // 直接 `!==` 会把 3 和 '3' 判成「已修改」——保存按钮亮着,其实一行都没改。 + return rows.some((row) => String(row.input) !== row.original) } /** diff --git a/web/src/admin/tests/entry-heat.test.ts b/web/src/admin/tests/entry-heat.test.ts index f5b21c1..3e4f7cf 100644 --- a/web/src/admin/tests/entry-heat.test.ts +++ b/web/src/admin/tests/entry-heat.test.ts @@ -1,5 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { applyHeatToRows, filterRowsByHeat, @@ -10,6 +11,8 @@ import { type HeatEntryRow, } from '../pages/entryHeat' +const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8') + type Row = HeatEntryRow & { name: string } function rows(spec: Array<[string, number | null]>): Row[] { @@ -126,3 +129,10 @@ test('applyHeatToRows:只改命中的行,其余行(含正在编辑的成 // 未命中的批次筛选值不受影响。 assert.deepEqual(applyHeatToRows(list, [], 5).map((row) => row.heat), [null, 1, null]) }) + +test('录入页:批次号 / 每批人数两个 number 输入框按 number 也能取值', () => { + // 两个框都是 type="number",填了数字后 v-model 给的是 number(清空才是空串), + // 直接 `.trim()` 会在渲染期 computed 里抛 TypeError → 整页白屏(与团体名次同一个坑)。 + assert.match(ENTRY_SOURCE, /String\(heatInput\.value\)\.trim\(\)/) + assert.match(ENTRY_SOURCE, /String\(heatSizeInput\.value\)\.trim\(\)/) +}) diff --git a/web/src/admin/tests/team-ranks.test.ts b/web/src/admin/tests/team-ranks.test.ts index 8e55a82..139d922 100644 --- a/web/src/admin/tests/team-ranks.test.ts +++ b/web/src/admin/tests/team-ranks.test.ts @@ -73,6 +73,45 @@ test('名次输入:空 / 0 / 负数 / 非整数一律按清空处理', () => { assert.equal(parseTeamRankInput('abc'), 0) }) +test('名次输入:输入框填了数字后 v-model 给的是 number,解析口径与字符串一致', () => { + // type="number" 的 v-model 会被 Vue 自动转成 number(只有清空才是空串), + // 所以 raw 是 number 时必须照样解析——直接 .trim() 会在渲染期抛 TypeError 导致白屏。 + assert.equal(parseTeamRankInput(3), 3) + assert.equal(parseTeamRankInput(0), 0) + assert.equal(parseTeamRankInput(3.5), 0) +}) + +test('input 是 number 时:提示 / 描红 / 保存载荷都不抛异常且结果正确', () => { + const rows = teamRankRowsOf(makeView(), []) + rows[0]!.input = 3 + rows[1]!.input = 0 + assert.deepEqual(teamRanksPayload(rows), { + scope: 'team', + basis: 'manual', + ranks: [ + { teamId: 1, rank: 3 }, + { teamId: 2, rank: 0 }, + ], + }) + // 超出参赛单位数(两个单位却填了 3):只提示不抛错。 + assert.match(teamRankWarnings(rows).join(';'), /超出参赛单位数 2/) + assert.deepEqual([...teamRankFlaggedTeams(rows)], [1]) + + // 重复名次(number 形态)同样算得出来。 + rows[1]!.input = 3 + assert.match(teamRankWarnings(rows).join(';'), /名次 3 重复 2 次/) + assert.deepEqual([...teamRankFlaggedTeams(rows)], [1, 2]) +}) + +test('改动检测:input 是 number、original 是字符串时不误判为已修改', () => { + const rows = teamRankRowsOf(makeView(), []) + // 回填的 original 是字符串 '2',输入框里没动过时 input 已经是 number 2。 + rows[0]!.input = 2 + assert.equal(teamRanksDirty(rows), false) + rows[0]!.input = 3 + assert.equal(teamRanksDirty(rows), true) +}) + test('表格行:单位来自接口,已有名次回填,未填留空', () => { const rows = teamRankRowsOf(makeView(), []) assert.deepEqual(