fix(entry): 补报名弹窗的搜索框支持「号码布」;版本 dev_0.5.7

## 现场问题(主人原话)
> 成绩录入界面的补报名按钮窗口里面的搜索框只能查姓名和工号,不能查号码布,加一下。

号码布是本系统里**运动员的唯一标识**(定稿口径),现场拿号码布找人是常态,所以这条是刚需。

## 改了什么
- 抽出纯函数 `athleteMatchesKeyword(athlete, keyword)` 作为**选人搜索的唯一口径**:姓名、工号、**号码布** 三维并列;
  关键字归一(`trim()` + `toLowerCase()`)与仓库既有写法逐字一致,空搜索仍不筛人。
- `EntryPicker.vue` **两处**搜索都改成走它:
  - `filteredAthletes`(个人报名名单)
  - `membersByTeam`(团体队员名单)
- 两个搜索框的提示文字从「搜索姓名 / 工号」改成「**搜索姓名 / 工号 / 号码布**」。
- ⚠️ 收口成一个函数的意义:两个框**不会再各走各的** —— 这次就是因为两处各写了一份过滤条件,改一处漏一处。

## 验证
- **先红后绿**:新增 `web/src/admin/tests/athlete-search-bib.test.ts`(号码布能搜到、姓名/工号不回归、大小写与空格处理、空搜索不筛人)。
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 929 例 0 fail / Console 68 例 0 fail。

## 顺带
`debug/使用说明/` 里落后的 4 份(使用手册 / 升级说明 / 报名名单导入规范 / 使用说明.txt)已补刷,与 `docs/` 逐份一致;
`docs/升级说明.md` 新增 `dev_0.5.7` 一节。

## 版本
`dev_0.5.6` → **`dev_0.5.7`**(无数据迁移);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
This commit is contained in:
AskaEth committed 2026-09-14 21:54:05 +08:00
1 parent 99c91f99f7
commit d11cab31db
9 files changed
+133 -23

No files matched your search

