## 一、新能力:团体项目多队
现场:羽毛球双打,一个单位可能派两个队,一个单位两个队各占一个名次。
- 迁移 `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`。
177 lines
7.7 KiB
TypeScript
177 lines
7.7 KiB
TypeScript
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { readFileSync } from 'node:fs'
|
||
import { parse } from 'vue/compiler-sfc'
|
||
import { breakdownAthleteName, breakdownRevisionLine, breakdownSourceTag, type BreakdownItem } from '@/shared'
|
||
import { resultTextOrPlaceholder } from '../results'
|
||
|
||
/**
|
||
* 大屏积分明细覆盖层的「运动员」列。
|
||
*
|
||
* 现场裁判长的原话:推到大屏的明细只有项目 / 名次 / 成绩 / 积分,看不出「谁在哪个项目拿了多少分」,
|
||
* 这种明细说服不了观众单位名字为什么升上去。数据(athleteName)服务端一直有,是覆盖层漏了这一列。
|
||
*
|
||
* 项目没有 jsdom 与组件测试工具,这里沿用仓库既有做法:`.vue` 模板用源码契约断言
|
||
* (列头顺序、每个格子绑了什么),文案内容交给 `breakdownAthleteName` 这个纯函数用例守。
|
||
*/
|
||
|
||
const OVERLAY_SOURCE = readFileSync(new URL('../components/DetailOverlay.vue', import.meta.url), 'utf8')
|
||
|
||
/** 取模板里以某个固定串开头、到下一个 </div> 为止的片段(这些行里没有嵌套 div)。 */
|
||
function block(startPattern: RegExp): string {
|
||
const found = startPattern.exec(OVERLAY_SOURCE)
|
||
assert.ok(found !== null, `模板里应有 ${startPattern}`)
|
||
const rest = OVERLAY_SOURCE.slice(found.index)
|
||
const end = rest.indexOf('</div>')
|
||
assert.ok(end > 0, '行片段应以 </div> 结束')
|
||
return rest.slice(0, end)
|
||
}
|
||
|
||
interface Cell {
|
||
cls: string
|
||
inner: string
|
||
}
|
||
|
||
function cellsOf(fragment: string): Cell[] {
|
||
return [...fragment.matchAll(/<span class="(col-[a-z]+)">([\s\S]*?)<\/span>/g)].map((match) => ({
|
||
cls: match[1] ?? '',
|
||
inner: (match[2] ?? '').replace(/\s+/g, ' ').trim(),
|
||
}))
|
||
}
|
||
|
||
const HEAD_ROW = block(/<div class="row head-row">/)
|
||
const BODY_ROW = block(/<div v-for="item in items" :key="item\.ledgerId" class="row">/)
|
||
|
||
function styleText(source: string): string {
|
||
const { descriptor } = parse(source, { filename: 'style.vue' })
|
||
return descriptor.styles[0]?.content ?? ''
|
||
}
|
||
|
||
function cssRule(style: string, selector: string): string {
|
||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
const match = new RegExp(`${escaped}\\s*\\{([^}]*)\\}`).exec(style)
|
||
assert.ok(match !== null, `样式里应存在 ${selector} 规则`)
|
||
return match[1] ?? ''
|
||
}
|
||
|
||
function item(over: Partial<BreakdownItem> = {}): BreakdownItem {
|
||
return {
|
||
ledgerId: 1,
|
||
sourceType: 'result',
|
||
eventId: 1,
|
||
eventCode: 'M100',
|
||
eventName: '100米跑',
|
||
categoryName: '男子',
|
||
athleteId: 7,
|
||
athleteName: '张伟',
|
||
rank: 1,
|
||
resultText: '11.58',
|
||
points: 10,
|
||
createdAt: '2026-09-13 10:00:00',
|
||
revisionCount: null,
|
||
lastRevisionAt: null,
|
||
lastRevisionReason: null,
|
||
lastRevisedBy: null,
|
||
...over,
|
||
}
|
||
}
|
||
|
||
test('列头按「项目/组别 · 运动员 · 名次 · 成绩 · 积分」排列,运动员插在项目与名次之间', () => {
|
||
const labels = [...HEAD_ROW.matchAll(/<span class="col-[a-z]+">([^<]*)<\/span>/g)].map((match) =>
|
||
(match[1] ?? '').trim(),
|
||
)
|
||
assert.deepEqual(labels, ['项目 / 组别', '运动员', '名次', '成绩', '积分'])
|
||
})
|
||
|
||
test('明细行渲染运动员格,绑定的是团队共用的降级函数', () => {
|
||
const cells = cellsOf(BODY_ROW)
|
||
assert.deepEqual(
|
||
cells.map((cell) => cell.cls),
|
||
['col-name', 'col-athlete', 'col-rank', 'col-result', 'col-points'],
|
||
)
|
||
assert.equal(cells[1]?.inner, '{{ breakdownAthleteName(item, teamName) }}')
|
||
})
|
||
|
||
test('项目 / 名次 / 成绩 / 积分四列原样保留(是加列不是改列)', () => {
|
||
const cells = cellsOf(BODY_ROW)
|
||
assert.match(cells[0]?.inner ?? '', /item\.eventName \?\? '人工调整'/)
|
||
assert.equal(cells[2]?.inner, "{{ item.rank ?? '—' }}")
|
||
// 成绩格与分项成绩页 / 实时赛况页同一个口径:`??` 只挡 null,手录名次的项目给空串就画成空白。
|
||
assert.equal(cells[3]?.inner, '{{ resultTextOrPlaceholder(item.resultText) }}')
|
||
assert.equal(cells[4]?.inner, '{{ item.points }}')
|
||
})
|
||
|
||
test('个人流水那一行大屏上看得见是谁:运动员格是姓名,四个老格子照旧', () => {
|
||
const row = item()
|
||
const rendered = [
|
||
row.eventName ?? '人工调整',
|
||
breakdownAthleteName(row, '第一分公司'),
|
||
row.rank ?? '—',
|
||
resultTextOrPlaceholder(row.resultText),
|
||
row.points,
|
||
]
|
||
assert.deepEqual(rendered, ['100米跑', '张伟', 1, '11.58', 10])
|
||
})
|
||
|
||
test('团体项目流水没有运动员名:降级成单位名,不出现空白格或 null', () => {
|
||
const row = item({ athleteName: null, athleteId: null, sourceType: 'result' })
|
||
const cell = breakdownAthleteName(row, '第一分公司')
|
||
assert.equal(cell, '第一分公司')
|
||
assert.doesNotMatch(cell, /null|undefined/)
|
||
})
|
||
|
||
test('覆盖层是 16:9 右半屏:五列加间距放得下,姓名列留得够宽', () => {
|
||
const row = cssRule(styleText(OVERLAY_SOURCE), '.row')
|
||
const tracks = /grid-template-columns:\s*([^;]+);/.exec(row)
|
||
assert.ok(tracks !== null, '.row 应声明 grid-template-columns')
|
||
const parts = (tracks[1] ?? '').trim().split(/\s+(?![^(]*\))/)
|
||
assert.equal(parts.length, 5, '五列:项目/组别、运动员、名次、成绩、积分')
|
||
|
||
const gap = Number(/gap:\s*(\d+)px/.exec(row)?.[1] ?? 0)
|
||
const fixed = parts.reduce((sum, part) => sum + Number(/(\d+)px/.exec(part)?.[1] ?? 0), 0)
|
||
// 1920 宽的 16:9 屏幕:覆盖层占右半边 960px,减去左右内边距 44px×2。
|
||
const available = 960 - 44 * 2
|
||
assert.ok(fixed + gap * (parts.length - 1) <= available, `五列 ${fixed}px + 间距会挤出右半屏`)
|
||
|
||
const nameTrack = /minmax\(0,\s*1fr\)/.test(parts[0] ?? '')
|
||
assert.ok(nameTrack, '项目 / 组别列应可伸缩')
|
||
const athleteWidth = Number(/(\d+)px/.exec(parts[1] ?? '')?.[1] ?? 0)
|
||
assert.ok(athleteWidth >= 180, '运动员列至少 180px,单位名才不至于只剩几个字')
|
||
|
||
const athlete = cssRule(styleText(OVERLAY_SOURCE), '.col-athlete')
|
||
assert.match(athlete, /text-overflow:\s*ellipsis/, '长姓名 / 单位名要省略号收尾')
|
||
assert.match(athlete, /white-space:\s*nowrap/)
|
||
})
|
||
|
||
/**
|
||
* 「这几分怎么来的、有没有被改过」——大屏不能悬停,所以:
|
||
* - 非普通成绩(破纪录加分)在项目名后面打一个来源小标签;
|
||
* - 被改过的成绩多一行整行小字 `10:42 已修订(成绩录入笔误)`。
|
||
*
|
||
* 两处都只在需要时才渲染(v-if),没改过的行连元素都没有 —— 大屏行高与加这一行之前完全一致。
|
||
*/
|
||
test('来源标签只在非普通成绩时渲染,普通成绩不打(行高与列宽都不受影响)', () => {
|
||
assert.match(BODY_ROW, /v-if="breakdownSourceTag\(item\)"/)
|
||
assert.match(BODY_ROW, /<em v-if="breakdownSourceTag\(item\)" class="col-source">/)
|
||
assert.equal(breakdownSourceTag(item()), '')
|
||
// 大屏标签用短词:项目名后面那一行放不下「破纪录加分」五个字。
|
||
assert.equal(breakdownSourceTag(item({ sourceType: 'record_bonus' })), '破纪录')
|
||
})
|
||
|
||
test('修订小字占满一整行(grid-column: 1 / -1),不与五列抢宽度', () => {
|
||
assert.match(BODY_ROW, /v-if="breakdownRevisionLine\(item\)"/)
|
||
assert.match(BODY_ROW, /class="col-revision"/)
|
||
assert.match(BODY_ROW, /\{\{ breakdownRevisionLine\(item\) \}\}/)
|
||
const css = cssRule(styleText(OVERLAY_SOURCE), '.col-revision')
|
||
assert.match(css, /grid-column:\s*1\s*\/\s*-1/, '修订小字要横跨整行,不能挤第六列')
|
||
})
|
||
|
||
test('没改过的成绩不渲染小字,渲染出来的也只有一行短句', () => {
|
||
assert.equal(breakdownRevisionLine(item()), '')
|
||
const line = breakdownRevisionLine(
|
||
item({ revisionCount: 1, lastRevisionAt: '2026-09-13 10:42:00', lastRevisionReason: '成绩录入笔误' }),
|
||
)
|
||
assert.doesNotMatch(line, /null|undefined|NaN|×1/)
|
||
assert.ok(line.length <= 30, `大屏一行小字要短:${line}`)
|
||
})
|