Commit Graph
15 Commits
Author SHA1 Message Date
AskaEth c5ce1878fc feat(publish): 「以决赛成绩发布」开关 —— 非决赛发布只更新名次、不计分;版本 dev_0.5.8
## 需求(主人原话)
> 只有在最后一轮(决赛)的成绩才算分,你给发布按钮边上带一个选项,勾选之后**当前小项/组以决赛成绩发布**,
> 不然的话就**只更新名次(非决赛) 不加分**。

**设计好处**:不引入「轮次」模型,而是**在已有的「发布」动作上加一个口径** —— 分轮次比赛时,同一批人各轮成绩各发各的,
榜单只认决赛那一轮的分。引擎本来就有一条「只排名不计分」的通路,这次是**把它在发布入口露出来**,没有另造算分分支。

## 契约
`POST /api/results/publish` 请求体新增 **`rankOnly: boolean`**:
- **缺省 / `false`** = 计分发布,**与改动前逐字节一致**(老客户端不带这个字段走现状路径);
- **`true`** = 本次只更新名次。

**只更新名次时写**:`results.state/verified_by/verified_at`(照旧)、`results.rank`(预赛排名次是刚需)、
整小项录完时的 `events.status`、`result_revisions`(message 写明本次口径)、`change_log(entity=result)`(大屏据此重拉名次)。
**不写**:`score_ledger`(**既不加,也不作废任何已有流水**)、`channel_ranks`(不重建也不删除)、榜单快照版本。

⇒ 于是会出现预期的「**名次变了、分还是旧的**」(本组上次按决赛发布过时)—— 确认框与录入页常驻提示都点名说了这件事。

## 界面
- 录入页「发布」按钮**左边**:勾选框「**以决赛成绩发布(勾选才计分发牌)**」,**默认不勾**;
  旁边徽标随状态切「**只更新名次(不计分)**」(警示色)/「**计分发布(算分发牌)**」(绿色)。
- 确认框:标题切「确认按决赛成绩发布该组?」/「确认发布该组(只更新名次)?」;不勾时不再报「影响 N 个单位、最大分差 X 分」;
  已有在榜分时加一句「本组已有 X 分在榜…那份积分与奖牌会原样保留、不随名次变动」。
- **常驻「在榜口径」提示**:系统不记「上次勾了什么」,判据用**榜面事实**(已发布成绩的流水合计)——
  「本组在榜上只有名次(上次是按只更新名次发布的)」/「本组在榜上是已计分的成绩(已有 X 分来自上一次按决赛发布)」。
- **发布成功后勾选复位**:勾选是「这一次发布」的口径,不是页面设置(免得换小项时把预赛当决赛算分)。
- ⚠️ **审核页是第二个发布入口**,同样加了口径 —— 否则从审核页发布预赛会被**悄悄算成分**。

## 兼容性(回答「能不能兼容现有数据包」)
- **没有任何数据库变更、没有迁移**:纯行为改动,多带一个请求字段。
- 旧客户端 / 旧数据包 / 旧库照旧工作;不带该字段时与改动前**逐字节一致**(用改动前采集的整份写库 golden 对拍证明)。
- 反向(用新版导出的项目包去旧版本导入)仍是不支持的,与之前几版一致。

## 验证
- **先红后绿**:改动前跑新用例,红在「只更新名次的发布动了已有流水」(贴出 `score_ledger` 前后对比);
- 另有 `rank_only_heal_test.go` 钉住「只更新名次发布过的库,**启动自愈不许把它算成分**」;
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **944 例** 0 fail / Console 68 例 0 fail。

## 文档
`docs/升级说明.md` 新增 `dev_0.5.8` 一节。

