fix(entry): 修「团体名次 / 批次号 / 每批人数」填数字导致整页白屏(P0);版本 dev_0.5.4

## 现场现象
在「按单位手动录名次」的项目(例如篮球)的录成绩页,往底部「团体名次」输入框里填一个数字,
**整个录入页立刻变空白**,控制台 `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 两张变化)。
This commit is contained in:
AskaEth committed 2026-09-14 09:57:02 +08:00
1 parent 830a4dbdb7
commit 9663d5bfb0
10 files changed
+100 -14

No files matched your search

+7 -4
View File
@@ -197,10 +197,12 @@ const tableRef = ref<HTMLElement | null>(null)
const selectedHeat = ref<HeatFilter>('all')
/** 表格行选择(批量设置批次用),存 entryId。 */
const selectedEntryIds = ref<number[]>([])
// 两个输入框都是 type="number",Vue 的 v-model 会把填进去的数字转成 number(清空才给空串),
// 如实标注 string | number:按下游当字符串用(.trim())会在渲染期抛 TypeError,整页白屏。
/** 「设为第 N 批」的批次号输入;空 = 用推荐的下一个批次号。 */
const heatInput = ref('')
const heatInput = ref<string | number>('')
/** 「一键均分」的每批人数。 */
const heatSizeInput = ref(String(DEFAULT_HEAT_SIZE))
const heatSizeInput = ref<string | number>(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<number | null>(() => {
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<number | null>(() => {
/** 「一键均分」的每批人数;非法时为 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
})
+17 -6
View File
@@ -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)
}
/**
+10
View File
@@ -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\(\)/)
})
+39
View File
@@ -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(