+1 -1
View File
@@ -1 +1 @@
dev_0.5.6
dev_0.5.7
Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+1 -1
View File
@@ -1 +1 @@
0.5.6
0.5.7
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scoreboard-console",
"productName": "Scoreboard Console",
"version": "0.5.6",
"version": "0.5.7",
"private": true,
"author": "AskaEth",
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
+21 -1
View File
@@ -6,7 +6,27 @@
---
## dev_0.5.6(当前版本)
## dev_0.5.7(当前版本)
### 一句话
**没有数据迁移,升级后不用重配任何东西**:修好一个小地方 —— 成绩录入页**补报名弹窗里的搜索框**以前只能按**姓名 / 工号**找人,现在也能按**号码布**搜了。号码布是本系统里运动员的唯一标识,现场拿号码布找人是常态。
### ① 改了什么
- **补报名弹窗**里有两个搜索框(一个筛**个人报名**的名单,一个筛**团体报名**的队员名单),原先都只匹配**姓名**与**工号**;现在**同时匹配号码布**。两个框的提示文字也相应改成「搜索姓名 / 工号 / 号码布」。
- 匹配逻辑收口成一个共用函数(姓名、工号、号码布三维并列),所以两个框的口径**不会再各走各的**。
- 关键字处理与原来一致(去掉首尾空格、忽略大小写、空搜索不筛人)。
### ② 升级后核对
1. 打开成绩录入页 → 点「补报名」→ 在搜索框里输入某个运动员的**号码布**,应该能筛出那个人;
2. 在团体报名的「队员」选择里输号码布,同样应该能筛出来;
3. 其余照旧:**不需要重新导入规则包,也不需要重配任何东西**。
---
## dev_0.5.6
### 一句话
@@ -8,6 +8,7 @@ import { submitEntryAthletes } from '../../pages/entryAthletes'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
import {
athleteMatchesKeyword,
buildSelectionRows,
buildTeamSelectionRows,
buildTeamSelections,
@@ -108,15 +109,10 @@ const teamAthletes = computed(() =>
data.athletes.value.filter((athlete) => athlete.active && String(athlete.teamId) === individualTeamId.value),
)
const filteredAthletes = computed(() => {
const keyword = search.value.trim().toLowerCase()
if (keyword === '') return teamAthletes.value
return teamAthletes.value.filter(
(athlete) =>
athlete.name.toLowerCase().includes(keyword) ||
(athlete.employeeNo ?? '').toLowerCase().includes(keyword),
/** 按姓名 / 工号 / 号码布过滤本单位的人:号码布是人员的唯一标识,现场拿号码布找人是常态。 */
const filteredAthletes = computed(() =>
teamAthletes.value.filter((athlete) => athleteMatchesKeyword(athlete, search.value)),
)
})
/** 勾选顺序可能与列表顺序不同,提交前统一按列表顺序取,团体队员名单才稳定。 */
const checkedAthletes = computed(() => teamAthletes.value.filter((athlete) => checked.value.includes(athlete.id)))
@@ -137,19 +133,12 @@ const pickedTeamCount = computed(() => teamSelections.value.length)
/** 「全选」的范围:启用中且还没报名的单位。 */
const selectableTeams = computed(() => selectableTeamIds(data.teams.value, registeredTeams.value))
/** 展开的单位里可勾选的队员;搜索只作用于展开的名单。 */
/** 展开的单位里可勾选的队员;搜索只作用于展开的名单,口径与上面的个人报名名单同一份。 */
const membersByTeam = computed(() => {
const keyword = memberSearch.value.trim().toLowerCase()
const map = new Map<number, Athlete[]>()
for (const athlete of data.athletes.value) {
if (!athlete.active) continue
if (
keyword !== '' &&
!athlete.name.toLowerCase().includes(keyword) &&
!(athlete.employeeNo ?? '').toLowerCase().includes(keyword)
) {
continue
}
if (!athleteMatchesKeyword(athlete, memberSearch.value)) continue
const list = map.get(athlete.teamId)
if (list === undefined) map.set(athlete.teamId, [athlete])
else list.push(athlete)
@@ -543,7 +532,7 @@ async function submitTeam(): Promise<void> {
</select>
<div class="mx-input-wrap bd-search">
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
<input v-model="search" class="mx-input" placeholder="搜索姓名 / 工号" />
<input v-model="search" class="mx-input" placeholder="搜索姓名 / 工号 / 号码布" />
</div>
</div>
<div class="bd-toolbar-right">
@@ -615,7 +604,7 @@ async function submitTeam(): Promise<void> {
<span class="bd-note">已勾选 {{ pickedTeamCount }} 个参赛单位</span>
<div v-if="expandedTeams.length > 0" class="mx-input-wrap bd-search">
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
<input v-model="memberSearch" class="mx-input" placeholder="搜索队员姓名 / 工号" />
<input v-model="memberSearch" class="mx-input" placeholder="搜索队员姓名 / 工号 / 号码布" />
</div>
</div>
<div class="bd-toolbar-right">
@@ -217,6 +217,31 @@ export function selectionAllowsSubmit(mode: SelectionMode, chosenCount: number):
return mode === 'team' || chosenCount > 0
}
/**
* 选人搜索框的匹配口径:**姓名 / 工号 / 号码布**任一命中即算(大小写不敏感、首尾空格忽略)。
*
* 号码布必须在内:它是本系统里人员的唯一标识(先认证件号,没有证件号就看号码布),
* 现场拿着号码布找人是常态,只能按姓名 / 工号搜等于搜不到人。
*
* 工号与号码布都可能为空(老数据两个都没填、老服务端也不下发),一律当空串处理。
* 关键字全为空白 = 没在搜,返回 true(不能把名单筛空)。
*
* 搜索框是文本框,这里只做字符串比较;**不要**把它的取值喂给 `type="number"` 的输入框
* (那种框的 v-model 会被 Vue 转成 number,清空才留 '',字符串方法会在渲染期抛错)。
*/
export function athleteMatchesKeyword(
athlete: { name: string; employeeNo?: string | null; bib?: string | null },
keyword: string,
): boolean {
const needle = keyword.trim().toLowerCase()
if (needle === '') return true
return (
athlete.name.toLowerCase().includes(needle) ||
(athlete.employeeNo ?? '').toLowerCase().includes(needle) ||
(athlete.bib ?? '').toLowerCase().includes(needle)
)
}
/**
* 报名行的展示文案:运动员姓名 → 团体队员首人 → 单位简称 / 全称。
*
@@ -0,0 +1,76 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { athleteMatchesKeyword } from '../components/baseData/entrySelection'
/**
* 现场报的问题:**补报名弹窗里的搜索框只能查姓名和工号,查不了号码布**。
*
* 号码布在本系统里是人员的唯一标识(先认证件号,没有证件号就看号码布),
* 现场拿着号码布找人更是常态,所以补报名的两处搜索(个人报名的名单、团体报名的队员名单)
* 都必须带上这一维。
*
* SFC 跑不进 `node --test`(无 jsdom,见 entryGuards.ts:6),所以分两层钉:
* 1. 匹配口径本身抽成 entrySelection.ts 里的纯函数,在这里逐条测;
* 2. 组件里的两处接线用源码契约断言(两处必须都走同一个函数,不许各写一份)。
*/
const PICKER = readFileSync(new URL('../components/baseData/EntryPicker.vue', import.meta.url), 'utf8')
/** 一名可以按 姓名 / 工号 / 号码布 三路搜到的人。 */
const athlete = { name: '张伟', employeeNo: 'E11', bib: 'A001' }
test('号码布能搜到:号码布是唯一标识,现场拿号码布找人是常态', () => {
assert.equal(athleteMatchesKeyword(athlete, 'A001'), true)
// 号码布常常是纯数字,同样要能按前缀 / 片段搜到。
assert.equal(athleteMatchesKeyword({ name: '李强', employeeNo: null, bib: '1234' }, '12'), true)
})
test('姓名与工号照旧能搜到:这一维只加不减', () => {
assert.equal(athleteMatchesKeyword(athlete, '张'), true)
assert.equal(athleteMatchesKeyword(athlete, '张伟'), true)
assert.equal(athleteMatchesKeyword(athlete, 'E11'), true)
assert.equal(athleteMatchesKeyword(athlete, 'e11'), true, '工号大小写不敏感')
})
test('大小写与首尾空格的处理与既有搜索框一致:两边都 trim + toLowerCase', () => {
assert.equal(athleteMatchesKeyword(athlete, 'a001'), true, '号码布大小写不敏感')
assert.equal(athleteMatchesKeyword(athlete, ' A001 '), true, '首尾空格先去掉')
assert.equal(athleteMatchesKeyword({ name: 'Zhang Wei', employeeNo: null, bib: null }, ' zhang '), true)
// 全是空白的输入框等于没搜:不能把名单筛空。
assert.equal(athleteMatchesKeyword(athlete, ''), true)
assert.equal(athleteMatchesKeyword(athlete, ' '), true)
})
test('搜不到的关键字照旧返回 false(号码布不误伤别的行)', () => {
assert.equal(athleteMatchesKeyword(athlete, 'A002'), false)
assert.equal(athleteMatchesKeyword(athlete, '王芳'), false)
})
test('工号 / 号码布为空的人不会抛错(老数据可能两个都没有)', () => {
const bare = { name: '无标识', employeeNo: null, bib: null }
assert.equal(athleteMatchesKeyword(bare, '无'), true)
assert.equal(athleteMatchesKeyword(bare, '101'), false)
// 缺字段(老服务端不下发)同样当空串,不抛。
assert.equal(athleteMatchesKeyword({ name: '王芳' }, '101'), false)
})
test('补报名两处搜索框都接上号码布:个人报名名单 + 团体队员名单', () => {
// 口径只有一份实现:两处都调它,避免以后只改一处、两处慢慢分叉。
const calls = [...PICKER.matchAll(/athleteMatchesKeyword\(/g)].length
assert.equal(calls, 2, '个人报名的 filteredAthletes 与团体报名的 membersByTeam 各一处')
assert.match(PICKER, /import \{[\s\S]*?athleteMatchesKeyword[\s\S]*?\} from '\.\/entrySelection'/)
// 旧的「姓名 / 工号」两维写法不许再留在组件里。
assert.doesNotMatch(PICKER, /\(athlete\.employeeNo \?\? ''\)\.toLowerCase\(\)\.includes\(keyword\)/)
})
test('两处搜索框的提示文案都写上了号码布', () => {
// 用 includes 而不是 assert.match:失败时不要把那 800 行源码原样打出来。
assert.ok(PICKER.includes('placeholder="搜索姓名 / 工号 / 号码布"'), '个人报名的搜索框要写清能按号码布搜')
assert.ok(PICKER.includes('placeholder="搜索队员姓名 / 工号 / 号码布"'), '团体队员的搜索框同理')
})
test('号码布匹配不下沉到 v-model 上:搜索框是文本框,不进 type="number" 的坑', () => {
// 组件里不许出现「把输入值当数字用」的写法(.trim()/字符串方法会在渲染期抛错)。
assert.doesNotMatch(PICKER, /type="number"[\s\S]{0,120}v-model="(search|memberSearch)"/)
})