## 版本
`dev_0.5.7` → **`dev_0.5.8`**(无数据迁移);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 22:57:03 +08:00
AskaEth d11cab31db 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` 两张)。
2026-09-14 21:54:05 +08:00
AskaEth 99c91f99f7 feat(entry): 团体报名「队员身份」—— 一条报名挂多个运动员,标识用号码布;版本 dev_0.5.6
## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」

**现状问题**:界面(`EntryPicker`)**本来就能勾多个运动员**,但提交时只把姓名拼成文字
(`entrySelection.ts` 的 `input.athletes.map(a => a.name)` 存进 `entries.member_names TEXT`)→ **身份全丢**
→ 系统不知道队里坐着谁 → **所有团体/对抗项目都给不出个人分、个人奖牌,上不了个人积分榜**。

## 口径
- **运动员的唯一标识是号码布**(主人明确更正,不是证件号)。
- **每个队员各拿一份完整的个人分** —— 是「**复制**」不是「拆」(与联队 `splitSharePoints` 正好相反)。
- **个人奖牌每人各一枚**;**单位总分与单位奖牌不受影响**(由通道开关 `count_to_team` / `count_to_team_medals` 控制)。

## 一、数据
- 迁移 `0026_entry_athletes.sql`:新表 `entry_athletes(entry_id, athlete_id, sort_order)`,PK `(entry_id, athlete_id)`,两个外键都 `ON DELETE CASCADE`。
  `entries` **一个字节没动**(`member_names` 保留为显示用名单;多队唯一键 `(category_id,team_id,squad) WHERE athlete_id IS NULL` 未触碰)。
- **老数据回填(尽力而为)**:拿 `member_names` 去 `athletes` 匹配 —— **号码布优先**,其次「姓名 + 单位」且同单位同名唯一;**匹配不上留空**,不硬猜。

## 二、引擎
- `RankedResult.MemberCopy`(报名项那份 `false`、队员副本 `true`);**只在 `scope=athlete` 通道展开**(`scope=team` 与 `basis=aggregate` 不展开)。
- **单位维度不重复计算的三道锁**(本单最大的坑):
  1. 队员副本流水 `count_to_team` **恒为 false** —— 单位总分那条 SQL 第一句就跳过它,而个人榜不看这个开关;
  2. 单位奖牌仍按**报名项**去重、且报名项那份**排在最前**;
  3. 队员副本**照抄 `TeamID` 与 `Shares`**(不换单位、不另造份额 —— 联队报名尤其危险)。
  反面也测了:个人奖牌若按报名项去重会**一枚都发不出来**(被 `AthleteID=0` 那份吃掉),故改为按「报名项 + 运动员」去重。

## 三、写 / 读
- `PUT /api/entries/{id}/athletes`(recorder+、CSRF):body `{"athletes":[{"athleteId":12}],"requestId":"..."}`,**只认 athleteId**;空数组=清空;
  改完当场重算推榜。400:athleteId 缺/不存在、同一人两次、>30 人、个人报名不能挂队员。
- 读:`entryDTO.athletes: [{athleteId, name, bib}]`(**始终是数组**);`memberNames` 原样保留,**两者可并存**(没档案的人只能留文字)。

## 四、导入
- 报名表「队员名单」列现在**按号码布认人**(命中即关联,同时保留文字);⚠️ **姓名完全不做匹配**(同名会认错,而认错的分已经上了榜);匹配不上**不报错、只当文字**。
- 边界逐条有 notice/报错:跨单位号码布**照关联 + notice**(号码布是唯一标识);同格重号只关联一次 + notice;**个人行**写了号码布只留文字 + notice;**认出 >30 人整行拦下**;成功时一条汇总 notice。
- **更新路必须重算**:老表先导入、成绩已发布,之后补上号码布重传 —— 不在更新路重算就会出现「名单里有队员、榜上没有他」(删掉该条件即红)。

## 五、界面与显示
- 补报名勾的人**真正提交**(导入成功后追加 `PUT /athletes`,**只发需要变的**:勾了人的单位、以及原来有队员这次一个没勾的(发 `[]`);一个都没勾且原来也没队员的一律不发);**队员写失败单独提示**(报名本身已加入,不冒充「加入报名失败」)。
- **改队员**:录入页「运动员/队」那格的**「队员」小按钮**(只有纯团体行才有),弹窗勾选式名单;**与「成员单位与份额」是两个独立写请求**(份额管「分给哪些单位」、队员管「这条报名里有哪几个人」)。
- 显示统一写法 **`姓名(号码布)`**(导出件;大屏只写姓名与 ` / ` 分隔 —— 号码布太长会把整屏字号压下去);**没有队员关联的老数据一个字不变**;
  ⚠️ **一条报名仍只占一行**(有护栏测试钉着);`squad`(队)与 `athletes`(队员)**正交**。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **921 例** 0 fail / Console 68 例 0 fail。
- 各块先红后绿并留原始输出;**老数据零变化**有 golden 对拍(引擎层 / 接口层 / 导出层逐行 dump)。

## 文档
`docs/报名名单导入规范.md`(队员列语义升级,列数仍是 13)、`docs/使用手册.md`(新增 4.13 团体报名的队员)、
`docs/升级说明.md`(新增 `dev_0.5.6` 一节)。

## 版本
`dev_0.5.5` → **`dev_0.5.6`**(含数据迁移 `0026`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 14:05:48 +08:00
AskaEth 7a3e4ad365 feat(entry): 团体项目多队(迁移 0025)+ 修联队团体名次 / 大屏分项成绩 / 预览算分口径;版本 dev_0.5.5
## 一、新能力:团体项目多队
现场:羽毛球双打,一个单位可能派两个队,一个单位两个队各占一个名次。
- 迁移 `0025_entry_squad.sql`:`entries` 加 `squad TEXT NOT NULL DEFAULT ''`(自由文本队名,空串=不区分队伍);
  唯一索引由 `(category_id, team_id) WHERE athlete_id IS NULL` 换成 `(category_id, team_id, squad) WHERE athlete_id IS NULL`
  —— 原来那条正是「一个单位在一个组别只能报一条团体报名」的拦路虎。
- 导入:**第 13 列「队伍」**(`squad` / 队伍 / 队名 / 队伍名,可选,放最后);老 12 列表照常导入。
- 显示:`单位名 + 队名`(不加括号),榜单不写队名(分与牌本来就归单位)。
- 新增「改队名」:`PUT /api/entries/{id}/squad` + 「成员单位与份额」弹窗里的队伍输入框(只对纯团体报名显示)。
  改队名**不需要重算**(`grep squad server/internal/domain/` = 0 处,改名前后流水/名次/榜单逐字节相同)。
- **老数据零变化**:接口层 JSON 剥掉新字段后逐字节相同;导出层 9 份导出件全单元格 750 行 dump 的 md5 相同。

## 二、修:团体名次控件没体现联队
联队的成员单位在「团体名次」里各占一行 → **合并成一行**(标签用大屏/导出一致的联队名),名次**只落主单位**;
成员单位不再进保存载荷,顺手堵掉「看 A、B 各一行,给 B 也录一个名次 → B 重复拿分」。
被并掉的成员单位原来若录过名次,区块里点名提示「已并入联队那一行,保存后清除」,不静默吞。

## 三、修:大屏「分项成绩」排名错乱
现场:第 3 栏列出的项目排名跳着显示(3、6、9、14、15、16),羽毛球单打第 1 名不显示。
**根因**:`group.rows.slice(0, 6)` 取的是**数据到达顺序**,整条渲染链**没有一处读 `rank`**。
修法:按名次升序(并列保持原先后、无名次排最后)再截断 —— 与同页「实时赛况」的既有口径一致。
复现证据:修前 `actual: [3,6,9,14,15,16]`(与现场数字一致)、`expected: [1,2,3,4,5,6]`。
顺带澄清:「只有第 3 栏出问题」不是栏的问题,是那一栏分到的数据次序不成立。

## 四、修:录入页预览算分与后端口径不一致(四处 + 一条延伸)
现场:手动录名次的项目(台球)「积分(自动)」列全是 0,而后端**有分**。
1. **预览不读 `channel_manual_ranks`、也不按通道 scope 分名次来源** → 「按单位录名次」的项目预览全 0,反过来名次填在成绩表时预览有分而服务端 0;
2. **末位归零判据不同**:预览原本「区间跨末位就整组归零」,与后端的 `startRank == lastPlace` 不符(并列第 1 时预览 0、服务端 10);
3. **`compare=none` 时预览静默降级成 manual**(后端此时直接报错)→ 改为同口径:显示**原因**而不是一个算不出来的数字;
4. **管理端保存计分通道缺守卫**:`compare=none + basis=result/aggregate` 原先只有规则包导入拦,手工能配出来、到发布才报错 → 保存前 400 + 人话。
延伸:预览的「已发布分」基线**只扣行分**,不挂报名项的分(团体名次那类)没扣 → 发布确认框「最大分差」虚高;新增 `unitPoints` 一并扣。

## 五、修:手录名次项目的「成绩」列空白
`displayResult()` 在 `resultValue`/`resultText` 都拿不到时**漏出空值**(且函数声明返回 `string`)→
新增占位 `—`、类型收紧,实时赛况与底栏对齐同一口径。

## 文档
`docs/报名名单导入规范.md`(12 → 13 列)、`docs/使用手册.md`(多队章节)、
`docs/升级说明.md`(新增 `dev_0.5.5` 一节)、`debug/使用说明/` 七份同步。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **883 例** 0 fail / Console 68 例 0 fail。
- 各块都先写会红的测试、留了原始输出(含现场报的那组数字与「预览全 0」)。

## 版本
`dev_0.5.4` → **`dev_0.5.5`**(含数据迁移 `0025`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。

## 下一批
`dev_0.5.6` = **团体报名队员身份**(把队员从文字名单变成人员关联,标识用**号码布**),工单见 `devdocs/20-团体报名队员身份.md`。
2026-09-14 11:45:32 +08:00
AskaEth 9663d5bfb0 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 两张变化)。
2026-09-14 09:57:02 +08:00
AskaEth 830a4dbdb7 feat(joint-team): 联队 + 份额分成 —— 迁移 0024、引擎按份额拆、导出 / 大屏 / 录入三路打通;版本 dev_0.5.3
## 需求
现场 6 项球类比赛里,有两个单位因参赛人数不足**合并成一个单位参赛**。
做法不是新建「联队单位」(那样榜单会多出第 8 个单位,且分和奖牌分不到原单位头上),
而是**一条报名挂多个单位、按份额拆**。

## 拍板口径
- 份额**可配、默认均分**
- 积分**平均拆、接受 .5**(团体第 1 名 15 分 → 两个单位各 7.5 分)
- 奖牌**各记一枚**(两个单位各 1 枚完整金牌,不是各 0.5 枚)
- 显示上把联队名字写全(形如 `第一分公司+第二分公司联队`),成员单位的 logo 都放
- 导入表里单位编码写 `A+B`

## 一、核心层(数据模型 + 引擎 + 存储 + 报名写入 + 导入解析)
- 迁移 `0024_entry_team_shares.sql`:新建 `entry_team_shares(entry_id, team_id, points_share,
  medal_share, sort_order)`。**两个口径分开表达**是刻意的——积分要拆成小数(7.5)、奖牌要整枚
  各记,用一个数表达就会把「7.5 分」和「半枚金牌」绑在一起,奖牌榜立刻错。
  `entries.team_id` 保留为**主单位**;老数据 `INSERT OR IGNORE` 回填成「主单位独占 100%」。
- 引擎:`ResolveShares`(引擎与读侧共用)、`splitSharePoints`(按权重归一、**末位吞余数**,
  各份之和恒等于原来那一份)、`TeamShare.Medal`(缺省整枚)。积分按权重拆,奖牌每个份额各记整枚。
- 报名写入:`PUT /api/entries/{id}/team-shares`(recorder 及以上,保存后当场重算该小项组别并推榜)。
- 导入:`team_code` 支持 `A+B`(主单位 = 加号左边,均分,奖牌各整枚);非法形态
  (`A+` / `+B` / `A++B` / `A+A` / 成员 >5 / 单位不存在)**行级拦下、不落库**,给人话报错。

## 二、导出与导入模板
- 11 份导出件:联队写全成员单位(`第一分公司+第二分公司联队`),并注明**分拆给了哪几家、各多少**
  (份额占比 + 各单位实得积分,取自按份额拆完的流水)。
- 单位奖牌明细:奖牌格写「金牌(联队 …,各单位各记 1 枚)」;含联队时副标题说明
  「各单位奖牌数之和会大于实际颁发数」——这是口径 3 的必然结果,**不是 bug**。
- 按单位筛选导出把成员单位一起算上(否则 B 单位导自己的名单会看不到自己参加的那场联队比赛)。
- 分工作表模板:`A+B` 写法挂在「单位编码」表头的**单元格批注 + 输入提示**(中英各一份);
  **表头文字、列序、12 列、工作表命名一个字未动**(动了模板就导不回去)。

## 三、前端与大屏
- 录入页 / 复核页「单位」列:联队显示 `联队 第一分公司 67%(权重 2) 第二分公司 33%`,
  点该格打开「成员单位与份额」弹窗(加/删成员、改权重、主单位徽标、实时比例预览)。
  **普通报名(单单位)的文案、元素、类名一字未动**,只多了一个 `@click` 入口与悬停提示。
- 大屏:分项成绩 / 实时赛况单位列显示联队全名并**放上两个单位的 logo**(单位列宽按该行的
  logo 数留位);底栏同理。

## 四、验证(都留了证据)
- **老数据逐字节不变**(改算分引擎的硬门槛):引擎层 golden `a2aa1c97…`、接口层 golden
  `aa6cea37…`,两个哈希由主会话**在修前树上亲手重跑确认**;导出层另有 11 份导出件逐行对拍,
  差异 **0 处**。
- 前端 773 → **809 例**全绿(旧用例一行未改);服务端 14 包两轮全绿;Console 68 例全绿。
- 真机联调:录入页份额弹窗、大屏两个 logo、奖牌榜 A/B 各 1 金、写路径(移除成员 → 保存 → 200)。

## 五、版本
`dev_0.5.2` → **`dev_0.5.3`**(本批含数据迁移 0024 + 新功能,必须与上一包区分开);
安装器素材重生成(版本号烙在位图里,实测只有 `brand.bmp` 的 1x/2x 两张变化,其余 92 张逐字节未变)。
2026-09-14 03:52:34 +08:00
AskaEth b631418a85 chore(release): 版本号 dev_0.5.1 → dev_0.5.2(安装器素材与版本落点同步)
本批代码内容就是 1b8a37f(分工作表报名模板 + 号码布判重 + 备份原子落盘),
这里只把它作为一个**能和上一批区分开**的版本发出去——上一批与它同名同版本号,
`debug/` 里会被同名覆盖,现场两份包分不出来。

- `VERSION` 是唯一真相:`dev_0.5.1` → `dev_0.5.2`
- 安装器素材重生成(`console/installer/art-gen/gen_art.py`):版本号是**烙在位图里**的,
  所以必须重跑。实测**只有 `brand.bmp` 的 1x / 2x 两张变化,其余 92 张逐字节未变**;
  `art/version.txt` 同步成 `0.5.2`(构建期 `prepare-extra.mjs` 拿它和 `VERSION` 核对,对不上直接构建失败)
- `console/package.json` 同步(构建期会按 `VERSION` 重写,这里是让仓库自洽,免得构建机 pull 撞冲突)
- `docs/升级说明.md` 新增 **dev_0.5.2** 一节(① 分工作表模板 ② 号码布判重 ③ 备份原子落盘
  ④ 升级后 5 分钟核对),并把「(当前版本)」标记从 0.5.1 节移到新节;
  这一份是正源,`console/extra/升级说明.txt` 由 `prepare-extra.mjs` 从它生成(该目录 gitignore)

本地跑过一次 `node deploy/prepare-extra.mjs` 预检:`installer art version matches (0.5.2)` /
`version dev_0.5.2 -> deploy/version.gen.bat` / `console/package.json version is 0.5.2`,退出码 0。
2026-09-14 02:40:56 +08:00
AskaEth 1b8a37ff8c feat(import): 报名模板按「小项 × 组别」分工作表;号码布判重改认证件号;备份改原子落盘
三件事合成一次提交(其中两件都落在 handlers_config.go,无法按文件拆开):

一、分工作表报名模板(新增能力)
- 新增 GET /api/entries/template.xlsx:按当前赛事的「小项 × 组别」生成模板,
  一个小项组别一张工作表,工作表名 = <小项>@<组别>(31 字符上限、重名自动去重)。
- 导入解析器认工作表名还原小项 + 组别;若与表内列冲突则报错,不静默取其一。
- 小项名与组别名禁用 @,基础数据界面 / 规则包导入 / 服务端校验三处一并拦截。
- 单表 CSV/XLSX 导入路径不回归。

二、号码布判重(修误判)
- 旧逻辑依赖证件号本身,而证件号是可选字段 → 同一个人报多个项目会被整块拦下。
- 改为:号码布相同且证件号相同 → 判定为同一人报多项,放行;
  号码布相同且证件号都能填且不同 → 仍报错(真抢号);一方证件号为空 → 放行并给提示。
- 团体行的号码布一并纳入查重。

三、自动备份的关停与落盘(修数据安全隐患)
- export.Service 增加 Wait():StartAuto 的协程进 WaitGroup,关停时 join(10 秒上限,
  超时只记 WARN 不拖死关闭),不再在备份写到一半时退出。
- vacuumInto 改为先写 <最终名>.tmp-<随机> 再 rename,失败则删临时文件;
  Backup / SafetySnapshot / ExportProjectZip 三个调用方一并受益。
- 新增 isFinalBackupName,ListBackups / safetyCopies / KindOfBackupName 统一过滤中间文件,
  避免半截 .db 被列进备份列表、进而被「一键恢复」选中。

文档:docs/ 五份(使用手册 / 报名名单导入规范 / 升级说明 / 部署指南 / 上线检查清单)同步。
2026-09-14 01:10:16 +08:00
AskaEth 877ff15823 feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项
## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修)
- 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同**
  (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致
- 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到
- 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢);
  另加守卫「compare=none 的小项不得配 basis=result/aggregate」
- 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0
  (7 人三人并列第 5 时第 5、6 名的分也一起没了)
- 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分

## 现场反馈(主人实测报的)
- 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组),
  与「作废」语义分开;取消路径不产生任何副作用
- 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」:
  管理端、大屏覆盖层、xlsx 备注列三处同源
- 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦
- 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响)

## 新功能
- 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切)

## 安装器
- 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、
  安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540
- 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败

自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。
端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
2026-09-13 15:20:18 +08:00
AskaEth 78b14efaa9 feat(installer): 「为所有用户安装」那趟自动放行防火墙入站 19540(装时加 / 卸时回收)
- deploy/prepare-extra.mjs 生成的安装器脚本新增两个 !define(规则名 / 端口)与两段逻辑:
  customInstall 里当 $installMode == "all" 且当前是提权进程时,先删后加一条入站放行规则;
  customUnInstall 里只要是管理员就回收同一条规则。失败一律忽略,绝不因此让安装/卸载失败。
- 订正 docs 里「安装包按用户安装、不申请提权、改不了防火墙」这句错话(部署指南 7.5/7.9、
  上线检查清单、常见问题 Q101、升级说明):选「为所有用户安装」会 UAC 提权,那一趟就把规则加好了;
  「仅为我安装」才需要现场手工放行。手工规则名与自动规则名不同,卸载只删自动那条。
- .gitignore 注释补上「防火墙入站规则」。

验证:四套自检全绿(服务端 13 包 / 前端 vue-tsc + 680 例 + build / Console 20 文件 + 68 例);
生成器重跑幂等(.nsh 哈希 9edcdc31… 不变);本机 wine 用 makensis v3.04 编译含该 .nsh 的脚本 0 error。
未验证(需真机):提权安装后防火墙里真的出现该规则、卸载真的删掉、失败分支真机表现。
2026-09-12 19:42:23 +08:00
AskaEth c97d3150c9 fix(admin): 局域网大屏卡片按「地址类别」判定 + 三层兜底 + 手填入口(dev_0.5.0)
## 判断失误的修正(主人现场实测出来的)
主人现场是 **169.254 自动配置网段**(没有 DHCP,设备靠链路本地地址互访),
平板实测能访问 `http://169.254.79.190:19540/display`;而卡片一开始把链路本地地址
**排除**了,于是显示「未检测到局域网地址」。
根因:`usableLanIPv4` 排除了 `IsLinkLocalUnicast()`,理由写的是「169.254 说明没拿到 IP」
——那是**对网络健不健康的猜测**,不是**地址能不能用的事实**。

