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:
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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 || '未知单位'}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) || '—'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 \?\? \{\}\) \?\? ''/)
|
||||
})
|
||||
@@ -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: [] },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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('补报名:队伍输入框只在勾上单位后出现,不勾时界面与过去完全一样', () => {
|
||||
|
||||
Reference in new issue
Block a user