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('补报名:队伍输入框只在勾上单位后出现,不勾时界面与过去完全一样', () => {