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`。
This commit is contained in:
1 parent
9663d5bfb0
commit
7a3e4ad365
67 files changed
+4658
-228
No files matched your search
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* 之所以复用导入通道的表头与 CSV 格式:服务端只认 /entries/import 这一个报名入口,
|
||||
* 勾选结果转成同格式 CSV 后走同一个接口,字段校验、按 (组别, 单位, 运动员) upsert
|
||||
* 与行级报错回显都与文件导入完全一致,不必为选择器新增服务端接口。
|
||||
* (纯团体行是 (组别, 单位, 队伍),见迁移 0025)与行级报错回显都与文件导入完全一致,
|
||||
* 不必为选择器新增服务端接口。
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -12,11 +13,22 @@ import type {
|
||||
ImportConflictDecision,
|
||||
ImportConflictField,
|
||||
} from '@/shared'
|
||||
import { teamSquadLabel } from '@/shared'
|
||||
|
||||
/**
|
||||
* 队伍列(squad)的规范名:同一个单位在同一组别可以派多个队(迁移 0025),
|
||||
* 这一列就是「这个单位在本组别的第几支队」(自由文本:一队 / 二队 / 青年队)。
|
||||
*
|
||||
* 声明在列清单之前:它要出现在清单里(列在最后),而清单是模块加载期求值的。
|
||||
*/
|
||||
export const SQUAD_COLUMN = 'squad'
|
||||
|
||||
/**
|
||||
* 列顺序即 CSV 模板的列顺序,与服务端 handlers_config.go 的 importColumns 一致。
|
||||
*
|
||||
* heat 是批次列:可选,留空表示「未分批」;批次只做录入 / 展示分组,不参与算分。
|
||||
* 队伍(squad)同样可选、同样在最后:留空 = 不区分队伍,老数据与单队单位都是它,
|
||||
* 报名行为与升级前完全一致(服务端那一列也是 optional,缺列不报错)。
|
||||
* 勾选转 CSV(buildSelectionRows / buildTeamSelectionRows)一律用这套英文规范名;
|
||||
* 下载的 CSV 模板才有中英两套表头(见 templateHeader),服务端两套都认。
|
||||
*/
|
||||
@@ -33,13 +45,14 @@ export const REQUIRED_COLUMNS = [
|
||||
'heat',
|
||||
'status',
|
||||
'member_names',
|
||||
SQUAD_COLUMN,
|
||||
] as const
|
||||
|
||||
/** 取消勾选 / 取值的列名类型,保证别名表一列都不漏。 */
|
||||
export type ImportColumnName = (typeof REQUIRED_COLUMNS)[number]
|
||||
|
||||
/** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card 或 bib 之一。 */
|
||||
export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age', 'heat'] as const
|
||||
export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age', 'heat', SQUAD_COLUMN] as const
|
||||
|
||||
/** 必须存在的表头;服务端缺任一列会直接拒绝整份文件。 */
|
||||
const OPTIONAL_SET: readonly ImportColumnName[] = OPTIONAL_COLUMNS
|
||||
@@ -69,6 +82,7 @@ export const COLUMN_ALIASES: Record<ImportColumnName, readonly string[]> = {
|
||||
heat: ['批次', '批次号'],
|
||||
status: ['状态', '报名状态'],
|
||||
member_names: ['队员名单'],
|
||||
squad: ['队伍', '队名', '队伍名'],
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,11 +191,11 @@ export function atSignNameError(kind: string, name: string): string | null {
|
||||
export const TEMPLATE_SHEET_MOCK_HINT =
|
||||
'示例数据模式无法生成分工作表模板:它由服务端按当前赛事的小项与组别逐张建表。请连接服务端后重试,或继续用单表 CSV 模板'
|
||||
|
||||
/** 模板里的示例行:两种语言共用,只有表头不同。 */
|
||||
/** 模板里的示例行:两种语言共用,只有表头不同。队名列留空(示例行本来就该被填表人删掉)。 */
|
||||
const TEMPLATE_ROWS: readonly string[][] = [
|
||||
['M100', '男子组', 'F1', '张伟', '110101199001011234', '男', '26', '101', '', '1', 'registered', ''],
|
||||
['W100', '女子组', 'F2', '王芳', '', '女', '', '202', '', '2', 'registered', ''],
|
||||
['BB', '混合组', 'F2', '', '', '', '', '', '种子1', '', 'registered', '第一分公司代表队'],
|
||||
['M100', '男子组', 'F1', '张伟', '110101199001011234', '男', '26', '101', '', '1', 'registered', '', ''],
|
||||
['W100', '女子组', 'F2', '王芳', '', '女', '', '202', '', '2', 'registered', '', ''],
|
||||
['BB', '混合组', 'F2', '', '', '', '', '', '种子1', '', 'registered', '第一分公司代表队', ''],
|
||||
]
|
||||
|
||||
/** 模板 CSV 文本(不含 BOM,下载时由调用方补)。 */
|
||||
@@ -201,14 +215,24 @@ export function selectionAllowsSubmit(mode: SelectionMode, chosenCount: number):
|
||||
return mode === 'team' || chosenCount > 0
|
||||
}
|
||||
|
||||
/** 报名行的展示文案:运动员姓名 → 团体队员首人 → 单位简称 / 全称。 */
|
||||
/**
|
||||
* 报名行的展示文案:运动员姓名 → 团体队员首人 → 单位简称 / 全称。
|
||||
*
|
||||
* 团体行带上队名(`第一分公司二队`):同一个单位的两个队只差这几个字,
|
||||
* 少了它,防呆报错与批量确认框上的两行会写成同一个名字。
|
||||
*/
|
||||
export function entryRowLabel(row: {
|
||||
athleteName?: string | null
|
||||
memberNames?: string[] | null
|
||||
teamShort?: string | null
|
||||
teamName?: string | null
|
||||
squad?: string | null
|
||||
}): string {
|
||||
return row.athleteName || row.memberNames?.[0] || row.teamShort || row.teamName || ''
|
||||
return (
|
||||
row.athleteName ||
|
||||
row.memberNames?.[0] ||
|
||||
teamSquadLabel(row.teamShort || row.teamName || '', row.squad)
|
||||
)
|
||||
}
|
||||
|
||||
export interface SelectionInput {
|
||||
@@ -227,7 +251,8 @@ export interface SelectionInput {
|
||||
* 生成与导入表头一致的二维数组。
|
||||
*
|
||||
* 个人项目一人一行;团体 / 对抗项目整体一行,队员姓名进 member_names(可以为空,即只报单位)。
|
||||
* 服务端按 (组别, 单位, 运动员) upsert,团体行运动员为空,因此同组别 + 单位只留一条报名。
|
||||
* 服务端按 (组别, 单位, 运动员) upsert,团体行运动员为空,因此同组别 + 单位只留一条报名;
|
||||
* 队名列留空——个人报名不带队伍(服务端也只对纯团体报名写它)。
|
||||
*/
|
||||
export function buildSelectionRows(input: SelectionInput): string[][] {
|
||||
const status = input.status ?? 'registered'
|
||||
@@ -248,6 +273,8 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
|
||||
'',
|
||||
status,
|
||||
'',
|
||||
// 队伍留空:个人报名按人走,没有「这个单位的第几支队」这回事。
|
||||
'',
|
||||
])
|
||||
}
|
||||
return rows
|
||||
@@ -266,6 +293,11 @@ export interface TeamSelection {
|
||||
teamCode: string
|
||||
/** 队员姓名;空数组表示只报单位,名单之后可以再补。 */
|
||||
memberNames: string[]
|
||||
/**
|
||||
* 队伍(自由文本,如「一队」):同一个单位在同一组别派出的第几支队。
|
||||
* 留空 / 缺省 = 不区分队伍 —— 老数据与单队单位的默认,报名行为与升级前完全一致。
|
||||
*/
|
||||
squad?: string
|
||||
}
|
||||
|
||||
export interface TeamSelectionInput {
|
||||
@@ -277,10 +309,12 @@ export interface TeamSelectionInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* 多单位团体报名:每个单位一行,队员姓名进 member_names。
|
||||
* 多单位团体报名:每个单位一行,队员姓名进 member_names,队伍进最后一列。
|
||||
*
|
||||
* 服务端对 athlete_name 为空的行只按 (组别, 单位, athlete_id IS NULL) upsert,
|
||||
* 所以同组别 + 单位仍然只留一条报名,重复提交是更新(队员留空时即清空)队员名单而不是新增。
|
||||
* 服务端对 athlete_name 为空的行按 (组别, 单位, 队伍) upsert,
|
||||
* 所以同组别 + 同单位 + 同队伍仍然只留一条报名(重复提交是更新队员名单而不是新增),
|
||||
* 而同一个单位换个队名再提交就是**第二条报名** —— 一个单位派两个队正是这样报的。
|
||||
* 队伍留空 = 不区分队伍,与服务端那一列(optional)同口径。
|
||||
*/
|
||||
export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] {
|
||||
const status = input.status ?? 'registered'
|
||||
@@ -300,6 +334,8 @@ export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] {
|
||||
'',
|
||||
status,
|
||||
team.memberNames.join('/'),
|
||||
// 队名两侧空白去掉再写:服务端读表时就 TrimSpace,这里先归一,免得「一队 」与「一队」成了两个队。
|
||||
(team.squad ?? '').trim(),
|
||||
])
|
||||
}
|
||||
return rows
|
||||
@@ -316,6 +352,13 @@ export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] {
|
||||
export interface TeamPickState {
|
||||
teamIds: number[]
|
||||
memberIds: Record<number, number[]>
|
||||
/**
|
||||
* 每个单位填的队名(输入框原文):没填 / 不在表里 = 空串 = 不区分队伍。
|
||||
*
|
||||
* 可选字段是为了让「不填队名」这条老路径原样保留:老代码构造的状态里根本没有它,
|
||||
* 产出的报名行也就与改造前一模一样。
|
||||
*/
|
||||
squads?: Record<number, string>
|
||||
}
|
||||
|
||||
export function emptyTeamPick(): TeamPickState {
|
||||
@@ -348,11 +391,21 @@ export function toggleMemberPick(
|
||||
return { ...state, memberIds: { ...state.memberIds, [teamId]: next } }
|
||||
}
|
||||
|
||||
/** 全选 / 清空单位勾选(传空数组即清空);已登记的队员名单保留。 */
|
||||
/** 全选 / 清空单位勾选(传空数组即清空);已登记的队员名单与队名保留。 */
|
||||
export function setPickedTeams(state: TeamPickState, teamIds: readonly number[]): TeamPickState {
|
||||
return { ...state, teamIds: [...teamIds] }
|
||||
}
|
||||
|
||||
/**
|
||||
* 记下某个单位填的队名(输入框原文)。
|
||||
*
|
||||
* 存原文不存处理过的值:界面上「空」与「填了一队」是两件事(前者 = 不区分队伍),
|
||||
* 归一留给产出报名行的那一步(buildTeamSelections / buildTeamSelectionRows)。
|
||||
*/
|
||||
export function setTeamPickSquad(state: TeamPickState, teamId: number, squad: string): TeamPickState {
|
||||
return { ...state, squads: { ...state.squads, [teamId]: squad } }
|
||||
}
|
||||
|
||||
/** 「全选」的范围:启用中且还没报名的单位。已停用单位要单独勾,避免批量报错。 */
|
||||
export function selectableTeamIds(
|
||||
teams: readonly { id: number; active: boolean }[],
|
||||
@@ -366,6 +419,7 @@ export function selectableTeamIds(
|
||||
*
|
||||
* 单位按传入顺序(即界面列表顺序)产出,队员也只按运动员列表顺序取启用中的人,
|
||||
* 保证同一份勾选每次生成的 member_names 完全一致。
|
||||
* 队名只在填了的时候挂上去:不填时产出的 selection 与改造前逐字段相同(CSV 也不多那一列)。
|
||||
*/
|
||||
export function buildTeamSelections(
|
||||
state: TeamPickState,
|
||||
@@ -380,7 +434,8 @@ export function buildTeamSelections(
|
||||
const memberNames = athletes
|
||||
.filter((athlete) => athlete.active && athlete.teamId === team.id && memberIds.has(athlete.id))
|
||||
.map((athlete) => athlete.name)
|
||||
selections.push({ teamCode: team.code, memberNames })
|
||||
const squad = (state.squads?.[team.id] ?? '').trim()
|
||||
selections.push(squad === '' ? { teamCode: team.code, memberNames } : { teamCode: team.code, memberNames, squad })
|
||||
}
|
||||
return selections
|
||||
}
|
||||
@@ -403,6 +458,30 @@ export function registeredTeamIds(
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* 组别下各单位**已报名的队名**(去重、按报名表出现顺序)。
|
||||
*
|
||||
* 「已报名」这一个徽标在有了队伍之后不够用了:同一个单位可以再报第二支队,
|
||||
* 操作员得先看见它已经报过哪些队,才不会把「二队」又写成「一队」(那样是更新原来那条,不是新增)。
|
||||
* 空串(不区分队伍的老报名)不进表,那一行的徽标仍只说「已报名」,与升级前一样。
|
||||
*/
|
||||
export function registeredTeamSquads(
|
||||
entries: readonly { categoryId: number; teamId: number; squad?: string | null }[],
|
||||
categoryId: number | undefined,
|
||||
): Map<number, string[]> {
|
||||
const out = new Map<number, string[]>()
|
||||
if (categoryId === undefined) return out
|
||||
for (const entry of entries) {
|
||||
if (entry.categoryId !== categoryId) continue
|
||||
const squad = (entry.squad ?? '').trim()
|
||||
if (squad === '') continue
|
||||
const list = out.get(entry.teamId)
|
||||
if (list === undefined) out.set(entry.teamId, [squad])
|
||||
else if (!list.includes(squad)) list.push(squad)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 组别 + 单位下已报名的运动员 id;团体行 athleteId 为空,不会进集合。 */
|
||||
export function registeredAthleteIds(
|
||||
entries: readonly { categoryId: number; teamId: number; athleteId: number | null }[],
|
||||
|
||||
Reference in new issue
Block a user