现改为按**地址类别**判定(`ipv4Scope`,全部用标准库谓词,**代码里没有任何网段字面量**):
只排除「作为对端访问目标没有意义」的四类(回环 / 未指定 / 组播 / 广播),
链路本地与私网、全局一样进候选,只**排在最后**并带说明
「自动配置地址:本网段没有 DHCP,同网段设备可以互相访问」。
新增**反硬编码护栏用例**:函数体内不得出现 `x.x.x.x` 字面量,且必须用满 6 个谓词。
顺带修掉新用例抓出的真 bug:`ipv4Scope(nil)` 曾落进 default 被当成「可用的 global」,
而 IPv6 的 `To4()` 正是 nil → 「只有 IPv6 的网卡」会被误判成有可用地址。

## 三层兜底(离线现场必须拿得到地址)
1. **严格轮**:接口 up 且非回环;
2. **宽松轮**:严格轮为空时不再要求 up 位(某些驱动/虚拟网卡读不到),命中标 `confidence: low`;
3. **默认路由探测**:两轮皆空时用 `net.Dial("udp4", …)` 问出本机源地址
   (UDP connect 只查路由表、不握手不发包,离线安全;TCP 会等 SYN 超时,不能用),标 `confidence: route`。
接口同时回 `scope`(private/global/link-local)与 `skipped`(被跳过的网卡 + 原因,
code 收敛为 no-address / only-ipv6 / not-up / loopback——link-local 不再是「被跳过」的理由)。

