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 18825d2..a097737 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 cc822bb..bc3d6de 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 b49b253..d3532a1 100644 --- a/console/installer/art/version.txt +++ b/console/installer/art/version.txt @@ -1 +1 @@ -0.5.6 +0.5.7 diff --git a/console/package.json b/console/package.json index 16178aa..03cf6d2 100644 --- a/console/package.json +++ b/console/package.json @@ -1,7 +1,7 @@ { "name": "scoreboard-console", "productName": "Scoreboard Console", - "version": "0.5.6", + "version": "0.5.7", "private": true, "author": "AskaEth", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", diff --git a/docs/升级说明.md b/docs/升级说明.md index ec2632e..1186194 100644 --- a/docs/升级说明.md +++ b/docs/升级说明.md @@ -6,7 +6,27 @@ --- -## dev_0.5.6(当前版本) +## dev_0.5.7(当前版本) + +### 一句话 + +**没有数据迁移,升级后不用重配任何东西**:修好一个小地方 —— 成绩录入页**补报名弹窗里的搜索框**以前只能按**姓名 / 工号**找人,现在也能按**号码布**搜了。号码布是本系统里运动员的唯一标识,现场拿号码布找人是常态。 + +### ① 改了什么 + +- **补报名弹窗**里有两个搜索框(一个筛**个人报名**的名单,一个筛**团体报名**的队员名单),原先都只匹配**姓名**与**工号**;现在**同时匹配号码布**。两个框的提示文字也相应改成「搜索姓名 / 工号 / 号码布」。 +- 匹配逻辑收口成一个共用函数(姓名、工号、号码布三维并列),所以两个框的口径**不会再各走各的**。 +- 关键字处理与原来一致(去掉首尾空格、忽略大小写、空搜索不筛人)。 + +### ② 升级后核对 + +1. 打开成绩录入页 → 点「补报名」→ 在搜索框里输入某个运动员的**号码布**,应该能筛出那个人; +2. 在团体报名的「队员」选择里输号码布,同样应该能筛出来; +3. 其余照旧:**不需要重新导入规则包,也不需要重配任何东西**。 + +--- + +## dev_0.5.6 ### 一句话 diff --git a/web/src/admin/components/baseData/EntryPicker.vue b/web/src/admin/components/baseData/EntryPicker.vue index fcb9fcd..207e9d2 100644 --- a/web/src/admin/components/baseData/EntryPicker.vue +++ b/web/src/admin/components/baseData/EntryPicker.vue @@ -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() 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)"/) +})