From d11cab31db57a83a45f0d8be447d06d75a6d3421 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 14 Sep 2026 21:54:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(entry):=20=E8=A1=A5=E6=8A=A5=E5=90=8D?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E7=9A=84=E6=90=9C=E7=B4=A2=E6=A1=86=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E3=80=8C=E5=8F=B7=E7=A0=81=E5=B8=83=E3=80=8D=EF=BC=9B?= =?UTF-8?q?=E7=89=88=E6=9C=AC=20dev=5F0.5.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现场问题(主人原话) > 成绩录入界面的补报名按钮窗口里面的搜索框只能查姓名和工号,不能查号码布,加一下。 号码布是本系统里**运动员的唯一标识**(定稿口径),现场拿号码布找人是常态,所以这条是刚需。 ## 改了什么 - 抽出纯函数 `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` 两张)。 --- 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 | 22 ++++- .../admin/components/baseData/EntryPicker.vue | 29 +++---- .../components/baseData/entrySelection.ts | 25 ++++++ .../admin/tests/athlete-search-bib.test.ts | 76 ++++++++++++++++++ 9 files changed, 134 insertions(+), 24 deletions(-) create mode 100644 web/src/admin/tests/athlete-search-bib.test.ts diff --git a/VERSION b/VERSION index 9be3726..3dd72cf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.5.6 +dev_0.5.7 diff --git a/console/installer/art/1x/brand.bmp b/console/installer/art/1x/brand.bmp index 18825d27cd5fe59ea27afb62d8c4b1f8df440c40..a0977370954a72c1854304e0cd1994c8ed153cb4 100644 GIT binary patch delta 387 zcmdnCT6o)P;SJI`o2?nmnI;G2P3H8~;`ho?P2SCpAfx&# zCkT0}^Z02Cc&YP5m`VZVg7if;KkNm{#n((01=;~bK+C2UMz>j-RK-lWYm z*{FU3NE)csRf)YZ-laOy)=ilm=%UT;j6i9ord|mjux_9tPc`l+a~Yr+{yISO_<#oN zV3LN&0yPBa3VEn-*2g&3#W;AVa)NAF)uh2Rc~N_pqMweSw) delta 440 zcmdnCT6o)P;SJI`Y<}7T{yGAi9T^RoCI{tB<^l43wfNmtIK9-lH%~0lW11{qI9bR? zlh0d&7butMrX6pi0FrZI)L@$Yrg)l!s}g&VzR1M(rX$ZY#wQX(m0dIw90Z%vX!ge{**E7A83_bsleZp2>w_F;?=P zYTQ1Wyg-u@?UaBPflON4q{1{gv1X#ApBDe@%DAd<8#iT+BnOrGwMoGSB4A@yH>opC zHmaW>0d(Y)!myS^H=uK-mqz9L8p53ebTCs>uY|9*fQKp<(7}mz%6{5{A%>zJDja^= z{F|3I?O+1w2O19a#e}?ICwUg2=5SL|$pRH|t(NQYXg79S3ch3XrPvc&gQk|PLOKNADl%iB!{DsbQHIhdRtoH*x()ijso zza;^VefUE3+sPoDtuwuzygJnza2IE83WMAC72L!;k;-x)>Mm9UEAn34?s*aoH^o~s9NDMtC|n!o{e|U#v_@X3&E4 zz4+2tdp*W+-cf>=7)BT2SefEKiqPE4JH6AF`5}muH4P{U)q9zAANmyoyTE+MLUr&Z z3jR!yE{^r@rg~z;60m#>71@=}V8i5WtmElO2L|W0DA`7ecc9Zl-WWbw&{<>XuoogD zx@wJ~%KYf5Dr{sta@<`kM-BUvA|Z|~7NTn##QZkx^P3lz;=>CM`Z2++cy<5E{JlOL zMydq6Xlf@*a7h05+DQE2}l7MAO|u5 U1)u~}fEvgGG=P?sS7uZFKR#Cw=Kufz delta 1087 zcmX|ANla5w6wUjTB5i?|iV(3#fzFgNDFQ+TK`5mIg(6T&nL!2t87vBdpd$0A7>C5D zQB)kTz~8ZA;R<71xpL!5H<}2VxbggcV)9S({_8#Wo_F5)o2#bHRnzb~$=F^#wN1F( zLRFwi6SVxnj|%ZrQ3V`& zp{s-3rRfhYS3kSn^6*OCkTa`DCoWV8EU`k5ohs$f-#-x*k+PMu?Y8wr_g%MhwC2pM z?y~iTi_5(gHk}x)+2YC1=&Z2BiZFYiJac2wJ?zXj$oY89llc!i^w=4#&Naw*O#d>f zm_uv6kZ7WX7^5{WZ zB8*DL#C_)4E|>}w!3?+vQScA6+$cZnoR0LZ)km*Rbl~U=XLpbE`>NUMg--j*jn+GT zj>lIUdu=K8S@Is+ajf6X^>&&P_vQWBBbA(uU({jm45*uKwZ5L}s!WZ*8AKopj^;4o3ZDMnm5@S-V4l@;Mr93N7vk8x#4ZMtC0pe;X1J%>&b zP~jwkb*{^tgv;zNO~*9&jbDR;k05x%NR!{h%Ar4sC6pQ%=NY# 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() 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 {
@@ -615,7 +604,7 @@ async function submitTeam(): Promise { 已勾选 {{ pickedTeamCount }} 个参赛单位
diff --git a/web/src/admin/components/baseData/entrySelection.ts b/web/src/admin/components/baseData/entrySelection.ts index 36be417..86e48c7 100644 --- a/web/src/admin/components/baseData/entrySelection.ts +++ b/web/src/admin/components/baseData/entrySelection.ts @@ -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) + ) +} + /** * 报名行的展示文案:运动员姓名 → 团体队员首人 → 单位简称 / 全称。 * diff --git a/web/src/admin/tests/athlete-search-bib.test.ts b/web/src/admin/tests/athlete-search-bib.test.ts new file mode 100644 index 0000000..2003cbd --- /dev/null +++ b/web/src/admin/tests/athlete-search-bib.test.ts @@ -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)"/) +})