## 手填入口(最后一道保险)
自动检测不到、或不是现场要用的网段时,操作员把 `ipconfig` 里的地址填进卡片,
立刻生成链接 + 二维码;排在第一条、带「手动」徽标、可删除、localStorage 持久化、
四条就地校验文案(空/含空格/不像 IP/重复),IPv6 也支持。

## 自检工具
`debug/诊断/局域网地址自检.exe` + 说明:只读、不写文件、不需管理员,
打印系统给 Go 看的网卡列表(含类别)、默认路由探测结果与可用大屏地址。
(正是它把现场定性成 169.254 网段的。)

## 真浏览器验证(四场景,非源码断言)
链接本地地址出链接+二维码+说明句 / 空态列出被跳过网卡并折叠(5 条 + 等共 N 块)/
low 与 route 两种来源提示各占一行互不顶掉 / 手填地址排第一并刷新不丢 —— 全部通过,0 页面错误。

## 文档
使用手册 §9、部署指南 7.5/7.9、上线检查清单(**防火墙放行入站 19540** 与
「另一台设备能打开局域网大屏」两条新增)、常见问题 Q101、升级说明 dev_0.5.0;
devdocs 05/07/13 同步(含这次误判的来龙去脉与「Get-NetAdapter 显示 Up ≠ 有 IP」)。
**没有数据迁移**,旧赛事包照常导入。

