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` 两张)。
This commit is contained in:
AskaEth committed 2026-09-14 14:05:48 +08:00
1 parent 7a3e4ad365
commit 99c91f99f7
57 files changed
+4903 -94

No files matched your search

@@ -1,8 +1,10 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { Athlete, EntryWithResult, ImportConflict } from '@/shared'
import { normalizeSquad, teamSquadLabel } from '@/shared'
import MxIcon from '../MxIcon.vue'
import { adminApi } from '../../api/client'
import { submitEntryAthletes } from '../../pages/entryAthletes'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
import {
@@ -11,6 +13,7 @@ import {
buildTeamSelections,
conflictLabel,
emptyTeamPick,
planTeamAthleteSync,
registeredAthleteIds,
registeredTeamIds,
registeredTeamSquads,
@@ -24,6 +27,7 @@ import {
toCsv,
type SelectionMode,
type TeamPickState,
type TeamSelection,
} from './entrySelection'
/**
@@ -393,6 +397,48 @@ async function submitIndividual(): Promise<void> {
}
}
/** 一条报名的展示名(单位 + 队名):队员写失败时要点到具体是哪一条。 */
function entrySyncLabel(entryId: number): string {
const entry = entries.value.find((item) => item.id === entryId)
if (entry === undefined) return `报名 ${entryId}`
const team = data.teamById(entry.teamId)
const name = team?.shortName || team?.name || entry.teamCode || `报名 ${entryId}`
return teamSquadLabel(name, normalizeSquad(entry.squad))
}
/**
* 把勾中的队员写回去(`PUT /api/entries/{id}/athletes`,只认 athleteId)。
*
* 报名行本身走的是导入通道:CSV 的 member_names 列只有**姓名文字**,认不出是同名的哪一个人。
* 所以导入成功后再按「组别 + 单位 + 队伍」找回刚写进去的那几条报名,把勾中的人整组挂上去
* ——姓名文字与人员身份并存,没档案的人仍然只在文字名单里。
*
* 这里**不往外抛**:报名已经写进去了,抛给调用方会被说成「加入报名失败」,与事实相反。
* 失败只影响队员关联,单独提示(并且说清去哪补:录入页那一行的队员格还能改)。
*/
async function syncTeamAthletes(selections: readonly TeamSelection[], categoryId: number): Promise<void> {
// 示例数据模式没有真服务端:导入本身也只是内存里的示例数据,写请求发出去只会失败。
if (data.isMock.value) return
const plan = planTeamAthleteSync(selections, entries.value, categoryId)
if (plan.length === 0) return
const failed: string[] = []
for (const item of plan) {
try {
await submitEntryAthletes(item.entryId, item.athletes)
} catch (caught) {
const reason = caught instanceof Error ? caught.message : '队员没有改成功'
failed.push(`${entrySyncLabel(item.entryId)}:${reason}`)
}
}
if (failed.length === 0) return
const listed = failed.slice(0, 3).join(';')
const more = failed.length > 3 ? `;…还有 ${failed.length - 3} 条` : ''
toast.error(
`${failed.length} 条报名的队员关联没存上`,
`${listed}${more}。报名本身已经加入(队员名单的文字已写入),到成绩录入页点这一行的「队员」按钮即可重新挂人。`,
)
}
async function submitTeam(): Promise<void> {
const event = currentEvent.value
const category = currentCategory.value
@@ -409,6 +455,8 @@ async function submitTeam(): Promise<void> {
teams,
})
await importSelection(rows, event.id)
// 导入成功后再挂队员:athleteId 要挂到刚写出来的那条报名上,必须先有报名。
await syncTeamAthletes(teams, category.id)
// 提交成功后清空勾选:已报名的单位会转为「已报名」而不可再勾。
resetTeamPick()
} catch (caught) {
@@ -662,7 +710,11 @@ async function submitTeam(): Promise<void> {
所以「全选」会跳过已报名的单位,避免批量提交时误清名单;需要改名单时单独勾这一个单位,
队名要与要改的那支队写成一模一样的字,写成别的队名会新增一支队。
</p>
<p class="bd-note">队员名单只存姓名,不会带动运动员档案;证件号、号码布与种子等字段仍通过下方报名表导入填写。</p>
<p class="bd-note">
勾中的队员会连同运动员档案一起提交:这条报名挂了谁,配了个人通道的项目就按谁算个人分与个人奖牌。
没勾进名单的人不会关联到人(只在文字名单里留个姓名,个人榜上就看不到他);
证件号、号码布与种子等字段仍通过下方报名表导入填写。
</p>
</template>
<!-- 冲突行既没建报名也没报错,必须在这里说清「哪几行没进、去哪确认」,否则勾了等于白勾。 -->
@@ -9,11 +9,13 @@
import type {
ConflictAction,
EntryAthlete,
EntryAthleteInput,
ImportConflict,
ImportConflictDecision,
ImportConflictField,
} from '@/shared'
import { teamSquadLabel } from '@/shared'
import { entryRosterHead, teamSquadLabel } from '@/shared'
/**
* 队伍列(squad)的规范名:同一个单位在同一组别可以派多个队(迁移 0025),
@@ -220,17 +222,21 @@ export function selectionAllowsSubmit(mode: SelectionMode, chosenCount: number):
*
* 团体行带上队名(`第一分公司二队`):同一个单位的两个队只差这几个字,
* 少了它,防呆报错与批量确认框上的两行会写成同一个名字。
*
* 「首人」的口径没变(一行只念一个人):有队员关联时它是首名队员 `张伟(A001)`——
* 号码布让人对得上是谁(重名时唯一的线索);没有关联时就是文字名单的第一个人,与升级前逐字一样。
*/
export function entryRowLabel(row: {
athleteName?: string | null
memberNames?: string[] | null
athletes?: readonly EntryAthlete[] | null
teamShort?: string | null
teamName?: string | null
squad?: string | null
}): string {
return (
row.athleteName ||
row.memberNames?.[0] ||
entryRosterHead(row) ||
teamSquadLabel(row.teamShort || row.teamName || '', row.squad)
)
}
@@ -241,9 +247,16 @@ export interface SelectionInput {
teamCode: string
status?: string
mode: SelectionMode
/** 个人项目按 (姓名, 单位) 匹配已有运动员,带上证件号 / 号码布才能命中对应匹配规则。 */
athletes: { name: string; idCard?: string | null; bib?: string | null }[]
/** 团体项目队员名单;缺省时按 athletes 的列表顺序取姓名。 */
/**
* 个人项目按 (姓名, 单位) 匹配已有运动员,带上证件号 / 号码布才能命中对应匹配规则。
* `id` 是人员档案的 id:团体项目靠它把勾中的人提交成**队员关联**(写侧只认 athleteId)。
*/
athletes: { id?: number; name: string; idCard?: string | null; bib?: string | null }[]
/**
* 团体项目队员名单;缺省时按 athletes 的列表顺序取姓名。
*
* 这是**文字名单**(导入表的 member_names 列),与队员关联并存:没录档案的人只能留文字。
*/
memberNames?: string[]
}
@@ -284,15 +297,56 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
eventCode: input.eventCode,
categoryName: input.categoryName,
status,
teams: [{ teamCode: input.teamCode, memberNames: names }],
teams: [
{
teamCode: input.teamCode,
memberNames: names,
// 文字名单可以覆盖(人手写的名单),队员关联只认勾中的、带档案 id 的那几个人。
athletes: teamAthletesOf(input.athletes),
},
],
})
}
/** 一个单位的团体报名:只带单位编码与可选队员名单。 */
/**
* 勾中的人 → 队员关联(只有带 id 的才算:没有 id 的人只在文字名单里,见 devdocs/20 §五)。
*
* 顺序就是传入顺序(界面上的列表顺序),写进去的 sort_order 跟着它,队里的排列才稳定。
*/
function teamAthletesOf(
athletes: readonly { id?: number; name: string; bib?: string | null }[],
): EntryAthlete[] {
const out: EntryAthlete[] = []
const seen = new Set<number>()
for (const athlete of athletes) {
const athleteId = athlete.id
if (athleteId === undefined || !Number.isInteger(athleteId) || athleteId <= 0) continue
if (seen.has(athleteId)) continue
seen.add(athleteId)
out.push({ athleteId, name: athlete.name, bib: athlete.bib ?? null })
}
return out
}
/** 一个单位的团体报名:单位编码 + 可选队员名单 + 可选的队员关联。 */
export interface TeamSelection {
/**
* 单位 id:提交后要靠它(连同组别与队名)找回刚写进去的那条报名,
* 才谈得上把队员关联挂上去。缺省 = 不打算同步队员(例如只想拿这份选举行生成 CSV)。
*/
teamId?: number
teamCode: string
/** 队员姓名;空数组表示只报单位,名单之后可以再补。 */
/**
* 队员姓名(导入表的 member_names 列):空数组表示只报单位,名单之后可以再补。
*
* 与 `athletes` **并存**:这是给没录档案的人留的文字名单,也是老数据唯一有的东西。
*/
memberNames: string[]
/**
* 队员关联(运动员档案里的人):勾中的人进这里,提交后走 PUT /api/entries/{id}/athletes。
* 缺省 / 空数组 = 这条报名不挂任何队员(只报单位)。
*/
athletes?: EntryAthlete[]
/**
* 队伍(自由文本,如「一队」):同一个单位在同一组别派出的第几支队。
* 留空 / 缺省 = 不区分队伍 —— 老数据与单队单位的默认,报名行为与升级前完全一致。
@@ -420,26 +474,85 @@ export function selectableTeamIds(
* 单位按传入顺序(即界面列表顺序)产出,队员也只按运动员列表顺序取启用中的人,
* 保证同一份勾选每次生成的 member_names 完全一致。
* 队名只在填了的时候挂上去:不填时产出的 selection 与改造前逐字段相同(CSV 也不多那一列)。
*
* 勾中的人同时产出**队员关联**(athleteId,写侧只认它)——姓名文字继续进 member_names
* (导出、大屏、录入页都读它),身份进 athletes:个人分与个人奖牌挂的就是后者。
*/
export function buildTeamSelections(
state: TeamPickState,
teams: readonly { id: number; code: string }[],
athletes: readonly { id: number; teamId: number; name: string; active: boolean }[],
athletes: readonly { id: number; teamId: number; name: string; active: boolean; bib?: string | null }[],
): TeamSelection[] {
const picked = new Set(state.teamIds)
const selections: TeamSelection[] = []
for (const team of teams) {
if (!picked.has(team.id)) continue
const memberIds = new Set(state.memberIds[team.id] ?? [])
const memberNames = athletes
.filter((athlete) => athlete.active && athlete.teamId === team.id && memberIds.has(athlete.id))
.map((athlete) => athlete.name)
const members = athletes.filter(
(athlete) => athlete.active && athlete.teamId === team.id && memberIds.has(athlete.id),
)
const memberNames = members.map((athlete) => athlete.name)
const squad = (state.squads?.[team.id] ?? '').trim()
selections.push(squad === '' ? { teamCode: team.code, memberNames } : { teamCode: team.code, memberNames, squad })
const selection: TeamSelection = {
teamId: team.id,
teamCode: team.code,
memberNames,
athletes: teamAthletesOf(members),
}
selections.push(squad === '' ? selection : { ...selection, squad })
}
return selections
}
/** 一条要写回去的队员关联:先按 组别 + 单位 + 队伍 找到那条报名,再整组替换它的队员。 */
export interface TeamAthleteSync {
entryId: number
athletes: EntryAthleteInput[]
}
/**
* 补报名提交后要发的队员写请求:勾了人的单位、以及**原来挂着队员而这次一个都没勾**的单位。
*
* 为什么不是「所有勾选的单位都发一次」:队员是整组替换,发空数组 = 清空。一个单位都没勾队员的
* 单位本来就没关联,也发一次只是白发一趟请求(「全选」几十个单位就是几十次写请求,会撞写限流)。
* 反过来,原来有队员、这次一个都没勾的必须先写空数组:那条报名在界面上会显示成清空了名单,
* 不写就要留着一个谁都没在里面的队员关联,个人榜继续给早就换掉的人发分。
*
* 队名要一模一样才算同一条报名(同一个单位的一队与二队是两条报名,见迁移 0025),
* 所以这里按 组别 + 单位 + 归一后的队名 找;组别对不上、找不到(导入那一步没写进去)的一律跳过,
* 由导入那边的行级报错去说,不在这里猜。
*/
export function planTeamAthleteSync(
selections: readonly TeamSelection[],
entries: readonly {
id: number
categoryId: number
teamId: number
squad?: string | null
athletes?: readonly EntryAthlete[] | null
}[],
categoryId: number | undefined,
): TeamAthleteSync[] {
const plan: TeamAthleteSync[] = []
if (categoryId === undefined) return plan
for (const selection of selections) {
const teamId = selection.teamId
if (teamId === undefined) continue
const squad = (selection.squad ?? '').trim()
const entry = entries.find(
(item) =>
item.categoryId === categoryId &&
item.teamId === teamId &&
(item.squad ?? '').trim() === squad,
)
if (entry === undefined) continue
const athletes = selection.athletes ?? []
if (athletes.length === 0 && (entry.athletes ?? []).length === 0) continue
plan.push({ entryId: entry.id, athletes: athletes.map((athlete) => ({ athleteId: athlete.athleteId })) })
}
return plan
}
/** 多单位团体报名是否放行:至少要勾一个要参加的单位(队员可以为 0 人)。 */
export function teamSelectionAllowsSubmit(pickedTeamCount: number): boolean {
return pickedTeamCount > 0
+327 -1
View File
@@ -2,11 +2,14 @@
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import {
entryAthleteText,
eventInputOf,
formatPoints,
hasAggregateChannel,
entryRosterText,
isJointTeam,
jointTeamLabel,
normalizeEntryAthletes,
normalizeSquad,
normalizeTeamShares,
parseResultValue,
@@ -14,6 +17,7 @@ import {
teamSquadLabel,
type AvgMode,
type DraftResultInput,
type EntryAthlete,
type EntryStatus,
type EntryWithResult,
type Event,
@@ -68,6 +72,18 @@ import {
squadDraftOf,
submitEntrySquad,
} from './entrySquad'
import {
addAthleteDraft,
athleteChoiceRows,
athleteDraftDirty,
athleteDraftIssues,
athleteDraftOf,
athleteDraftPayload,
entryAthletesEditable,
removeAthleteDraft,
submitEntryAthletes,
type AthleteDraftRow,
} from './entryAthletes'
import {
entryBatchOutcome,
entryBatchSizeError,
@@ -135,6 +151,11 @@ interface EditableRow {
teamShares: TeamShareView[]
athleteName: string
memberNames: string[] | null
/**
* 队员(谁在这支队里,devdocs/20):读侧恒为数组,空数组 = 只报单位(老数据的样子)。
* 与 memberNames 并存——文字名单里可能有没录档案的人。
*/
athletes: EntryAthlete[]
/** 批次号(null = 未分批);只影响录入 / 展示分组,不参与算分。 */
heat: number | null
/** 报名项状态:与成绩状态(status)无关,退赛 / 取消资格看这一列。 */
@@ -901,6 +922,8 @@ function toRow(entry: EntryWithResult): EditableRow {
}),
athleteName: entry.athleteName ?? '',
memberNames: entry.memberNames,
// 老服务端不下发 athletes 时按空数组(= 只报单位),显示与升级前逐字一致。
athletes: normalizeEntryAthletes(entry.athletes),
heat: entry.heat,
entryStatus: entry.status,
resultId: result?.id ?? null,
@@ -1336,6 +1359,161 @@ async function saveShareEditor(): Promise<void> {
}
}
// ── 队员(谁在这支队里,devdocs/20) ──────────────────────────────────────────
/**
* 「队员」弹窗:一条**纯团体报名**挂着哪些运动员。
*
* 为什么单开一个弹窗、入口放在姓名格旁边,而不是塞进「成员单位与份额」那个弹窗:
* - 份额管的是「这条成绩分给哪些单位」,队员管的是「这条报名里有哪几个人」,是两条正交的线,
* 两个都是**整组替换**的写请求;塞进一个弹窗就得让一个保存按钮同时对应两张表;
* - 入口贴着「运动员 / 队」那一列 —— 队里坐着谁本来就写在这一格上,单位格已经归份额与联队了;
* - 常规流程不受影响:只有纯团体行(没有运动员的那种)才有这个按钮,
* 队员为空(老数据)时表格里的文案一个字符都没变。
*/
interface AthleteEditor {
entryId: number
/** 展示用的报名身份:姓名 / 队员名单 + 号码布,与成员单位弹窗同一口径。 */
label: string
/** 这条报名的主单位:候选队员只看本单位的人(名单里已有的跨单位队员仍然看得见)。 */
teamId: number
teamName: string
original: AthleteDraftRow[]
draft: AthleteDraftRow[]
/** 候选搜索原文:队员多的单位靠它找人。 */
search: string
saving: boolean
error: string | null
}
const athleteEditor = ref<AthleteEditor | null>(null)
/** 候选(本单位启用中的运动员)+ 名单里已有的(含已停用 / 跨单位),勾选状态跟着草稿走。 */
const athleteEditorChoices = computed(() => {
const editor = athleteEditor.value
if (editor === null) return []
const keyword = editor.search.trim().toLowerCase()
const rows = athleteChoiceRows(baseData.athletes.value, editor.draft, editor.teamId)
if (keyword === '') return rows
return rows.filter(
(row) =>
row.name.toLowerCase().includes(keyword) ||
row.employeeNo.toLowerCase().includes(keyword) ||
(row.bib ?? '').toLowerCase().includes(keyword),
)
})
const athleteEditorCount = computed(() => athleteEditor.value?.draft.length ?? 0)
const athleteEditorDirty = computed(() => {
const editor = athleteEditor.value
return editor === null ? false : athleteDraftDirty(editor.draft, editor.original)
})
const athleteEditorIssues = computed(() => athleteDraftIssues(athleteEditor.value?.draft ?? []))
const canSaveAthletes = computed(
() =>
canWrite.value &&
athleteEditor.value !== null &&
!athleteEditor.value.saving &&
athleteEditorDirty.value &&
athleteEditorIssues.value.length === 0,
)
/** 队员还没存上时这一行在表格里长什么样:提示里念一遍,改完一眼能核对。 */
const athletePreviewText = computed(() => {
const editor = athleteEditor.value
if (editor === null) return ''
const text = editor.draft.map(entryAthleteText).join('、')
return text === '' ? '(无队员,只报单位)' : text
})
/** 姓名格旁边那个按钮的悬停提示:把「点了会怎样」说清楚。 */
function athleteCellTitle(row: EditableRow): string {
const head = athleteCellName(row)
return head === '—'
? '点击登记这条报名的队员(个人分与个人奖牌按队员算)'
: `${head}|点击修改这条报名的队员(个人分与个人奖牌按队员算)`
}
async function openAthleteEditor(row: EditableRow): Promise<void> {
athleteEditor.value = {
entryId: row.entryId,
label: `${athleteCellName(row)}${inlineBibLabel(row.bib) === '' ? '' : ` · 号码 ${inlineBibLabel(row.bib)}`}`,
teamId: row.teamId,
teamName: row.teamName,
original: athleteDraftOf(row),
draft: athleteDraftOf(row),
search: '',
saving: false,
error: null,
}
// 候选队员来自基础数据的运动员名单;录入页平时不加载这一份,打开时补一次(已加载时不会重复请求)。
await baseData.load()
}
function closeAthleteEditor(): void {
athleteEditor.value = null
}
/**
* 勾上 / 取消一名队员;草稿的顺序就是「队里有谁」的排列,写进去的 sort_order 跟着它。
*
* 参数类型写成 `globalThis.Event`:这个文件里的 `Event` 是小项(`@/shared` 的 Event),
* 直接写 `Event` 会把 DOM 事件类型遮住。
*/
function onAthleteChoice(
athleteId: number,
name: string,
bib: string | null,
event: globalThis.Event,
): void {
const editor = athleteEditor.value
const input = event.target
if (editor === null || !(input instanceof HTMLInputElement)) return
editor.draft = input.checked
? addAthleteDraft(editor.draft, { id: athleteId, name, bib })
: removeAthleteDraft(editor.draft, athleteId)
}
function editAthleteSearch(event: InputEvent): void {
const editor = athleteEditor.value
const input = event.target
if (editor === null || !(input instanceof HTMLInputElement)) return
editor.search = input.value
}
/**
* 保存队员(整组替换),**不整表重拉**:队员只决定这条报名的分记给谁,
* 不影响任何一行的成绩与名次(重拉会把没保存的成绩编辑一并覆盖掉)。
* 服务端在同一事务里重算本组名次与流水,所以保存后榜单已经是新口径。
*/
async function saveAthleteEditor(): Promise<void> {
const editor = athleteEditor.value
if (editor === null || !canSaveAthletes.value) return
if (data.isMock) {
const notice = sampleDataBlockedNotice('保存队员')
toast.error(notice.title, notice.detail)
return
}
editor.saving = true
editor.error = null
const payload = athleteDraftPayload(editor.draft)
const row = allRows.value.find((item) => item.entryId === editor.entryId)
try {
const outcome = await submitEntryAthletes(editor.entryId, payload)
// 服务端回显优先(它按 sort_order 排过、姓名与号码布是库里的);响应里没有时按手里的草稿。
const saved = outcome.athletes ?? editor.draft
if (row !== undefined) row.athletes = saved
closeAthleteEditor()
void data.refreshStandings()
if (row !== undefined) {
toast.success('队员已保存', `${teamCellText(row)}:${athleteCellName(row)}(本组已重算,大屏榜单会跟着刷新)`)
}
} catch (caught) {
editor.error = caught instanceof Error ? caught.message : '保存队员失败'
} finally {
editor.saving = false
}
}
// ── 团体名次(按单位录入,只喂给团体 + 手动名次的通道) ────────────────────────
/** 把随行下发的视图按组别归位;服务端没带 categoryId 时收敛到 0 号键。 */
@@ -1756,6 +1934,9 @@ function batchRows(): EntryBatchRow[] {
return selectedRows.value.map((row) => ({
entryId: row.entryId,
name: rowLabel(row),
// 有队员关联的团体行在确认框里念**全名单**:退赛 / 移出报名动的是队里的每一个人,
// 只念首人会漏掉后面的人。没有队员关联时留空,确认框的文案与升级前逐字一致。
members: row.athletes.length > 0 ? (entryRosterText(row) ?? '') : '',
bib: row.bib,
// 单位带上队名:同一个单位的两个队都在这张表里时,确认框上要能分清删的是哪一条。
team: teamCellText(row),
@@ -2551,7 +2732,21 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</span>
</td>
<!-- 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。 -->
<td>{{ athleteCellName(row) }} <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span></td>
<td>{{ athleteCellName(row) }} <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span>
<!--
队员(devdocs/20):只有纯团体报名才有这个按钮——个人报名的身份就是那一个运动员。
老数据(没有队员关联)的文案一个字符都没变,这里只是多了一个改的入口。
-->
<button
v-if="entryAthletesEditable(row)"
class="mx-btn mx-btn-ghost mx-btn-sm athlete-edit"
type="button"
:title="athleteCellTitle(row)"
@click="openAthleteEditor(row)"
>
队员
</button>
</td>
<td class="result-cell">
<!--
已发布的行不再用 :disabled 锁(disabled 的表单控件收不到点击,
@@ -3039,6 +3234,102 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</div>
</div>
<!--
队员(谁在这支队里,devdocs/20):纯团体报名的姓名格旁边那个「队员」按钮打开。
默认摆出来的就是这条报名当前的队员——老数据(没有队员关联)打开是空名单,
不勾人的话保存按钮是灰的,点「关闭」不会发出任何请求,常规流程一点没被碰到。
-->
<div v-if="athleteEditor !== null" class="mx-modal-overlay open" @click.self="closeAthleteEditor">
<div class="mx-modal" role="dialog" aria-modal="true">
<div class="mx-modal-head">
<span class="mx-modal-title">队员(谁在这支队里)</span>
<button class="mx-modal-close" type="button" @click="closeAthleteEditor">
<MxIcon name="close" :size="16" />
</button>
</div>
<div class="mx-modal-body">
<p class="share-hint">
队员是<strong>人员关联</strong>:勾上的每个人各拿<strong>一整份</strong>个人分、各得<strong>一枚</strong>
个人奖牌(配了个人通道的项目才算),号码布是运动员的唯一标识,所以这里显示成「姓名(号码布)」——
光写姓名遇到重名会认错人。
</p>
<p class="bd-note">
正在修改:{{ athleteEditor.label }}({{ athleteEditor.teamName }});
一个都不勾 = 只报单位,与升级前完全一样。
</p>
<!-- 访客 / 断网只读:改不了就说明白原因,别让人对着灰按钮猜。 -->
<div v-if="!canWrite" class="share-readonly">
<MxIcon name="lock" :size="14" />
<span>{{ auth.readOnly ? '当前为访客只读视图,队员只能查看。' : '服务端未连接,暂时不能修改队员。' }}</span>
</div>
<div class="bd-toolbar">
<div class="bd-toolbar-left">
<div class="mx-input-wrap bd-search">
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
<input
class="mx-input"
:value="athleteEditor.search"
:disabled="!canWrite || athleteEditor.saving"
placeholder="搜索姓名 / 工号 / 号码布"
@input="editAthleteSearch"
/>
</div>
</div>
<div class="bd-toolbar-right">
<span class="bd-note">已选 {{ athleteEditorCount }} 人</span>
</div>
</div>
<!-- 人员名单读不出来时候选必然是空的,得说清是「读不到」而不是「这个单位没人」。 -->
<div v-if="baseData.error.value !== null" class="mx-badge mx-badge-warning share-error">
运动员名单没读出来({{ baseData.error.value }}):候选是空的,名单里已有的队员仍可查看与移除。
</div>
<div class="athlete-choice-list">
<label
v-for="choice in athleteEditorChoices"
:key="choice.athleteId"
class="bd-check athlete-choice-row"
>
<input
type="checkbox"
:checked="choice.picked"
:disabled="!canWrite || athleteEditor.saving"
@change="onAthleteChoice(choice.athleteId, choice.name, choice.bib, $event)"
/>
<span class="athlete-choice-name">{{ entryAthleteText(choice) }}</span>
<span class="bd-note">{{ choice.employeeNo || '—' }}</span>
<span v-if="choice.note !== ''" class="mx-badge mx-badge-neutral">{{ choice.note }}</span>
</label>
<div v-if="athleteEditorChoices.length === 0" class="bd-note athlete-choice-empty">
{{ athleteEditor.search.trim() === '' ? '该单位没有启用中的运动员' : '没有匹配的运动员' }}
</div>
</div>
<ul v-if="athleteEditorIssues.length > 0" class="issue-list">
<li v-for="(message, index) in athleteEditorIssues" :key="index" class="error">
<MxIcon name="warning" :size="14" />
<span>{{ message }}</span>
</li>
</ul>
<div v-if="athleteEditor.error !== null" class="mx-badge mx-badge-error share-error">
{{ athleteEditor.error }}
</div>
<p class="bd-note">改完这一行是:<strong>{{ athletePreviewText }}</strong></p>
<p class="bd-note">
保存后本组的名次与积分会当场重算(个人分与个人奖牌会跟着换人),大屏榜单跟着刷新。
没改动的话保存按钮是灰的,点「关闭」不会发出任何请求。
</p>
</div>
<div class="mx-modal-foot">
<button class="mx-btn mx-btn-secondary" type="button" @click="closeAthleteEditor">
{{ athleteEditorDirty ? '放弃修改' : '关闭' }}
</button>
<button class="mx-btn mx-btn-primary" type="button" :disabled="!canSaveAthletes" @click="saveAthleteEditor">
<MxIcon name="check" :size="14" />
{{ athleteEditor.saving ? '保存中…' : '保存队员' }}
</button>
</div>
</div>
</div>
<ConfirmDialog
:open="unpublishDialog"
tone="danger"
@@ -3900,6 +4191,41 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
font-size: 11px;
color: var(--mx-t3);
}
/* 「队员」入口:紧跟在队名单后面,弱化成一个小按钮,不抢姓名与成绩的位置。 */
.athlete-edit {
margin-left: 6px;
padding: 0 6px;
font-size: 11px;
line-height: 18px;
}
/* 队员候选列表:与补报名里的队员勾选同一套观感(那套是 EntryPicker 的 scoped 样式,
作用不到这里,所以在这边按同一份排版自己写一份)。 */
.athlete-choice-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 6px;
max-height: 320px;
overflow-y: auto;
margin-bottom: 12px;
}
.athlete-choice-row {
justify-content: flex-start;
gap: 8px;
padding: 8px 10px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
}
.athlete-choice-name {
font-size: 13px;
color: var(--mx-text);
}
.athlete-choice-row .bd-note {
margin-left: auto;
}
.athlete-choice-empty {
grid-column: 1 / -1;
padding: 12px 0;
}
.hotkey-hint {
display: flex;
align-items: center;
+3 -1
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import {
ApiError,
entryRosterText,
formatDateTime,
type EntryWithResult,
type Event,
@@ -121,7 +122,8 @@ const diffRows = computed<RevisionDiffRow[]>(() => {
entryId: entry.entryId,
kind: entry.kind,
teamLabel: teamId === null ? '—' : teamLabel(teamId),
athleteLabel: info?.athleteName ?? info?.memberNames?.join('、') ?? '',
// 团体行有队员关联时写「姓名(号码布)」;没有关联时与升级前逐字一样(文字名单)。
athleteLabel: info?.athleteName ?? entryRosterText(info ?? {}) ?? '',
beforeText: describeRow(entry.before),
afterText: describeRow(entry.after),
beforePoints,
+3 -1
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import {
ApiError,
entryRosterText,
formatDateTime,
formatPoints,
type Event,
@@ -350,7 +351,8 @@ async function loadSuspected(): Promise<void> {
categoryName: entry.categoryName ?? categoryLabels.value[event.id] ?? '—',
entryId: entry.id,
teamName: teamLabel(entry.teamId),
athleteName: entry.athleteName ?? entry.memberNames?.join('、') ?? '—',
// 团体行有队员关联时写「姓名(号码布)」;没有关联时与升级前逐字一样(文字名单)。
athleteName: entry.athleteName ?? entryRosterText(entry) ?? '—',
resultText: result.resultText,
resultValue: value,
level: best.level,
+11 -2
View File
@@ -2,18 +2,22 @@
import { computed, onMounted, ref, watch } from 'vue'
import {
ApiError,
entryRosterText,
formatPoints,
isJointTeam,
jointTeamLabel,
normalizeEntryAthletes,
normalizeSquad,
normalizeTeamShares,
teamSquadLabel,
type EntryAthlete,
type EntryWithResult,
type Event,
type EventCategory,
type ResultStatus,
type TeamShareView,
} from '@/shared'
import { athleteCellName } from './entryRowDisplay'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
@@ -37,6 +41,8 @@ interface DraftRow {
teamShares: TeamShareView[]
athleteName: string
memberNames: string[] | null
/** 队员(谁在这支队里,devdocs/20):空数组 = 只报单位,显示与升级前逐字一致。 */
athletes: EntryAthlete[]
resultText: string
rank: number | null
status: ResultStatus
@@ -174,7 +180,8 @@ function rowLabel(row: DraftRow, index: number): string {
// 团体行带上队名:同一单位的两个队只差这几个字,否则两行的报错文案一模一样。
// teamShort 为空时维持原来的 #id 占位(不拿全称顶上),老数据的文案一个字都不变。
const team = row.teamShort === '' ? '' : teamSquadLabel(row.teamShort, row.squad)
const name = row.athleteName || row.memberNames?.join('、') || team || `#${row.entryId}`
// 有队员关联时念的是「姓名(号码布)」;没有关联时就是原来的文字名单,逐字一样。
const name = row.athleteName || entryRosterText(row) || team || `#${row.entryId}`
return `第 ${index + 1} 行 ${name}`
}
@@ -292,6 +299,8 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
}),
athleteName: entry.athleteName ?? '',
memberNames: entry.memberNames,
// 老服务端不下发 athletes 时按空数组(= 只报单位),显示与升级前逐字一致。
athletes: normalizeEntryAthletes(entry.athletes),
resultText: entry.result?.resultText ?? '',
rank: entry.result?.rank ?? null,
status: entry.result?.status ?? 'ok',
@@ -660,7 +669,7 @@ watch(
<!-- 单单位:填了队名就拼在单位名后面(不加括号),没填时与改造前逐字一样。 -->
<span v-else class="team-cell">{{ teamCellText(row) }}</span>
</td>
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td>
<td>{{ athleteCellName(row) }}</td>
<td class="mono">{{ row.resultText || '—' }}</td>
<td class="center mono">{{ row.rank ?? '—' }}</td>
<td class="center">{{ statusLabel(row.status) }}</td>
+279
View File
@@ -0,0 +1,279 @@
import { ApiError, normalizeEntryAthletes } from '@/shared'
import type { ApiErrorCode, Athlete, Entry, EntryAthlete, EntryAthleteInput } from '@/shared'
import { entrySquadEditable } from './entrySquad'
/**
* 团体报名的**队员**(谁在这支队里,devdocs/20)的编辑模型与写请求。
*
* 改队员过去没有入口:报完名以后队里坐着谁只能靠导入报名表再写一遍,写进去的还只是姓名文字。
* 现在队员是人员关联,因此它必须能改——换人、补人、减人、清空都对应一场比赛的现实。
*
* 网络层直接 fetch 而不是走 adminApi:与 entryShares.ts / entrySquad.ts 同一套理由
* (请求构造与校验收在一个模块里,能直接单测),CSRF 与 X-Request-Id 的补法也必须与
* shared/api.ts 的写请求保持一致,否则写操作会被中间件 403 挡回——现场只会看到「保存不了」。
*
* 编辑模型只做两件事:把这条报名**当前的队员**原样摆出来(没动过就一个请求都不发),
* 以及把草稿换算成只带 athleteId 的写侧载荷。
*/
/** 与 server/internal/httpapi/entry_athletes.go 的 maxEntryAthletes 一致。 */
export const MAX_ENTRY_ATHLETES = 30
export function entryAthletesPath(entryId: number): string {
return `/api/entries/${entryId}/athletes`
}
/**
* 这条报名的队员能不能改:只对**纯团体报名**(没有运动员的那种)放开。
*
* 与服务端同口径,也与队名(entrySquadEditable)同一条线:个人报名按人报,它的身份就是
* 那一个运动员,再挂队员等于「这条报名既是一个人又是一支队」,服务端会 400。
* 界面上先拦住,别让操作员对着一个注定失败的弹窗白点一次保存。
*/
export function entryAthletesEditable(row: { athleteId: number | null }): boolean {
return entrySquadEditable(row)
}
/**
* 编辑器里的一行:一名队员。
*
* 只存展示够用的三样:athleteId(唯一身份,写侧只发它)、姓名与号码布(上屏用)。
* 号码布空着就是没有——不编一个假的出来。
*/
export interface AthleteDraftRow {
athleteId: number
name: string
bib: string | null
}
/** 报名行里够拼出编辑器初值的那几列(只有队员关联,别的都不是编辑模型的输入)。 */
export interface AthleteSourceLike {
athletes?: readonly EntryAthlete[] | null
}
/** 打开编辑器时的初值:这条报名当前的队员(读侧已按 athleteId 去重、号码布去空白)。 */
export function athleteDraftOf(row: AthleteSourceLike): AthleteDraftRow[] {
return normalizeEntryAthletes(row.athletes).map((athlete) => ({
athleteId: athlete.athleteId,
name: athlete.name,
bib: athlete.bib,
}))
}
/** 编辑器 → 写侧载荷:**只认 athleteId**(姓名 / 号码布是服务端读回来的展示字段)。 */
export function athleteDraftPayload(draft: readonly { athleteId: number }[]): EntryAthleteInput[] {
return draft.map((row) => ({ athleteId: row.athleteId }))
}
/** 有没有改动:按 athleteId 的**顺序与集合**比——顺序会写进 sort_order,队里的排列跟着它。 */
export function athleteDraftDirty(
draft: readonly AthleteDraftRow[],
original: readonly AthleteDraftRow[],
): boolean {
if (draft.length !== original.length) return true
for (let i = 0; i < draft.length; i++) {
if (draft[i]?.athleteId !== original[i]?.athleteId) return true
}
return false
}
/**
* 编辑器当前的校验问题(能直接上屏的中文)。
*
* 服务端都会再判一次并用自己的话回 400;前端先判一遍只是为了让操作员**在点保存之前**
* 就看到哪里不对。空名单是**合法值**(=只报单位,与升级前一致),所以这里不是「至少要一个人」。
*/
export function athleteDraftIssues(draft: readonly AthleteDraftRow[]): string[] {
const issues: string[] = []
if (draft.length > MAX_ENTRY_ATHLETES) {
issues.push(`一条报名的队员最多 ${MAX_ENTRY_ATHLETES} 人,当前有 ${draft.length} 人`)
}
const seen = new Set<number>()
for (const row of draft) {
if (seen.has(row.athleteId)) {
issues.push(`「${row.name}」出现了两次:同一条报名里一个人只能占一个位置`)
}
seen.add(row.athleteId)
}
return issues
}
/** 候选 / 已选名单里的一行:勾选状态 + 这条报名已有的队员。 */
export interface AthleteChoiceRow extends AthleteDraftRow {
/** 工号 / 学号:同单位同名时唯一的可见线索。 */
employeeNo: string
/**
* 这一行为什么不在候选里(`已停用` / `其他单位` / `档案里查不到`);候选里的人是空串。
*
* 名单里已有的队员不能因为「不在候选里」就悄悄消失或显示成停用——借调来的人还在打球、
* 档案被删掉的人也已经在这条报名上,标错一句话操作员就会以为人没了、顺手把人清掉。
*/
note: string
picked: boolean
}
/**
* 弹窗里那份可勾选的名单。
*
* 候选 = 该**本单位的启用中运动员**(与补报名里选队员同一口径:报名挂在单位名下);
* 名单里已经有的队员一律列出来,哪怕人已被停用、已经换了单位、档案都已经查不到
* ——从名单里凭空消失,操作员会以为人丢了,改完保存还会顺手把人清掉。
*/
export function athleteChoiceRows(
athletes: readonly Athlete[],
draft: readonly AthleteDraftRow[],
teamId: number,
): AthleteChoiceRow[] {
const byId = new Map(athletes.map((athlete) => [athlete.id, athlete]))
const rows: AthleteChoiceRow[] = []
const listed = new Set<number>()
for (const athlete of athletes) {
if (athlete.teamId !== teamId || !athlete.active) continue
listed.add(athlete.id)
rows.push({
athleteId: athlete.id,
name: athlete.name,
bib: athlete.bib,
employeeNo: athlete.employeeNo ?? '',
note: '',
picked: draft.some((row) => row.athleteId === athlete.id),
})
}
for (const row of draft) {
if (listed.has(row.athleteId)) continue
const athlete = byId.get(row.athleteId)
rows.push({
athleteId: row.athleteId,
name: row.name === '' ? (athlete?.name ?? '') : row.name,
bib: row.bib,
employeeNo: athlete?.employeeNo ?? '',
note: athlete === undefined ? '档案里查不到' : athlete.active ? '其他单位' : '已停用',
picked: true,
})
}
return rows
}
/** 加一名队员;已经在名单里的原样返回(不改顺序,也不重复)。 */
export function addAthleteDraft(
draft: readonly AthleteDraftRow[],
athlete: Pick<Athlete, 'id' | 'name' | 'bib'>,
): AthleteDraftRow[] {
if (draft.some((row) => row.athleteId === athlete.id)) return [...draft]
return [...draft, { athleteId: athlete.id, name: athlete.name, bib: athlete.bib }]
}
export function removeAthleteDraft(
draft: readonly AthleteDraftRow[],
athleteId: number,
): AthleteDraftRow[] {
return draft.filter((row) => row.athleteId !== athleteId)
}
const KNOWN_CODES: readonly string[] = [
'AUTH_REQUIRED',
'AUTH_FAILED',
'FORBIDDEN',
'VALIDATION_FAILED',
'CONFLICT',
'STATE_INVALID',
'RATE_LIMITED',
'INTERNAL',
'NETWORK',
'TIMEOUT',
'PARSE_FAILED',
]
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function apiError(code: unknown, message: string, status: number, detail?: unknown): ApiError {
const safe: ApiErrorCode =
typeof code === 'string' && KNOWN_CODES.includes(code) ? (code as ApiErrorCode) : 'INTERNAL'
return new ApiError(safe, message, status, detail)
}
function readCsrfToken(): string | null {
if (typeof document === 'undefined') return null
const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie)
return match?.[1] === undefined ? null : decodeURIComponent(match[1])
}
function defaultRequestId(): string {
const cryptoApi: Crypto | undefined = globalThis.crypto
if (cryptoApi !== undefined && typeof cryptoApi.randomUUID === 'function') return cryptoApi.randomUUID()
return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
interface EntryAthletesResult {
ok: boolean
data?: unknown
error?: unknown
}
export interface EntryAthletesOutcome {
entry: Entry | null
/** 服务端回显的队员;老服务端 / 响应里没有时为空,调用方退回手里的草稿。 */
athletes: EntryAthlete[] | null
}
/**
* 整组替换一条报名的队员(空数组 = 清空,只报单位)。
*
* 写操作不降级到示例数据:示例数据模式下的「保存成功」会让操作员以为分已经改好了,
* 页面必须先看 data.isMock 再决定是否调用这里。
*
* 校验失败(400)时服务端回的是**给人看的中文**(个人报名不能挂队员 / 人数超限 / 队员不存在…),
* 这里原样抛出去,界面直接展示那句话——前端不自己改写服务端的措辞。
*/
export async function submitEntryAthletes(
entryId: number,
athletes: readonly EntryAthleteInput[],
requestId?: string,
): Promise<EntryAthletesOutcome> {
// 一次调用只生成一个幂等键:header 供服务端 cached 层去重,body.requestId 是同一把钥匙。
// 两处各生成一个就永远命中不了缓存,超时重试会把同一次改动落两遍。
const key = requestId ?? defaultRequestId()
const headers: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/json; charset=utf-8',
'X-Request-Id': key,
}
const token = readCsrfToken()
if (token !== null && token !== '') headers['X-CSRF-Token'] = token
let response: Response
try {
response = await fetch(entryAthletesPath(entryId), {
method: 'PUT',
headers,
credentials: 'include',
body: JSON.stringify({ athletes: athleteDraftPayload(athletes), requestId: key }),
})
} catch (caught) {
throw apiError('NETWORK', '无法连接服务端,队员没有改成功', 0, caught)
}
let body: unknown
try {
body = await response.json()
} catch (caught) {
throw apiError('PARSE_FAILED', `服务端响应无法解析(HTTP ${response.status})`, response.status, caught)
}
if (!isRecord(body) || typeof body['ok'] !== 'boolean') {
throw apiError('PARSE_FAILED', '服务端响应不是约定的 {ok,data} 结构', response.status, body)
}
const payload = body as unknown as EntryAthletesResult
if (payload.ok !== true) {
const error = isRecord(payload.error) ? payload.error : {}
const message =
typeof error['message'] === 'string' ? error['message'] : `保存队员失败(HTTP ${response.status})`
throw apiError(error['code'], message, response.status, error['detail'])
}
const data = isRecord(payload.data) ? (payload.data as unknown as Entry) : null
return {
entry: data,
athletes: data === null ? null : normalizeEntryAthletes(data.athletes),
}
}
+15 -2
View File
@@ -56,6 +56,13 @@ export interface EntryBatchRow {
entryId: number
/** 展示名:运动员姓名 → 队员首人 → 单位(与表格行文案同口径)。 */
name: string
/**
* 这条报名挂着哪些队员(`张伟(A001)、李强(A002)`);没有队员关联时缺省 / 空串。
*
* 「移出报名」「退赛」动的是**队里的每一个人**,只念首人会让操作员看不出还有谁在里面;
* 没有队员关联(老数据 / 只报单位)时留空,确认框的文案与升级前逐字一致。
*/
members?: string
bib: string | null
team: string
categoryId: number
@@ -118,9 +125,15 @@ export function entryBatchSkipNote(action: EntryBatchAction, status: EntryStatus
return `当前状态为「${status}」,跳过`
}
/** 行在确认框里的身份行:姓名|号码布|单位。 */
/**
* 行在确认框里的身份行:谁|号码布|单位。
*
* 「谁」有队员关联时写全名单(队里的每个人都在这条报名上),没有关联时就是原来的展示名——
* 老数据的这一行一个字都没变。
*/
function personLine(row: EntryBatchRow): string {
return `${row.name || '(未署名)'}|号码 ${row.bib ?? '—'}|${row.team || '未知单位'}`
const who = row.members !== undefined && row.members !== '' ? row.members : row.name || '(未署名)'
return `${who}|号码 ${row.bib ?? '—'}|${row.team || '未知单位'}`
}
/**
+16 -3
View File
@@ -6,15 +6,28 @@
* 这两件事必须能被 node --test 直接断言——`.vue` 组件跑不进测试。
*/
import { entryRosterText } from '@/shared'
import type { EntryAthlete } from '@/shared'
export interface EntryNameLike {
athleteName: string
/** 团体 / 接力行的队员名单;个人行为 null。 */
/** 团体 / 接力行的队员名单(文字);个人行为 null。 */
memberNames: string[] | null
/**
* 团体 / 接力行的**队员关联**(devdocs/20);没有关联时缺省 / 空数组。
* 有它时按「姓名(号码布)」列出来——号码布才是运动员的唯一标识,重名也分得开。
*/
athletes?: readonly EntryAthlete[] | null
}
/** 姓名主文本:个人行用姓名,团体 / 接力行回落队员名单,都没有时给占位符。 */
/**
* 姓名主文本:个人行用姓名,团体 / 接力行回落队员名单,都没有时给占位符。
*
* 有队员关联时列成 `张伟(A001)、李强(A002)`,文字名单里没档案的人接在后面;
* 没有关联(老数据 / 只报单位)时就是 memberNames 的拼法,与升级前逐字一样。
*/
export function athleteCellName(row: EntryNameLike): string {
return row.athleteName || row.memberNames?.join('、') || '—'
return row.athleteName || entryRosterText(row) || '—'
}
/**
+471
View File
@@ -0,0 +1,471 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { ApiError } from '@/shared'
import type { Athlete } from '@/shared'
import {
entryAthleteText,
entryRosterHead,
entryRosterText,
normalizeEntryAthletes,
} from '@/shared'
import {
REQUIRED_COLUMNS,
buildTeamSelectionRows,
buildTeamSelections,
emptyTeamPick,
entryRowLabel,
planTeamAthleteSync,
toggleMemberPick,
toggleTeamPick,
} from '../components/baseData/entrySelection'
import { planEntryBatch } from '../pages/entryBatch'
import { athleteCellName } from '../pages/entryRowDisplay'
import {
MAX_ENTRY_ATHLETES,
addAthleteDraft,
athleteChoiceRows,
athleteDraftDirty,
athleteDraftIssues,
athleteDraftOf,
athleteDraftPayload,
entryAthletesEditable,
entryAthletesPath,
removeAthleteDraft,
submitEntryAthletes,
} from '../pages/entryAthletes'
import type { AthleteDraftRow } from '../pages/entryAthletes'
/**
* 团体报名的「队员身份」(devdocs/20)。界面这一侧要钉住的是:
* 1. 勾了人就把 **athleteId** 一起提交上去(姓名文字不再是唯一信息);
* 2. member_names 的文字名单原样保留 —— 没档案的人只能留文字,这条路不能丢;
* 3. 已存在的团体报名能改队员,走 PUT /api/entries/{id}/athletes,带 CSRF 与同一个幂等键;
* 4. 任何显示队员的地方都念成「姓名(号码布)」;**没有队员关联的老数据一个字符都不变**。
*/
const BIB = (id: number, name: string, bib: string | null) => ({ athleteId: id, name, bib })
// ── 显示:有队员 / 没队员两种 ────────────────────────────────────────────────
test('没有队员关联(老数据)时,队名单的显示与现在逐字一样', () => {
assert.equal(entryRosterText({ memberNames: ['王芳', '刘洋'] }), '王芳、刘洋')
assert.equal(entryRosterText({ memberNames: [] }), '')
assert.equal(entryRosterText({ memberNames: null }), null)
assert.equal(entryRosterText({}), null)
assert.equal(entryRosterText({ athletes: [], memberNames: ['王芳'] }), '王芳')
// 老服务端根本不下发这个字段:一样当「没有队员」。
assert.equal(entryRosterText({ athletes: null, memberNames: ['王芳'] }), '王芳')
assert.equal(entryRosterHead({ memberNames: ['王芳', '刘洋'] }), '王芳')
assert.equal(entryRosterHead({ memberNames: null }), '')
assert.equal(entryRosterHead({}), '')
// 表格姓名列与报错行名的回落口径都一个字没变。
assert.equal(athleteCellName({ athleteName: '', memberNames: ['李四', '王五'] }), '李四、王五')
assert.equal(athleteCellName({ athleteName: '', memberNames: null }), '—')
assert.equal(athleteCellName({ athleteName: '张三', memberNames: null }), '张三')
assert.equal(entryRowLabel({ memberNames: ['王芳'], teamShort: '一司', squad: '二队' }), '王芳')
assert.equal(entryRowLabel({ memberNames: null, teamShort: '', teamName: '第一分公司' }), '第一分公司')
})
test('有队员关联时显示「姓名(号码布)」;没有号码布的人只写姓名', () => {
const athletes = [BIB(11, '张伟', 'A001'), BIB(12, '李强', null)]
assert.equal(entryAthleteText(BIB(11, '张伟', ' A001 ')), '张伟(A001)', '号码布去掉两侧空白')
assert.equal(entryAthleteText(BIB(12, '李强', null)), '李强')
assert.equal(entryAthleteText(BIB(12, '李强', ' ')), '李强')
assert.equal(entryRosterText({ athletes }), '张伟(A001)、李强')
// 文字名单与队员是同一批人时不重复列一遍(勾选报名写出来的正是这种情况)。
assert.equal(entryRosterText({ athletes, memberNames: ['张伟', '李强'] }), '张伟(A001)、李强')
assert.equal(entryRosterHead({ athletes }), '张伟(A001)')
assert.equal(athleteCellName({ athleteName: '', memberNames: null, athletes }), '张伟(A001)、李强')
assert.equal(entryRowLabel({ athleteName: '', memberNames: null, athletes, teamShort: '一司' }), '张伟(A001)')
})
// 没档案的人(临时借调、没录档案):只能留在文字名单里,显示也要看得见。
test('队员关联与文字名单并存:没有档案的人仍留在文字名单里', () => {
const athletes = [BIB(11, '张伟', 'A001')]
assert.equal(entryRosterText({ athletes, memberNames: ['张伟', '临时工小王'] }), '张伟(A001)、临时工小王')
assert.equal(entryRosterText({ athletes: [], memberNames: ['临时工小王'] }), '临时工小王')
assert.equal(entryRosterText({ athletes, memberNames: null }), '张伟(A001)')
})
test('normalizeEntryAthletes:恒为数组、丢脏行、按 athleteId 去重、号码布归一', () => {
assert.deepEqual(normalizeEntryAthletes(undefined), [])
assert.deepEqual(normalizeEntryAthletes(null), [])
assert.deepEqual(normalizeEntryAthletes('x'), [])
assert.deepEqual(
normalizeEntryAthletes([
{ athleteId: 11, name: '张伟', bib: ' A001 ' },
{ athleteId: 11, name: '张伟', bib: 'A001' },
{ athleteId: 0, name: '没有 id' },
{ name: '缺 athleteId' },
null,
]),
[BIB(11, '张伟', 'A001')],
)
assert.deepEqual(normalizeEntryAthletes([{ athleteId: 12, name: '李强' }]), [BIB(12, '李强', null)])
assert.deepEqual(normalizeEntryAthletes([{ athleteId: 12 }]), [BIB(12, '', null)])
})
// ── 补报名 / 团体报名:勾了人就把 athleteId 带上 ──────────────────────────────
const ATHLETES = [
{ id: 11, teamId: 1, name: '张伟', bib: 'A001', active: true },
{ id: 12, teamId: 1, name: '李强', bib: null, active: true },
{ id: 13, teamId: 1, name: '王芳', bib: 'A003', active: false },
{ id: 21, teamId: 2, name: '刘洋', bib: 'B001', active: true },
]
test('勾了人:buildTeamSelections 把 athleteId 带上,memberNames 仍是姓名文字', () => {
const teams = [
{ id: 1, code: 'F1' },
{ id: 2, code: 'F2' },
]
let state = toggleTeamPick(emptyTeamPick(), 1, true)
state = toggleTeamPick(state, 2, true)
state = toggleMemberPick(state, 1, 12, true)
state = toggleMemberPick(state, 1, 11, true)
state = toggleMemberPick(state, 1, 13, true)
const selections = buildTeamSelections(state, teams, ATHLETES)
assert.equal(selections[0]?.teamId, 1, '带上单位 id:提交后要靠它找回刚写进去的那条报名')
assert.deepEqual(selections[0]?.memberNames, ['张伟', '李强'], '姓名文字按列表顺序,与改造前一样')
assert.deepEqual(selections[0]?.athletes, [BIB(11, '张伟', 'A001'), BIB(12, '李强', null)], '停用的人不进名单也不进关联')
assert.deepEqual(selections[1], {
teamId: 2,
teamCode: 'F2',
memberNames: [],
athletes: [],
}, '一个都没勾的单位没有队员关联(=只报单位)')
})
test('队员进的是人员关联,不是 CSV:member_names 仍是姓名文字、列数不变', () => {
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: [
{
teamCode: 'F1',
memberNames: ['张伟', '李强'],
athletes: [BIB(11, '张伟', 'A001'), BIB(12, '李强', null)],
},
],
})
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS])
assert.deepEqual(rows[1], [
'BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '张伟/李强', '',
])
})
test('没档案的人仍走文字名单:队员关联为空时,姓名照样写进 member_names', () => {
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: [{ teamCode: 'F1', memberNames: ['临时工小王'], athletes: [] }],
})
assert.equal(rows[1]?.[11], '临时工小王', '文字名单是没档案的人唯一的登记方式')
assert.deepEqual(rows[1]?.slice(12), [''], '队伍列照旧留空')
})
test('planTeamAthleteSync:按 组别+单位+队伍 找到刚提交的那条报名,只发 athleteId', () => {
const selections = [
{ teamId: 1, teamCode: 'F1', memberNames: ['张伟'], athletes: [BIB(11, '张伟', 'A001')] },
{ teamId: 2, teamCode: 'F2', memberNames: [], athletes: [] },
{ teamId: 3, teamCode: 'F3', memberNames: ['王芳'], squad: '二队', athletes: [BIB(31, '王芳', null)] },
{ teamId: 4, teamCode: 'F4', memberNames: [], athletes: [] },
]
const entries = [
{ id: 101, categoryId: 7, teamId: 2, squad: '', athletes: [BIB(21, '刘洋', 'B001')] },
{ id: 102, categoryId: 7, teamId: 2, squad: '', athletes: [] },
{ id: 103, categoryId: 7, teamId: 3, squad: '二队' },
{ id: 104, categoryId: 8, teamId: 3, squad: '二队' },
]
assert.deepEqual(planTeamAthleteSync(selections, entries, 7), [
// 单位 1:新报的名(entries 里还没有这一条)——导入那一步刚写进去,这里不该猜 id。
// 单位 2:原来挂着队员,这次一个都没勾 → 要发空数组把关联清掉。
// 单位 3:队伍名对上才算这一条;同单位同队名的另组别那条不算。
{ entryId: 101, athletes: [] },
{ entryId: 103, athletes: [{ athleteId: 31 }] },
])
})
test('planTeamAthleteSync:没有队员要写、原来也没有关联时一个请求都不发', () => {
const selections = [{ teamId: 2, teamCode: 'F2', memberNames: [], athletes: [] }]
const entries = [{ id: 102, categoryId: 7, teamId: 2, squad: '', athletes: [] }]
assert.deepEqual(planTeamAthleteSync(selections, entries, 7), [])
// 一个单位都没勾(或组别没选)时也没有要同步的东西。
assert.deepEqual(planTeamAthleteSync([], entries, 7), [])
assert.deepEqual(planTeamAthleteSync(selections, entries, undefined), [])
})
// ── 改队员:PUT /api/entries/{id}/athletes ──────────────────────────────────
interface Captured {
url?: string
method?: string
headers?: Record<string, string>
body?: { athletes?: unknown; requestId?: unknown }
credentials?: string
}
let lastRequest: Captured = {}
let reply: () => Response = () => jsonResponse({ ok: true, data: {} })
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = {
url: String(input),
method: init?.method ?? 'GET',
headers: (init?.headers ?? {}) as Record<string, string>,
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
credentials: init?.credentials,
}
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
function withCsrfCookie<T>(run: () => Promise<T>): Promise<T> {
const original = (globalThis as { document?: unknown }).document
;(globalThis as { document?: unknown }).document = { cookie: 'a=1; csrf_token=tok-9; b=2' }
return run().finally(() => {
if (original === undefined) delete (globalThis as { document?: unknown }).document
else (globalThis as { document?: unknown }).document = original
})
}
test('改队员走 PUT /api/entries/{id}/athletes,带 CSRF 与同一个幂等键,body 只认 athleteId', async () => {
await withCsrfCookie(async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: { id: 7, athletes: [BIB(12, '李强', 'A002'), BIB(11, '张伟', 'A001')] },
}),
)
const outcome = await submitEntryAthletes(7, [{ athleteId: 12 }, { athleteId: 11 }])
assert.equal(entryAthletesPath(7), '/api/entries/7/athletes')
assert.equal(lastRequest.url, '/api/entries/7/athletes')
assert.equal(lastRequest.method, 'PUT')
assert.equal(lastRequest.credentials, 'include')
assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'tok-9')
assert.equal(lastRequest.headers?.['Content-Type'], 'application/json; charset=utf-8')
const headerId = lastRequest.headers?.['X-Request-Id']
assert.equal(typeof headerId, 'string', '写请求必须带幂等键(header)')
assert.equal(lastRequest.body?.requestId, headerId, 'header 与 body.requestId 是同一把钥匙')
assert.deepEqual(lastRequest.body?.athletes, [{ athleteId: 12 }, { athleteId: 11 }], '只认 athleteId,姓名 / 号码布不上行')
// 服务端回显的队员交给页面直接回填。
assert.deepEqual(outcome.athletes, [BIB(12, '李强', 'A002'), BIB(11, '张伟', 'A001')])
assert.equal(outcome.entry?.id, 7)
})
})
test('清空队员:空数组是合法载荷(=只报单位),不是「什么都没做」', async () => {
respondWith(() => jsonResponse({ ok: true, data: { id: 7, athletes: [] } }))
const outcome = await submitEntryAthletes(7, [])
assert.equal(lastRequest.method, 'PUT')
assert.deepEqual(lastRequest.body?.athletes, [])
assert.deepEqual(outcome.athletes, [])
})
test('服务端 400 的中文原文原样抛出,前端不改写', async () => {
respondWith(() =>
jsonResponse(
{
ok: false,
error: {
code: 'VALIDATION_FAILED',
message: '这条报名带着运动员(个人报名):它的身份就是那一个运动员,不能再挂队员——队员只属于「只报单位」的团体报名',
},
},
400,
),
)
await assert.rejects(
() => submitEntryAthletes(7, [{ athleteId: 11 }]),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'VALIDATION_FAILED')
assert.match(error.message, /不能再挂队员/)
return true
},
)
})
test('网络不可达时说清「没有改成功」,不能让操作员以为已经落库', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
await assert.rejects(
() => submitEntryAthletes(7, [{ athleteId: 11 }]),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'NETWORK')
assert.match(error.message, /没有改成功/)
return true
},
)
})
// ── 编辑器模型 ──────────────────────────────────────────────────────────────
function draftRow(athleteId: number, name: string, bib: string | null = null): AthleteDraftRow {
return { athleteId, name, bib }
}
function athlete(id: number, name: string, over: Partial<Athlete> = {}): Athlete {
return {
id,
name,
gender: null,
teamId: 1,
employeeNo: null,
idCard: null,
bib: null,
age: null,
active: true,
...over,
}
}
test('编辑器初值:摆出这条报名当前的队员;没动过就不发请求', () => {
const athletes = [BIB(11, '张伟', 'A001'), BIB(12, '李强', null)]
const draft = athleteDraftOf({ athletes })
assert.deepEqual(draft, [draftRow(11, '张伟', 'A001'), draftRow(12, '李强', null)])
assert.equal(athleteDraftDirty(draft, athleteDraftOf({ athletes })), false)
// 老数据(没有队员关联):打开就是空名单,一个勾都没有,等于没动过。
assert.deepEqual(athleteDraftOf({ athletes: [] }), [])
assert.deepEqual(athleteDraftOf({ athletes: null }), [])
assert.deepEqual(athleteDraftOf({}), [])
assert.deepEqual(athleteDraftPayload(draft), [{ athleteId: 11 }, { athleteId: 12 }])
})
test('加人 / 去人:顺序按点选先后,改回原样就不算改动', () => {
const original = athleteDraftOf({ athletes: [BIB(11, '张伟', 'A001')] })
let draft = addAthleteDraft(original, athlete(12, '李强', { bib: 'A002' }))
assert.deepEqual(draft, [draftRow(11, '张伟', 'A001'), draftRow(12, '李强', 'A002')])
assert.equal(athleteDraftDirty(draft, original), true)
// 同一个人加不进两次。
assert.deepEqual(addAthleteDraft(draft, athlete(12, '李强')), draft)
draft = removeAthleteDraft(draft, 12)
assert.equal(athleteDraftDirty(draft, original), false, '去掉刚加的人=回到初值')
assert.deepEqual(removeAthleteDraft(draft, 99), draft, '去掉不在名单里的人不动草稿')
})
test('勾选式增删:草稿的顺序就是「队里有谁」的排列,写进去的 sort_order 跟着它', () => {
let draft = addAthleteDraft([], athlete(11, '张伟', { bib: 'A001' }))
draft = addAthleteDraft(draft, athlete(12, '李强'))
assert.deepEqual(draft, [draftRow(11, '张伟', 'A001'), draftRow(12, '李强', null)])
assert.deepEqual(athleteDraftPayload(draft), [{ athleteId: 11 }, { athleteId: 12 }])
draft = removeAthleteDraft(draft, 11)
assert.deepEqual(draft, [draftRow(12, '李强', null)])
})
test('候选名单:本单位启用中的人才可选,名单里已有的(停用 / 借调 / 档案没了)也看得见', () => {
const draft = [draftRow(11, '张伟', 'A001'), draftRow(13, '王芳', null), draftRow(31, '临时借调', null)]
const rows = athleteChoiceRows(
[
athlete(11, '张伟', { bib: 'A001', employeeNo: 'E11' }),
athlete(12, '李强', { active: false }),
athlete(13, '王芳', { active: false }),
athlete(21, '刘洋', { teamId: 2, bib: 'B001' }),
athlete(14, '赵六', { teamId: 1, employeeNo: 'E14' }),
],
draft,
1,
)
assert.deepEqual(
rows.map((row) => `${row.name}${row.picked ? '✓' : ''}${row.note === '' ? '' : `·${row.note}`}`),
['张伟✓', '赵六', '王芳✓·已停用', '临时借调✓·档案里查不到'],
'本单位启用中的人在前,已经在名单里的仍然看得见(借调来的人还在队里)',
)
assert.equal(rows.find((row) => row.name === '张伟')?.employeeNo, 'E11')
assert.equal(rows.some((row) => row.name === '李强'), false, '停用的人不能再被勾进名单')
assert.equal(rows.some((row) => row.name === '刘洋'), false, '别的单位的人不进候选(除非已经在这条报名上)')
// 借调来的人(别的单位、仍然启用)在名单里要标明「其他单位」,不能谎报成已停用。
const borrowed = athleteChoiceRows([athlete(21, '刘洋', { teamId: 2 })], [draftRow(21, '刘洋', 'B001')], 1)
assert.deepEqual(borrowed.map((row) => `${row.name}·${row.note}`), ['刘洋·其他单位'])
})
test('校验:没有队员时合法(=只报单位);超过上限要说出来', () => {
assert.deepEqual(athleteDraftIssues([]), [])
const many = Array.from({ length: MAX_ENTRY_ATHLETES + 1 }, (_, index) =>
draftRow(index + 1, `选手${index + 1}`),
)
assert.match(athleteDraftIssues(many).join(';'), /最多 30 人/)
assert.deepEqual(athleteDraftIssues(many.slice(0, MAX_ENTRY_ATHLETES)), [])
})
test('entryAthletesEditable:只有纯团体报名(没有运动员的)能改队员', () => {
assert.equal(entryAthletesEditable({ athleteId: null }), true)
assert.equal(entryAthletesEditable({ athleteId: 11 }), false, '个人报名的身份就是那一个运动员')
})
test('批量确认框:有队员的团体行念全名单,没有队员关联的行一个字都没变', () => {
const base = { bib: null, team: '第一分公司', categoryId: 7, entryStatus: 'registered' as const }
const plan = planEntryBatch('remove', [
{ ...base, entryId: 1, name: '张伟(A001)', members: '张伟(A001)、李强(A002)' },
{ ...base, entryId: 2, name: '王芳' },
])
assert.equal(plan.changed, 2)
assert.ok(
plan.details.some((line) => line.includes('张伟(A001)、李强(A002)|号码 —|第一分公司')),
'队里的每个人都写在确认框里(删掉的是这一整条报名)',
)
assert.ok(
plan.details.some((line) => line.includes('· 王芳|号码 —|第一分公司')),
'没有队员关联的行与升级前逐字一致',
)
})
// ── 接线(SFC 挂不进 node --test,用源码契约钉住,与 entry-squad.test.ts 同一套写法) ──
const PICKER = readFileSync(new URL('../components/baseData/EntryPicker.vue', import.meta.url), 'utf8')
const ENTRY = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
const REVIEW = readFileSync(new URL('../pages/ReviewPage.vue', import.meta.url), 'utf8')
const RECORDS = readFileSync(new URL('../pages/RecordsPage.vue', import.meta.url), 'utf8')
const HISTORY = readFileSync(new URL('../pages/HistoryPage.vue', import.meta.url), 'utf8')
test('补报名接线:导入成功后再挂队员,失败不冒充「加入报名失败」', () => {
assert.match(
PICKER,
/await importSelection\(rows, event\.id\)[\s\S]{0,160}await syncTeamAthletes\(teams, category\.id\)/,
'先有报名才谈得上挂队员(athleteId 要挂到刚写出来的那条报名上)',
)
assert.match(PICKER, /planTeamAthleteSync\(selections, entries\.value, categoryId\)/)
assert.match(PICKER, /submitEntryAthletes\(item\.entryId, item\.athletes\)/)
// syncTeamAthletes 自己吞掉失败(报名已经写进去了),只单独提示队员没挂上。
assert.match(PICKER, /报名本身已经加入/)
// 示例数据模式没有真服务端:写请求发出去只会报一个假失败。
assert.match(PICKER, /if \(data\.isMock\.value\) return/)
})
test('录入页接线:只有纯团体行有「队员」入口,保存走 PUT 且不整表重拉', () => {
assert.match(ENTRY, /v-if="entryAthletesEditable\(row\)"/, '个人报名那一格不出现这个入口')
assert.match(ENTRY, /@click="openAthleteEditor\(row\)"/)
assert.match(ENTRY, /athletes: normalizeEntryAthletes\(entry\.athletes\)/, '老服务端不下发时按空数组')
assert.match(ENTRY, /const outcome = await submitEntryAthletes\(editor\.entryId, payload\)/)
// 没改动 / 有校验问题时不发请求(与成员单位弹窗同一口径)。
assert.match(ENTRY, /athleteEditorDirty\.value &&\s*athleteEditorIssues\.value\.length === 0/)
// 队员只决定分记给谁,不影响任何一行的成绩:保存后不整表重拉(会把没保存的成绩编辑冲掉)。
assert.match(ENTRY, /const saved = outcome\.athletes \?\? editor\.draft[\s\S]{0,400}void data\.refreshStandings\(\)/)
assert.doesNotMatch(ENTRY, /await submitEntryAthletes\(editor\.entryId, payload\)[\s\S]{0,400}loadEvent\(/)
})
test('显示接线:复核页 / 破纪录页 / 历史页的报名行都念队员(有队员看号码布,没队员不动)', () => {
assert.match(REVIEW, /\{\{ athleteCellName\(row\) \}\}/, '复核页运动员列走同一份口径')
assert.match(REVIEW, /row\.athleteName \|\| entryRosterText\(row\) \|\| team/, '报错文案里的行名也念队员')
assert.match(REVIEW, /athletes: normalizeEntryAthletes\(entry\.athletes\)/)
assert.match(RECORDS, /entry\.athleteName \?\? entryRosterText\(entry\) \?\? '—'/)
assert.match(HISTORY, /info\?\.athleteName \?\? entryRosterText\(info \?\? \{\}\) \?\? ''/)
})
+26 -6
View File
@@ -245,9 +245,18 @@ test('buildTeamSelections 只产出勾选单位的队员,并按列表顺序取
state = toggleMemberPick(state, 1, 11, true)
state = toggleMemberPick(state, 3, 99, true)
// 勾中的人同时带上队员关联(athleteId,devdocs/20):姓名文字照旧进 memberNames。
assert.deepEqual(buildTeamSelections(state, teams, athletes), [
{ teamCode: 'F1', memberNames: ['张伟', '李强'] },
{ teamCode: 'F2', memberNames: [] },
{
teamId: 1,
teamCode: 'F1',
memberNames: ['张伟', '李强'],
athletes: [
{ athleteId: 11, name: '张伟', bib: null },
{ athleteId: 12, name: '李强', bib: null },
],
},
{ teamId: 2, teamCode: 'F2', memberNames: [], athletes: [] },
])
assert.deepEqual(
buildTeamSelections(setPickedTeams(state, []), teams, athletes),
@@ -382,14 +391,25 @@ test('buildTeamSelections:填了队名的单位带上 squad,没填的与改
state = toggleTeamPick(state, 2, true)
state = toggleMemberPick(state, 1, 11, true)
assert.deepEqual(buildTeamSelections(state, teams, athletes), [
{ teamCode: 'F1', memberNames: ['张伟'] },
{ teamCode: 'F2', memberNames: [] },
{
teamId: 1,
teamCode: 'F1',
memberNames: ['张伟'],
athletes: [{ athleteId: 11, name: '张伟', bib: null }],
},
{ teamId: 2, teamCode: 'F2', memberNames: [], athletes: [] },
])
const withSquad = setTeamPickSquad(state, 1, ' 二队 ')
assert.deepEqual(buildTeamSelections(withSquad, teams, athletes), [
{ teamCode: 'F1', memberNames: ['张伟'], squad: '二队' },
{ teamCode: 'F2', memberNames: [] },
{
teamId: 1,
teamCode: 'F1',
memberNames: ['张伟'],
athletes: [{ athleteId: 11, name: '张伟', bib: null }],
squad: '二队',
},
{ teamId: 2, teamCode: 'F2', memberNames: [], athletes: [] },
])
})
+3 -1
View File
@@ -55,7 +55,9 @@ test('复核页:单位列同样显示「单位名 + 队名」', () => {
assert.match(REVIEW, /\{\{ teamCellText\(row\) \}\}/)
assert.doesNotMatch(REVIEW, /\{\{ row\.teamShort \|\| row\.teamName \}\}/)
assert.match(REVIEW, /squad: normalizeSquad\(entry\.squad\)/)
assert.match(REVIEW, /row\.memberNames\?\.join\('、'\) \|\| team \|\| `#\$\{row\.entryId\}`/, '报错文案里的行名带队名')
// 行名的链还是「文字名单 → 队名 → #id」,只是文字名单换成了 entryRosterText(有队员关联时
// 念「姓名(号码布)」、没有关联时就是 memberNames 的拼法,逐字不变,见 entry-athletes.test.ts)。
assert.match(REVIEW, /entryRosterText\(row\) \|\| team \|\| `#\$\{row\.entryId\}`/, '报错文案里的行名带队名')
})
test('补报名:队伍输入框只在勾上单位后出现,不勾时界面与过去完全一样', () => {
+10 -2
View File
@@ -3,6 +3,7 @@ import { computed, onUnmounted, ref, watch } from 'vue'
import { medalBoardRows } from '@/shared'
import type { EventResultRow } from '@/shared'
import { tickerRowText } from '../jointTeams'
import { rosterSuffix } from '../members'
import { displayResult } from '../results'
import { useScoreboard } from '../useScoreboard'
import { DEFAULT_ROTATE_SECONDS } from '../guards'
@@ -14,7 +15,7 @@ interface TickerItem {
label: string
}
const { standings, latestResults, announceText, detail, settings, jointTeamOf, squadOf } =
const { standings, latestResults, announceText, detail, settings, jointTeamOf, squadOf, rosterOf } =
useScoreboard()
/**
@@ -22,9 +23,16 @@ const { standings, latestResults, announceText, detail, settings, jointTeamOf, s
*
* 底栏放不下两个 logo,但两个单位的名字必须都在——只写主单位会让现场以为
* 这条成绩是它一家拿的(成员单位与队名都来自报名表,见 display/jointTeams.ts)。
*
* 团体报名挂了队员时(devdocs/20)名单接在单位名后面(`第一分公司 · 张伟 / 赵六`):
* 底栏这一行本来就是「谁拿了这一名」,队员是这句话里最该看得出来的部分;单位名不许被顶掉
* ——联队 / 队名同样要认得出。没挂队员的报名拿到空后缀,与升级前逐字一致。
*/
function chipName(row: EventResultRow): string {
return tickerRowText(row, jointTeamOf(row.entryId), squadOf(row.entryId))
return (
tickerRowText(row, jointTeamOf(row.entryId), squadOf(row.entryId)) +
rosterSuffix(rosterOf(row.entryId))
)
}
// 与单位奖牌榜页同一口径:按服务端的奖牌榜顺序(金→银→铜)取前 3,不在前端自己重排。
@@ -7,7 +7,7 @@ import { useScoreboard } from '../../useScoreboard'
import PageFrame from '../PageFrame.vue'
import TeamLogo from '../TeamLogo.vue'
const { latestResults, options, jointTeamOf, squadOf } = useScoreboard()
const { latestResults, options, jointTeamOf, squadOf, rosterOf } = useScoreboard()
// 数据口径:服务端只过滤 state='published'(不看项目 status),按发布时间倒序取前 N 个(06 第 5 节)。
const columns = computed(() =>
@@ -26,6 +26,19 @@ function teamText(row: EventResultRow): string {
function teamLogos(row: EventResultRow): (string | null)[] {
return rowTeamLogos(row, jointTeamOf(row.entryId))
}
/**
* 运动员列(devdocs/20):团体报名挂了队员就写队员名单(见 display/members.ts),
* 没挂队员的报名拿到 null,与改造前逐字一致。
*/
function athleteText(row: EventResultRow): string {
return displayName(row, rosterOf(row.entryId))
}
/** 带队员名单的行:运动员列放开折行(`.line.roster .nm`),名字长高而不是截成省略号。 */
function hasRoster(row: EventResultRow): boolean {
return rosterOf(row.entryId) !== null
}
</script>
<template>
@@ -41,10 +54,10 @@ function teamLogos(row: EventResultRow): (string | null)[] {
v-for="row in column.rows"
:key="row.entryId"
class="line"
:class="`top-${Math.min(row.rank ?? 99, 4)}`"
:class="[`top-${Math.min(row.rank ?? 99, 4)}`, { roster: hasRoster(row) }]"
>
<span class="rk">{{ row.rank ?? '—' }}</span>
<span class="nm">{{ displayName(row) }}</span>
<span class="nm">{{ athleteText(row) }}</span>
<!-- 联队两个单位的 logo 都要出现:teamLogos 普通报名时只有一个元素。 -->
<span class="team">
<TeamLogo v-for="(logo, index) in teamLogos(row)" :key="index" :path="logo" />
@@ -128,6 +141,15 @@ h3 {
text-overflow: ellipsis;
white-space: nowrap;
}
/* 团体报名的队员名单(devdocs/20):运动员列在这一页只有 110~158px(28px 字号下约 4~5.6 个汉字),
一对双打的名单(`张伟 / 赵六`)塞不进一行。这一列是「谁上了场」,截成省略号就等于没写,
而这一行下面的高度是够的(6 行 × 45px 的余量),所以带队员的行放开折行:名字长高,
不截成省略号。列宽一分不动,普通报名(没有这个类)的行观感与升级前完全一致。 */
.line.roster .nm {
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.15;
}
.team {
display: flex;
align-items: center;
+18 -1
View File
@@ -26,6 +26,7 @@ const {
refreshLiveEvents,
jointTeamOf,
squadOf,
rosterOf,
} = useScoreboard()
/**
@@ -41,6 +42,14 @@ function teamLogos(row: EventResultRow): (string | null)[] {
return rowTeamLogos(row, jointTeamOf(row.entryId))
}
/**
* 运动员列(devdocs/20):团体报名挂了队员就写队员名单,没挂队员的报名拿到 null,
* 与改造前逐字一致(见 display/members.ts)。
*/
function athleteText(row: EventResultRow): string {
return displayName(row, rosterOf(row.entryId))
}
const eventNames = computed(() => new Map(events.value.map((event) => [event.id, event.name])))
/**
@@ -96,6 +105,13 @@ const resultTexts = computed(() =>
const teamTexts = computed(() =>
boards.value.flatMap((board) => board.rows.map((row) => teamText(row))),
)
// 团体报名的队员名单(devdocs/20)同样要按实际写出来的字算:名字列原来只保证 4 个汉字,
// 一对双打的名单比它长,不传进预算就会被省略号截掉(见 fitFont.fitLiveRowFontPx 的 rosters)。
const rosterTexts = computed(() =>
boards.value.flatMap((board) =>
board.rows.filter((row) => rosterOf(row.entryId) !== null).map((row) => athleteText(row)),
),
)
// 单位名后面还要放 logo(TeamLogo 是 1em + 0.28em 间距),一个不够就按最多的那一行算。
const teamsWithLogo = computed(() =>
boards.value.some((board) => board.rows.some((row) => row.logoPath !== null && row.logoPath !== '')),
@@ -119,6 +135,7 @@ const rowFontPx = computed(() =>
teamTexts.value,
teamsWithLogo.value,
maxLogosPerRow.value,
rosterTexts.value,
),
)
@@ -179,7 +196,7 @@ onUnmounted(() => {
:class="`top-${Math.min(row.rank ?? 99, 4)}`"
>
<span class="rk">{{ row.rank ?? '—' }}</span>
<span class="nm">{{ displayName(row) }}</span>
<span class="nm">{{ athleteText(row) }}</span>
<!-- 联队两个单位的 logo 都画(teamLogos 普通报名时只有一个元素)。 -->
<span class="team">
<TeamLogo v-for="(logo, index) in teamLogos(row)" :key="index" :path="logo" />
+32 -6
View File
@@ -207,6 +207,22 @@ const MAX_TEAM_LOGOS = 8
/** 运动员列至少要留的宽度(em):4 个汉字的名字(复姓、团体成员)不该被单位名挤没。 */
const NAME_MIN_EM = 4
/**
* 运动员列**必须装下**的宽度(em)。
*
* 默认就是 4 个汉字(与改动前一致);实时赛况页把「这一屏真的会写进运动员列的队员名单」
* 传进来时按实际内容算——团体报名的队员名单(`张伟 / 赵六`)比 4 个汉字长,
* 不把它算进预算就会被省略号截成三个点,而队员正是这一行要认的人。
*
* 与单位名同一套取舍(见 fitLiveRowFontPx):名字长的代价是整屏字号小一点,比截成省略号强;
* 字号有 16px 的下限,再长的名单到头来还是截断——那时是物理上排不下,不是没算。
*/
function nameBudgetEm(rosters: readonly string[]): number {
let widest = 0
for (const text of rosters) widest = Math.max(widest, textEm(text))
return Math.max(NAME_MIN_EM, widest)
}
/** 单位列要留的 logo 宽度(em):`withLogo` 为假时是 0,否则按 logoCount 份算。 */
function logoWidthEm(withLogo: boolean, logoCount = 1): number {
if (!withLogo) return 0
@@ -265,7 +281,7 @@ export function liveNameColumnPx(
return free - Math.max(team, share)
}
/** 当前字号下这一行装得下吗:三列定宽 + 名字列至少 4 个汉字。 */
/** 当前字号下这一行装得下吗:三列定宽 + 名字列至少放得下这一屏的名单。 */
function liveRowFitsPx(
results: readonly string[],
teams: readonly string[],
@@ -273,9 +289,10 @@ function liveRowFitsPx(
fontPx: number,
withLogo: boolean,
logoCount: number,
rosters: readonly string[],
): boolean {
const name = liveNameColumnPx(results, teams, layout, fontPx, withLogo, logoCount)
return name >= NAME_MIN_EM * fontPx
return name >= nameBudgetEm(rosters) * fontPx
}
/**
@@ -287,9 +304,14 @@ function liveRowFitsPx(
* 只有 `64:21.50` 这类特别长的成绩才真的收字号。
*
* 真机缺陷二:单位列原来写死(两栏 190px / 拆两块 150px),长单位名一律省略号。单位名与成绩
* 抢的是同一行的宽度,所以一条行内网格的账要一起算:名次 + 名字(≥4 个汉字)+ 单位 + 成绩
* 装得进一行,装不下就往下收字号,直到名字列守不住 4 个汉字为止(那时由单位名省略号收场,
* 成绩与名次仍然完整)。单位名长的代价是整屏字号略小,比把单位名截成三个点强。
* 抢的是同一行的宽度,所以一条行内网格的账要一起算:名次 + 名字 + 单位 + 成绩装得进一行,
* 装不下就往下收字号,直到名字列守不住为止(那时由单位名省略号收场,成绩与名次仍然完整)。
* 单位名长的代价是整屏字号略小,比把单位名截成三个点强。
*
* 真机缺陷三(devdocs/20):团体报名的队员名单比 4 个汉字长(`张伟 / 赵六` 这类),
* 而名字列的预算原来只保证 4 个汉字——挂上队员之后,一对双打的名字会被省略号截掉。
* `rosters` 是这一屏会写进名字列的名单(**没有队员的屏传空数组**,判据与改动前逐字相同),
* 名单也算进这条账:名字写全的代价是收几个 px 的字号,比把队员截掉强。
*/
export function fitLiveRowFontPx(
rowCount: number,
@@ -298,6 +320,7 @@ export function fitLiveRowFontPx(
teams: readonly string[] = [],
teamsWithLogo = false,
logoCount = 1,
rosters: readonly string[] = [],
): number {
const spec = LIVE_LAYOUT_SPECS[layout]
const base = fitRowFontPx(rowCount, BOARD_AVAILABLE_PX, spec.ratio, spec.capPx)
@@ -306,7 +329,10 @@ export function fitLiveRowFontPx(
if (widest > 0) {
font = Math.min(font, Math.floor((spec.resultBudgetPx - RESULT_COLUMN_PAD_PX) / widest))
}
while (font > MIN_ROW_FONT_PX && !liveRowFitsPx(results, teams, layout, font, teamsWithLogo, logoCount)) {
while (
font > MIN_ROW_FONT_PX &&
!liveRowFitsPx(results, teams, layout, font, teamsWithLogo, logoCount, rosters)
) {
font--
}
return Math.max(MIN_ROW_FONT_PX, font)
+129
View File
@@ -0,0 +1,129 @@
/**
* 大屏侧的「团体报名的队员」(devdocs/20)。
*
* 大屏拿到的成绩行(分项成绩 / 实时赛况 / 底栏)里只有主单位与 `memberNames`(文字名单),
* 而**队员关联**只存在于报名表(`GET /api/events/{id}/entries` 的 `athletes`)。这一份读数
* 大屏本来就在为联队成员与队名读一次(见 display/jointTeams.ts 与 useScoreboard 的
* loadEventShares),这里把它的 athletes 收敛成 `entryId → 这一行要写的名单`,再抽成纯函数,
* 页面只负责把结果画出来。
*
* 两条硬口径:
* 1. 没有队员关联的报名(老数据、只登记文字名单的报名)**不进索引**:调用方拿到的 null
* 就是「按原来那样渲染」,于是个人姓名 / memberNames / 单位名三条老路一个字都没动;
* 2. member_names 是**显示用的文字名单**,不因为挂了队员就丢掉:里面没被队员认走的那些
* 名字(人没录档案、临时借调的)照旧接在队员后面,两者并存、互不覆盖。
*
* 只写姓名、不写号码布:号码布是**对账**用的唯一标识,写在导出件与录入页上(「姓名(号码布)」);
* 大屏的运动员列在分项成绩卡里只有约 110~158px、拆两块版式下约 240px,一个「姓名(号码布)」
* 标签本身就有约 7em,而一对双打是约 14em——写上去只能把整屏字号收到 20px 上下,
* 那是把大屏读废了(见 fitFont 的队员预算:写姓名只收几个 px,写号码布要砍掉一半)。
*/
/** 大屏侧的一名队员:athleteId 是身份,姓名与号码布是展示字段。 */
export interface DisplayEntryMember {
athleteId: number
/** 队员姓名;空串表示档案里没填名字,此时这一名队员不写进名单。 */
name: string
/** 号码布(运动员的唯一标识);空串表示档案里没填。 */
bib: string
}
/** 名单在运动员列 / 底栏里的分隔符:与 memberNames 原来的写法同一个字。 */
export const ROSTER_SEPARATOR = ' / '
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
/**
* 解析一条报名的 `athletes`。缺字段、类型不对、athleteId 非法一律当「没有这一名队员」,
* 不抛异常——老服务端根本没下发这个字段(读侧是新契约),大屏不能因此整页渲染不出来。
*
* athleteId 是身份(写侧只认它):缺了就不是一条能用的关联,宁可少写一个人也不能凭空造身份。
*/
export function readEntryAthletes(source: unknown): DisplayEntryMember[] {
if (typeof source !== 'object' || source === null) return []
const raw = (source as { athletes?: unknown }).athletes
if (!Array.isArray(raw)) return []
const out: DisplayEntryMember[] = []
for (const item of raw) {
if (typeof item !== 'object' || item === null) continue
const record = item as { athleteId?: unknown; name?: unknown; bib?: unknown }
const athleteId = record.athleteId
if (typeof athleteId !== 'number' || !Number.isInteger(athleteId) || athleteId <= 0) continue
out.push({ athleteId, name: text(record.name), bib: text(record.bib) })
}
return out
}
/** 一条报名的文字名单(`entries.memberNames`);缺字段或类型不对时是空数组。 */
export function readMemberNames(source: unknown): string[] {
if (typeof source !== 'object' || source === null) return []
const raw = (source as { memberNames?: unknown }).memberNames
if (!Array.isArray(raw)) return []
return raw.filter((item): item is string => typeof item === 'string')
}
/**
* 一条报名要写在屏幕上的名单:先按报名表顺序写**队员**,再把文字名单里没被队员认走的
* 名字接在后面。
*
* 「认走」的判据与迁移 0026 的回填、导出侧的 participantLabel 同源(那份在 Go 里):
* 文字名单里写的是**号码布**(导入规范允许这么写)或**姓名**时,都算这个人已经在队员里了,
* 不再重复写一遍。判据不一致会出现「同一个人在屏上出现两次」或「写过的人反而漏掉」。
*/
export function rosterLabels(
members: readonly DisplayEntryMember[],
names: readonly string[] = [],
): string[] {
const out: string[] = []
for (const member of members) {
if (member.name !== '') out.push(member.name)
}
const seen = new Set(out)
for (const name of names) {
const value = name.trim()
if (value === '' || seen.has(value)) continue
if (members.some((member) => value === member.bib || value === member.name)) continue
seen.add(value)
out.push(value)
}
return out
}
/**
* 一批报名 → `entryId → 名单`,**只收有队员关联的报名**。
*
* 没进表的报名,调用方拿到的 null 就是「按原来那样渲染」:普通报名的成绩行与大屏改造前
* 完全一致(连一个多余的节点都不多)。名单为空(队员都没填姓名、文字名单也没有)时同样不进表,
* 免得屏上出现一个空格子。
*/
export function entryRosterIndex(entries: readonly unknown[]): Map<number, string[]> {
const out = new Map<number, string[]>()
for (const entry of entries) {
if (typeof entry !== 'object' || entry === null) continue
const id = (entry as { id?: unknown }).id
if (typeof id !== 'number' || !Number.isInteger(id) || id <= 0) continue
const members = readEntryAthletes(entry)
if (members.length === 0) continue
const roster = rosterLabels(members, readMemberNames(entry))
if (roster.length > 0) out.set(id, roster)
}
return out
}
/** 名单的显示文案:`王五 / 赵六`。 */
export function rosterText(roster: readonly string[]): string {
return roster.join(ROSTER_SEPARATOR)
}
/**
* 底栏一行的后缀:` · 王五 / 赵六`;没有队员时是空串(一个字都不加)。
*
* 底栏只有一行字,写不下一个 logo 也写不下两行,但队员必须看得出来——写不下的是队徽,
* 不是人。单位名(含联队成员与队名)照旧在前面,队员接在后面,两者互不覆盖。
*/
export function rosterSuffix(roster: readonly string[] | null | undefined): string {
if (roster === null || roster === undefined || roster.length === 0) return ''
return ' · ' + rosterText(roster)
}
+15 -3
View File
@@ -1,5 +1,6 @@
import { formatResultText } from '@/shared'
import type { EventResultGroup, EventResultRow, ResultStatus } from '@/shared'
import { ROSTER_SEPARATOR } from './members'
/**
* 成绩行的展示口径:分项成绩页、实时赛况页、底栏轮播与积分明细覆盖层共用,
@@ -42,10 +43,21 @@ export function displayResult(row: EventResultRow): string {
return formatResultText(row.resultValue, row.unit, row.unit === 's' ? 'time' : 'none')
}
/** 运动员列:个人项目用姓名,团体项目用成员名单,都缺时退回单位简称。 */
export function displayName(row: EventResultRow): string {
/**
* 运动员列:个人项目用姓名,团体报名用**队员名单**(devdocs/20:挂上队员之后写队员,
* 没挂队员就写 memberNames 那份文字名单),都缺时退回单位简称。
*
* `roster` 是 `entryId → 名单` 里这一条报名的名单(见 display/members.ts),
* **没有队员关联的报名拿到的是 null**,三条老路(姓名 / 文字名单 / 单位名)与升级前逐字一致。
* 名单只写姓名、不写号码布,理由见 members.ts 的文件头(大屏的运动员列装不下带号码布的名字)。
*/
export function displayName(
row: EventResultRow,
roster: readonly string[] | null = null,
): string {
if (roster !== null && roster.length > 0) return roster.join(ROSTER_SEPARATOR)
if (row.athleteName !== null && row.athleteName !== '') return row.athleteName
if (row.memberNames !== null) return row.memberNames.join(' / ')
if (row.memberNames !== null) return row.memberNames.join(ROSTER_SEPARATOR)
return row.shortName ?? row.teamName
}
+255
View File
@@ -0,0 +1,255 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { parse } from 'vue/compiler-sfc'
import type { EventResultRow } from '@/shared'
import {
entryRosterIndex,
readEntryAthletes,
rosterSuffix,
rosterText,
} from '../members'
import { displayName } from '../results'
import { fitLiveRowFontPx, liveNameColumnPx, liveRowGeometry, liveTeamColumnPx, textEm } from '../fitFont'
/**
* 大屏侧的「团体报名的队员」(devdocs/20)。
*
* 大屏拿到的成绩行里只有主单位与 memberNames,**队员关联**只存在于报名表
* (`GET /api/events/{id}/entries` 的 athletes),而这一份读数大屏本来就在为联队 / 队名读
* (见 display/jointTeams.ts 与 useScoreboard 的 loadEventShares)。这里把它的 athletes 收敛成
* `entryId → 队员名单`,并抽成纯函数,页面只负责把结果画出来。
*
* 两件事必须同时成立:
* 1. **没有队员关联的报名(老数据)逐字不变**:索引里根本没有这条报名的位置,调用方拿到的
* null 就是「按原来那样渲染」,于是个人姓名 / memberNames / 单位名三条老路一个字都没动;
* 2. 挂了队员之后,即使 member_names 是空的(界面勾人时没写文字名单)也要看得出队里有谁——
* 这正是本单在大屏上要交付的东西。
*
* 号码布只写进导出件(`姓名(号码布)`),大屏只写姓名:大屏的运动员列在分项成绩卡里只有
* 约 110~158px、「拆两块」版式下约 240px,一个「姓名(号码布)」标签本身就有约 7em,
* 而一对双打的名单是约 14em——写上去只能靠把整屏字号收到 20px 上下,那是把大屏读废了。
* 见 fitFont 的队员预算用例:姓名写全的代价是收字号,号码布要的是把字号砍半。
*/
function row(over: Partial<EventResultRow> = {}): EventResultRow {
return {
entryId: 1,
rank: 1,
teamId: 1,
teamCode: 'T1',
teamName: '第一分公司',
shortName: '一司',
logoPath: null,
athleteId: null,
athleteName: null,
memberNames: null,
resultText: '21',
resultValue: 21,
unit: 'pts',
isTie: false,
status: 'ok',
...over,
}
}
/** 报名表里的一条报名:`athletes` 就是读侧下发的队员关联。 */
function entry(id: number, over: Record<string, unknown> = {}): Record<string, unknown> {
return {
id,
teamId: 1,
teamCode: 'T1',
teamName: '第一分公司',
memberNames: null,
squad: '',
...over,
}
}
test('athletes 解析:缺字段 / 类型不对一律当「没有队员关联」,不抛异常', () => {
assert.deepEqual(readEntryAthletes({}), [])
assert.deepEqual(readEntryAthletes({ athletes: null }), [])
assert.deepEqual(readEntryAthletes({ athletes: '王五' }), [])
assert.deepEqual(readEntryAthletes(null), [])
// 老服务端下发的 entry 连这个字段都没有:老数据一律走「没有队员」那条路。
assert.deepEqual(readEntryAthletes({ id: 1, athleteId: null }), [])
// athleteId 是身份(写侧只认它),缺了就不是一条能用的关联。
assert.deepEqual(readEntryAthletes({ athletes: [{ name: '王五', bib: 'A001' }] }), [])
assert.deepEqual(readEntryAthletes({ athletes: [{ athleteId: 3, name: '王五', bib: null }] }), [
{ athleteId: 3, name: '王五', bib: '' },
])
})
test('无队员关联的报名一个都不进索引(老数据走原来的渲染)', () => {
const index = entryRosterIndex([
entry(11, { memberNames: ['王磊', '赵雷'] }),
entry(12, { athletes: [] }),
entry(13, { athleteId: 5, athleteName: '张三' }),
entry(14, { athletes: [{ athleteId: 3, name: '王五', bib: 'A001' }] }),
])
assert.equal(index.has(11), false, 'member_names 是文字名单,不是队员关联')
assert.equal(index.has(12), false)
assert.equal(index.has(13), false, '个人报名自己就是那个人的身份,没有队员这回事')
assert.deepEqual(index.get(14), ['王五'])
})
test('队员名单按报名表顺序写出来;文字名单里没被认走的名字接在后面,两者并存', () => {
const index = entryRosterIndex([
entry(21, {
athletes: [
{ athleteId: 3, name: '王五', bib: 'A001' },
{ athleteId: 4, name: '赵六', bib: 'A002' },
],
// 「临时借调的老周」没录档案,认不出人:他照旧留在屏幕上的名单里,不能被队员覆盖掉。
memberNames: ['王五', '赵六', '临时借调的老周'],
}),
])
assert.deepEqual(index.get(21), ['王五', '赵六', '临时借调的老周'])
})
test('文字名单里写的是号码布时(导入规范允许)不会被重复写一遍', () => {
const index = entryRosterIndex([
entry(31, {
athletes: [
{ athleteId: 3, name: '王五', bib: 'A001' },
{ athleteId: 4, name: '赵六', bib: 'A002' },
],
memberNames: ['A001', 'A002'],
}),
])
assert.deepEqual(index.get(31), ['王五', '赵六'])
})
test('member_names 为空也写得出来(界面勾人时没写文字名单的那种报名)', () => {
const index = entryRosterIndex([
entry(41, {
athletes: [
{ athleteId: 3, name: '王五', bib: 'A001' },
{ athleteId: 4, name: '赵六', bib: '' },
],
}),
])
assert.deepEqual(index.get(41), ['王五', '赵六'], '号码布为空时只写姓名,不留空括号')
})
test('运动员列:有队员名单就写名单,没有就与升级前逐字一致', () => {
// 老数据的三条路,一个字都不许变。
assert.equal(displayName(row({ athleteName: '张三' })), '张三')
assert.equal(displayName(row({ memberNames: ['王磊', '赵雷'] })), '王磊 / 赵雷')
assert.equal(displayName(row({ memberNames: null })), '一司')
// 文字名单是空数组(老契约里出现过)时仍旧写空串:这是升级前就有的行为,本单一个字没动。
assert.equal(displayName(row({ memberNames: [], shortName: null })), '')
// 空数组(索引里没有这条报名)与大屏上的老路径同责:不许把单位名换成空白。
assert.equal(displayName(row({ memberNames: ['王磊'] }), []), '王磊')
assert.equal(displayName(row({ memberNames: null }), ['王五', '赵六']), '王五 / 赵六')
})
test('名单文案与底栏后缀:一个名字时不留分隔符', () => {
assert.equal(rosterText(['王五']), '王五')
assert.equal(rosterText(['王五', '赵六']), '王五 / 赵六')
assert.equal(rosterSuffix(['王五', '赵六']), ' · 王五 / 赵六')
assert.equal(rosterSuffix(null), '')
assert.equal(rosterSuffix([]), '')
})
// 现场量级:一对双打(「张伟 / 赵六」这类两个名字的名单)在三种版式下都要写得下。
const DOUBLES = ['张伟 / 赵六', '王五 / 赵六']
const LONG_ROSTER = ['第三保障支援分队一队 / 机关直属通信保障分队二队']
const RESULTS = ['1:06.33', '1:09.09']
const TEAMS = ['第一分公司']
const LAYOUTS = ['single', 'split', 'duo'] as const
test('队员名单进运动员列的宽度预算:名字不会被省略号截掉', () => {
for (const layout of LAYOUTS) {
const font = fitLiveRowFontPx(6, layout, RESULTS, TEAMS, false, 1, DOUBLES)
const name = liveNameColumnPx(RESULTS, TEAMS, layout, font)
// 运动员列的字号与行字号同为 1em(.nm 没有单独的 font-size),墨迹必须装得进列宽:
// 这就是「单元格不进入省略态」的判据(省略号只在 scrollWidth > clientWidth 时出现)。
assert.ok(
textEm('张伟 / 赵六') * font <= name,
`${layout}:${font}px 字号下队员名单要 ${textEm('张伟 / 赵六') * font}px,列宽只有 ${name}px`,
)
// 收字号不许把单位列挤到下限以下(那会让单位名反过来被截掉)。
assert.ok(liveTeamColumnPx(TEAMS, layout, font) >= liveRowGeometry(layout).teamFloorPx)
}
})
test('常见的双打(单位名不长)只让出几个 px 的字号,大屏不至于变小', () => {
for (const rows of [1, 3, 5, 6]) {
for (const layout of LAYOUTS) {
const withRoster = fitLiveRowFontPx(rows, layout, RESULTS, TEAMS, false, 1, ['张伟 / 赵六'])
const without = fitLiveRowFontPx(rows, layout, RESULTS, TEAMS)
assert.ok(withRoster <= without, `${layout} ${rows} 行:队员名单只该让字号`)
assert.ok(without - withRoster <= 6, `${layout} ${rows} 行:让出了 ${without - withRoster}px`)
assert.ok(withRoster >= 44, `${layout} ${rows} 行:字号只剩 ${withRoster}px`)
}
}
})
test('不带队员名单时字号与改动前逐字一致(老数据零变化)', () => {
// 与 fitFont.test.ts 里那几张表同源:多出来的第七个参数不给时要完全等价。
const expected = [56, 56, 56, 56, 53, 44, 38, 33, 29, 26]
for (let rows = 1; rows <= 10; rows++) {
assert.equal(fitLiveRowFontPx(rows, 'duo', ['1:06.33', '1:09.09', '1:10.55', '59.99']), expected[rows - 1])
assert.equal(
fitLiveRowFontPx(rows, 'duo', ['1:06.33', '1:09.09', '1:10.55', '59.99'], TEAMS, false, 1, []),
expected[rows - 1],
'空名单等于没有队员:判据与改动前一模一样',
)
}
for (const rows of [1, 3, 6]) {
for (const layout of LAYOUTS) {
assert.equal(
fitLiveRowFontPx(rows, layout, RESULTS, TEAMS, true, 2, []),
fitLiveRowFontPx(rows, layout, RESULTS, TEAMS, true, 2),
)
}
}
})
test('队员名单很长时只收字号(收到下限为止),不收成省略号', () => {
const font = fitLiveRowFontPx(6, 'single', RESULTS, TEAMS, false, 1, LONG_ROSTER)
assert.ok(font < fitLiveRowFontPx(6, 'single', RESULTS, TEAMS), '长名单要让出字号')
assert.ok(font >= 16, '字号不许低于下限(再小就 10 米外看不清)')
const name = liveNameColumnPx(RESULTS, TEAMS, 'single', font)
assert.ok(textEm(LONG_ROSTER[0]!) * font <= name, '收完字号之后名单仍要装得下')
})
// 页面契约:模板里必须真的把队员名单传下去(纯函数的用例证明不了「页面用了它」)。
function sourceOf(relative: string): string {
return readFileSync(new URL(relative, import.meta.url), 'utf8')
}
test('分项成绩页与实时赛况页把队员名单交给运动员列,并留出换行的余地', () => {
const results = sourceOf('../components/pages/EventResultsPage.vue')
assert.match(results, /displayName\(row, rosterOf\(row\.entryId\)\)/)
const live = sourceOf('../components/pages/LivePage.vue')
assert.match(live, /displayName\(row, rosterOf\(row\.entryId\)\)/)
// 实时赛况的字号预算要把名单一起算进去(第七个参数)。
assert.match(live, /fitLiveRowFontPx\([\s\S]*?rosterTexts\.value[\s\S]*?\)/)
assert.match(live, /const rosterTexts = computed\(/)
})
test('分项成绩卡的运动员列本来就窄:带队员的行允许折行,宁可长高也不截成省略号', () => {
const source = sourceOf('../components/pages/EventResultsPage.vue')
const { descriptor } = parse(source, { filename: 'EventResultsPage.vue' })
const style = descriptor.styles[0]?.content ?? ''
const rule = /\.line\.roster \.nm\s*\{([^}]*)\}/.exec(style)
assert.ok(rule !== null, '缺少 .line.roster .nm 规则')
assert.match(rule[1] ?? '', /white-space:\s*normal/, '要把 nowrap 放开才折得动')
assert.match(source, /roster: hasRoster\(row\)/, '带队员的行要挂上这个类')
})
test('底栏的队员跟着单位名一起写(联队 / 队名不许被队员顶掉)', () => {
const source = sourceOf('../components/TickerBar.vue')
assert.match(source, /rosterSuffix\(rosterOf\(row\.entryId\)\)/)
})
test('大屏为屏上的小项读一次报名表就把队员一起收下来(不多发请求)', () => {
const source = sourceOf('../useScoreboard.ts')
assert.match(source, /entryRosterIndex\(entries\)/)
assert.match(source, /function rosterOf\(entryId: number\)/)
// 重读时旧值要删掉(换人 / 清空队员之后屏上不能留着旧名单),与份额 / 队名同一套。
assert.match(source, /nextRosters\.delete\(id\)/)
})
+26 -1
View File
@@ -23,6 +23,7 @@ import {
import type { DisplayLiveFeed, DisplaySettings, DisplaySnapshot } from './guards'
import { entryShareIndex, entrySquadIndex } from './jointTeams'
import type { DisplayTeamShare } from './jointTeams'
import { entryRosterIndex } from './members'
import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce'
import { applyDisplayFonts } from './fonts'
import { liveRefreshDelay } from './live'
@@ -276,6 +277,15 @@ const entryShares = ref<Map<number, DisplayTeamShare[]>>(new Map())
*/
const entrySquads = ref<Map<number, string>>(new Map())
/**
* `entryId → 队员名单`(只收挂了队员的报名,devdocs/20)。同样搭这次请求的顺风车:
* 队员关联与份额、队名都在 `GET /api/events/{id}/entries` 的同一份读数里。
*
* 取不到(老服务端 / 请求失败 / 这条报名没有队员)就是 null = 按原来那样渲染,
* 屏上逐字不变(见 display/members.ts)。
*/
const entryRosters = ref<Map<number, string[]>>(new Map())
/** 每个小项上次成功拉到报名表的时间;为 0 表示没拉过。 */
const sharesFetchedAt = new Map<number, number>()
/** 正在飞的小项,避免定时刷新把同一个请求叠成好几份。 */
@@ -298,13 +308,16 @@ async function loadEventShares(eventId: number): Promise<void> {
const entries = await api.events.entries(eventId)
const index = entryShareIndex(entries, teams.value)
const squads = entrySquadIndex(entries)
const rosters = entryRosterIndex(entries)
const nextShares = new Map(entryShares.value)
const nextSquads = new Map(entrySquads.value)
// 重读要先删掉上次读回来的那些报名:改成单单位 / 清掉队名后,旧值不能留在屏上。
const nextRosters = new Map(entryRosters.value)
// 重读要先删掉上次读回来的那些报名:改成单单位 / 清掉队名 / 换掉队员之后,旧值不能留在屏上。
const previousIds = shareEntryIds.get(eventId) ?? []
for (const id of previousIds) {
nextShares.delete(id)
nextSquads.delete(id)
nextRosters.delete(id)
}
const ids = new Set<number>()
for (const [entryId, members] of index) {
@@ -316,9 +329,15 @@ async function loadEventShares(eventId: number): Promise<void> {
// 只有队名的报名同样要记住 id,下次重读才删得掉。
ids.add(entryId)
}
for (const [entryId, roster] of rosters) {
nextRosters.set(entryId, roster)
// 只挂了队员的报名同样要记住 id(份额与队名都没有时也删得掉)。
ids.add(entryId)
}
shareEntryIds.set(eventId, [...ids])
entryShares.value = nextShares
entrySquads.value = nextSquads
entryRosters.value = nextRosters
sharesFetchedAt.set(eventId, Date.now())
} catch {
// 静默:没有联队 / 队伍信息时屏上仍是主单位,与升级前一致。
@@ -356,6 +375,11 @@ function squadOf(entryId: number): string | null {
return entrySquads.value.get(entryId) ?? null
}
/** 某个报名项的队员名单;没挂队员 / 没读到都是 null,调用方退回原来的姓名 / 文字名单 / 单位名。 */
function rosterOf(entryId: number): string[] | null {
return entryRosters.value.get(entryId) ?? null
}
watch(sharesKey, () => {
void ensureEntryShares(sharesEventIds.value)
})
@@ -758,6 +782,7 @@ export function useScoreboard() {
teams,
jointTeamOf,
squadOf,
rosterOf,
liveFeed,
liveStale,
page,
+103
View File
@@ -0,0 +1,103 @@
import type { EntryAthlete } from './types'
/**
* 团体报名的「队员」口径(devdocs/20),前端只有这一份实现。
*
* 队员是报名上的**人员关联**:一条纯团体报名挂 0..N 个运动员,0 个 = 只报单位。
* 界面要念成同一个样子——录入页的姓名列、复核页的报名行、批量确认框、报错行名都读这里,
* 各拼一套「姓名 + 号码布」迟早会变成一处带号码布、一处不带的局面。
*
* 与 `memberNames`(纯文字名单)**并存**:文字名单是没录档案的人唯一的登记方式,
* 老数据里也只有它。所以这里的规矩是——**一个队员都没有时,输出与升级前逐字一致**。
*/
/** 号码布去空白:库里可能存着手工填进去的空格,展示时不该带出来。 */
function bibText(bib: unknown): string {
return typeof bib === 'string' ? bib.trim() : ''
}
/**
* 读侧收口:接口给的队员补齐成数组,丢掉脏行、按 athleteId 去重、号码布去空白。
*
* 老服务端不下发这个字段、库里还没跑到 0026 的迁移,都走「空数组 = 没有队员」——
* 这正是老数据本来的样子,不是容错降级。
*
* 脏数据(athleteId 非法、同一个人出现两次)在这里丢掉:队员是「谁拿到个人分」的依据,
* 界面把同一个人列两遍、或者显示一个不存在的 id,比少显示一名队员更容易误导现场。
* 真身份由服务端(entry_athletes 主键)兜着,这里只是不让界面炸。
*/
export function normalizeEntryAthletes(value: unknown): EntryAthlete[] {
if (!Array.isArray(value)) return []
const seen = new Set<number>()
const out: EntryAthlete[] = []
for (const item of value) {
if (item === null || typeof item !== 'object') continue
const row = item as Partial<EntryAthlete>
const athleteId = row.athleteId
if (typeof athleteId !== 'number' || !Number.isInteger(athleteId) || athleteId <= 0) continue
if (seen.has(athleteId)) continue
seen.add(athleteId)
out.push({
athleteId,
name: typeof row.name === 'string' ? row.name : '',
bib: bibText(row.bib) === '' ? null : bibText(row.bib),
})
}
return out
}
/**
* 一名队员的展示文案:`张伟(A001)`。
*
* 带号码布是因为**号码布才是运动员的唯一标识**(姓名会重名,两个「张伟」在同一支队里
* 光看名字分不出谁是谁);人没录号码布时就只写姓名——不编一个假的号码布出来。
*/
export function entryAthleteText(athlete: EntryAthlete): string {
const bib = bibText(athlete.bib)
const name = typeof athlete.name === 'string' ? athlete.name : ''
return bib === '' ? name : `${name}(${bib})`
}
export interface EntryRosterLike {
athletes?: readonly EntryAthlete[] | null
memberNames?: readonly string[] | null
}
/**
* 报名行「队里都有谁」的文案。
*
* 有队员关联:`张伟(A001)、李强(A002)`,再把 memberNames 里**没有档案**的人接在后面
* (文字名单里有、队员关联里没有的名字=这个人只能留文字)。文字名单里的人已经在队员里
* 时不重复列一遍——勾选报名写出来的正是「两份都有同一批人」。
*
* 一个队员都没有(老数据 / 只报单位):**原样返回 memberNames 的拼法**,连「空数组拼出空串」
* 这个细节都一致,界面上一个字符都不会变。
*
* 两者都没有时返回 null(而不是空串),让调用方沿用自己原来的回落(占位符 / 单位名 / `#id`)。
*/
export function entryRosterText(row: EntryRosterLike): string | null {
const athletes = normalizeEntryAthletes(row.athletes)
if (athletes.length === 0) {
return row.memberNames ? row.memberNames.join('、') : null
}
const shown = athletes.map(entryAthleteText)
const covered = new Set(athletes.map((athlete) => athlete.name))
for (const name of row.memberNames ?? []) {
if (covered.has(name)) continue
shown.push(name)
}
return shown.join('、')
}
/**
* 报名行「人」的**首人**文案:一行只念一个人时用(报错行名、批量确认框的单行身份)。
*
* 有队员关联时是首名队员(`张伟(A001)`,号码布让重名的两个人分得开);
* 没有关联时就是 memberNames 的第一个人——与升级前的口径逐字一致(那时也只取首人)。
* 都没有时返回空串,由调用方决定回落什么。
*/
export function entryRosterHead(row: EntryRosterLike): string {
const athletes = normalizeEntryAthletes(row.athletes)
if (athletes.length > 0) return entryAthleteText(athletes[0] as EntryAthlete)
return row.memberNames?.[0] ?? ''
}
+7
View File
@@ -38,6 +38,13 @@ export {
teamSharePayload,
} from './jointTeams'
export type { TeamShareView } from './jointTeams'
export {
entryAthleteText,
entryRosterHead,
entryRosterText,
normalizeEntryAthletes,
} from './entryAthletes'
export type { EntryRosterLike } from './entryAthletes'
export { normalizeSquad, teamSquadLabel } from './squad'
export {
breakdownAthleteName,
+28
View File
@@ -234,6 +234,25 @@ export interface EntryTeamShare {
teamName?: string
}
/**
* 团体报名上的一名**队员**(devdocs/20):这条报名挂在谁名下的人。
*
* `athleteId` 是唯一身份——认人看**号码布**(`bib`),姓名会重名,所以姓名只是展示用的;
* 与联队那套(`EntryTeamShare`)对称而语义相反:那边是一条报名多个**单位**、按权重**拆**分,
* 这边是一条报名多个**运动员**、每人各拿**整份**分与各自的一枚奖牌。
*/
export interface EntryAthlete {
athleteId: number
name: string
/** 号码布:运动员的唯一标识,展示成「姓名(号码布)」;没有档案号码布时为 null。 */
bib: string | null
}
/** 写侧的一名队员:只认 athleteId(姓名 / 号码布是读侧的展示字段,服务端不二次猜人)。 */
export interface EntryAthleteInput {
athleteId: number
}
export interface Entry {
id: number
eventId: number
@@ -267,6 +286,15 @@ export interface Entry {
* 联队是多条(外层 `teamId` 恒为主单位)。老服务端不下发这个字段,收口在 normalizeTeamShares。
*/
teamShares?: EntryTeamShare[]
/**
* 这条报名的**队员**(团队 / 对抗项目里坐着谁,devdocs/20):读侧恒为数组,
* 空数组 = 只报单位(升级前所有团体报名的样子)。老服务端不下发这个字段,
* 读侧一律收口到 normalizeEntryAthletes,界面按「没有队员」处理。
*
* 与 `memberNames` 是**两件事、可以并存**:队员指向人员档案(算个人分 / 个人奖牌靠它),
* memberNames 只是显示用的文字名单——没录档案的人(临时借调)只能留文字,老数据也只有它。
*/
athletes?: EntryAthlete[]
}
export interface EntryWithResult extends Entry {