自检:服务端 13 包 ×2;前端 vue-tsc + 680 例 ×2 + build;Console 20 文件 + 68 例。
2026-09-12 18:30:46 +08:00
AskaEth 9ef68c2589 feat(console,admin): kiosk 不再点击穿透 + 压住任务栏;大屏控制页新增「局域网大屏」(链接/复制/二维码)dev_0.4.0
## ① 现场问题:kiosk 窗口点击穿透(原设计方向反了)
`displays.js` 原来对多屏 kiosk 调 `setIgnoreMouseEvents(true)`,注释写的是
「防止现场误触」——但该 API 的语义是「本窗口忽略鼠标」,点击会**落到它后面的
窗口或桌面**,现场想防的误触正好变成误点别的东西;被忽略鼠标的窗口也点不活。
现在窗口**默认吞掉鼠标、不穿透**。

配置项**改名**为 `display.allowClickThrough`(缺省 false;显式 true 才穿透)。
**旧键 `display.ignoreMouse` 完全不读**——老机器的配置文件里还写着旧版模板留下的
`ignoreMouse: true`,而配置策略是「已存在不覆盖」,只改默认值等于老机器升级后
照样穿透;改名才能让默认值真正生效。启动时若发现旧键会记一条中文提示(只记一次),
并在合并后的配置里把它剥掉,防止将来有人再读到那个陈年 true。
(不是自动改写用户配置文件:无法区分「操作员刻意写的 true」与「旧模板留下的 true」。)

## ② 现场问题:任务栏盖住 kiosk 窗口、被盖住后抢不回前台
原顺序是 `setKiosk(true)` 之后又 `setBounds(...)`,在 Windows 上会把窗口从真正的
全屏态拽回「恰好铺满该屏的无边框窗口」→ 不是 topmost,任务栏与拖过去的窗口都盖得住。
现在固定为 **先 `setBounds` 定位 → 再 `setKiosk(true)` → 最后 `setAlwaysOnTop(true,
'screen-saver')`**(默认层级 `floating` 在 Windows 上仍画在任务栏下面),并加了
「已达标就不重复进出 kiosk」的守卫避免重排白闪;非 kiosk 分支会摘掉置顶。
Win 真机行为需现场复核,代码/桩级已验(含顺序与层级的断言)。

## ③ 新功能:大屏控制页「局域网大屏」卡片
- 服务端新增 `GET /api/system/network`(recorder 及以上):回本机可用局域网 IPv4
  (排除回环/link-local/IPv6,去重,按 192.168/10/172.16-31 优先排序)+ 真实监听端口
  + 路径;无地址回 `[]` 不报错。
- 卡片:每个地址一条可点击链接 + 复制按钮,第一个地址配二维码(148px)。
- **二维码是离线自实现的纯 TS 编码器**(版本 1–40 自动、ECC L/M/Q/H、8 种掩码择优),
  正确性用 **Python OpenCV 真解码**验收:29/29 条解出原文(含中文与边界),
  SVG 渲染后再解码 4/4,并把验证过的矩阵冻结成仓库参照用例。
- 复制按**非安全上下文**处理:面板常从 `http://192.168.x.x` 打开,那里
  `navigator.clipboard` 是 undefined,自动回退 `execCommand('copy')`。

## ④ 真机联调抓出并修掉一个时序缺陷(真浏览器 + 真服务端)
`DisplayPage` 的 `refreshLan()` 在挂载时跑,而 `auth` 是异步加载的——那一刻
`auth.readOnly` 必然为 true,卡片把「当前为只读访客」写进一个**普通 ref** 就返回了,
身份落定后没有任何东西再触发它:**管理员登录后卡片永远停在访客态,
`/api/system/network` 一次都没被请求**。
改为「gate 由 computed 直接渲染 + watch gate 变 null 时重跑加载」,并补回归钉子。
(同时修正了一条把该错误结构当成正确行为的源码断言用例。)
端到端证据:真服务 + 局域网 IP(真非安全上下文)→ 卡片列出两个地址、点击复制变
「已复制」(走 execCommand 回退)→ 浏览器渲染的二维码截图经 cv2 解出
`http://10.50.101.85:19861/display`。

## 文档
部署指南配置表与多屏行为、使用手册「大屏控制」与局域网设备段、devdocs 05/07/10/13、
升级说明新增 dev_0.4.0(含旧键废弃与启动提示)。**无数据迁移**,旧赛事包照常导入。

自检:服务端 13 包 ×2;前端 vue-tsc + 645 例 ×2 + build;Console 20 文件 + 68 例。
2026-09-12 16:25:04 +08:00
AskaEth ed2c0b3f3c feat: 主人拍板的 4 条全部落地(单条作废/重录、破纪录驳回、历史纪录、仪表盘卡片)+ 迁移 0021(dev_0.3.0)
## ① 成绩「作废本条 / 重新录入」——手滑填错了怎么办
- 新增 `POST /api/results/{id}/reopen`:把已发布的那条退回草稿(state=draft、
  status=ok、rank=NULL、note=原因)并重算推榜;口径与既有 `void` 完全一致
  (回滚类动作,不受 `standings.live_on_publish` 约束)。
- 成绩录入页新增「操作」列:已发布行可「作废本条」,已作废行可「重新录入」,
  两者都必填原因、确认后立即重算+推大屏。**验收用例**:作废→重录→改→重发,
  名次/流水/榜单与「一开始就填对」逐字节一致。

## ② 破纪录的驳回 / 作废
- 迁移 `0021_record_breaks_note.sql`:`record_breaks` 加一列可空 `note`。
- 新增 `GET /api/record-breaks?state=`(recorder 起可读,guest 403)与
  `POST /api/record-breaks/{id}/reject`(必填原因):已批准的登记被作废时
  **撤回该笔加分、重算推榜、并把该级别当前纪录恢复为上一条**;`approve`
  放开为可对 rejected 再次批准。
- 破纪录页新增「已批准的破纪录」卡片(作废入口);原先只存在浏览器内存里的
  「拒绝」改为持久化(先登记再驳回,刷新不再复现)。

## ③ 历史纪录的写入路径(`records.source` 以前恒为「本届」)
- 新增 `POST /api/records`(source 只收 history)与 `DELETE /api/records/{id}`
  (删当前纪录时恢复上一条)。纪录库卡片新增「新增历史纪录」与「删除」。
- 至此《使用手册》6.2 承诺的「来源(本届/历史)」这一列才真正有意义。

## ④ 仪表盘补齐承诺的卡片
今天的小项(按 scheduledAt 的本地时区当天 + 进度)、草稿待发布、待确认破纪录、
大屏在线数;数据源读不到显示 `—` 而不是 0。文档里「管理端在线数」服务端没有
接口,按实际改写成本机管理端连接状态。

## 旧版数据包兼容(主人特别交代,三处验证)
迁移只加**可空**列、不重建表、不动数据行;旧赛事包导入后由 `main.go` 的
`store.MigrateEvent` 在启动时补跑迁移。证据:`migration_0021_test.go`
(旧库升级 + 幂等 + 老行取 NULL)、`migration_legacy_test.go` 扩到 0021、
`legacy_pack_upgrade_test.go`(真实旧 event.db 走 main 同一条路径后装配 API,
驳回老登记 → 加分撤回、纪录回退、旧数据逐表一行不差),另有真二进制冒烟。
未迁移的库上不存在任何新列读取。

## 文档
升级说明新增 dev_0.3.0(含「旧包仍可导入、自动补跑迁移」);使用手册新增
§4.10 与 §6.1/§6.2 明细;常见问题新增 Q98/Q99/Q100;devdocs 04/05/07/13/02
同步(含新接口表与错误分级)。

自检:服务端 13 包 ×2 全绿(httpapi 新增 reopen/reject/records/旧包 四个用例文件)、
前端 vue-tsc + 604 例 ×2 + build、Console 19 文件 + 54 例。
2026-09-12 01:27:09 +08:00
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00
AskaEth ab7662b7f1 docs: 新增《升级说明》(按版本记录改动与升级须知)+ 安装包随包带上升级说明
- docs/升级说明.md:dev_0.1.8 的升级须知——「个人+团体」项目必须补录团体名次
  (旧版没有录入位置、团体分是拿个人名次顶替算的)、单位总分为何会变化、
  奖牌三层开关、即刻重算的动作清单、5 分钟核对表
- README 文档地图与《部署指南》3.4 加入口
- deploy/prepare-extra.mjs:把升级说明一并平铺进安装目录(升级说明.txt)
- debug/使用说明/:同步全部用户文档(含刷新旧的使用说明.txt 快照)
2026-09-11 22:02:00 +08:00