feat: 小项类型加「个人+团体」+ 报名导入拆团体/个人两部分

小项类型:
- 迁移 0016 给 events.event_type 的 CHECK 增加 individual_team。SQLite 改不了 CHECK,
  而 events 被多张子表引用(含 ON DELETE CASCADE/SET NULL),事务内 DROP 重建会级联删数据,
  故用 writable_schema 就地改写建表语句文本(只追加一个枚举值,'match' 在建表语句里仅出现一次,
  无副作用),零数据搬动
- domain 加 EventTypeIndividualTeam;管理端下拉加「个人+团体」并提示会同时走两条通道;
  guards/rules 的 eventType 白名单同步(否则会被降级成 individual)
- 注意:该类型目前只解决「能被选出来」,双计的算分语义(个人分不计入单位、团体按队内平均排名)
  尚未实现,等口径确认后再动数据模型

报名导入:
- 卡片拆成「个人报名 / 团体报名」两个 tab,小项+组别提到 tab 之上共享
- 团体报名 = 从已配置单位清单里多选(默认全不勾,正好表达「部分单位不参加」),
  可逐单位为登记队员(0 人即纯单位报名),一次提交为多个单位各建一条报名
- 已报名单位仍可覆盖名单,但会提示「提交将清空原名单」,且「全选」只勾启用中且未报名的单位

自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 117/117;console 16 文件通过。
This commit is contained in:
AskaEth committed 2026-09-10 11:19:31 +08:00
1 parent b5f40642ff
commit 592e47aef7
19 files changed
+1318 -117

No files matched your search

@@ -99,22 +99,169 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
return rows
}
const names = input.memberNames ?? input.athletes.map((athlete) => athlete.name)
rows.push([
input.eventCode,
input.categoryName,
input.teamCode,
'',
'',
'',
'',
'',
'',
return buildTeamSelectionRows({
eventCode: input.eventCode,
categoryName: input.categoryName,
status,
names.join('/'),
])
teams: [{ teamCode: input.teamCode, memberNames: names }],
})
}
/** 一个单位的团体报名:只带单位编码与可选队员名单。 */
export interface TeamSelection {
teamCode: string
/** 队员姓名;空数组表示只报单位,名单之后可以再补。 */
memberNames: string[]
}
export interface TeamSelectionInput {
eventCode: string
categoryName: string
status?: string
/** 一次提交可以覆盖多个单位,勾了哪些单位就产出哪些行。 */
teams: TeamSelection[]
}
/**
* 多单位团体报名:每个单位一行,队员姓名进 member_names。
*
* 服务端对 athlete_name 为空的行只按 (组别, 单位, athlete_id IS NULL) upsert,
* 所以同组别 + 单位仍然只留一条报名,重复提交是更新(队员留空时即清空)队员名单而不是新增。
*/
export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] {
const status = input.status ?? 'registered'
const rows: string[][] = [[...REQUIRED_COLUMNS]]
for (const team of input.teams) {
rows.push([
input.eventCode,
input.categoryName,
team.teamCode,
'',
'',
'',
'',
'',
'',
status,
team.memberNames.join('/'),
])
}
return rows
}
// ── 团体多单位勾选:状态与产出 ──────────────────────────────────────────────
/**
* 团体报名的勾选状态:哪些单位参加 + 每个单位要登记的队员。
*
* 单位与队员分开存:取消勾选单位不清队员名单,重新勾上即恢复,避免手滑丢名单;
* 产出(buildTeamSelections)只取已勾选单位的队员。
*/
export interface TeamPickState {
teamIds: number[]
memberIds: Record<number, number[]>
}
export function emptyTeamPick(): TeamPickState {
return { teamIds: [], memberIds: {} }
}
/** 勾选 / 取消一个要参加的单位。 */
export function toggleTeamPick(state: TeamPickState, teamId: number, picked: boolean): TeamPickState {
const teamIds = picked
? state.teamIds.includes(teamId)
? state.teamIds
: [...state.teamIds, teamId]
: state.teamIds.filter((id) => id !== teamId)
return { ...state, teamIds }
}
/** 勾选 / 取消某单位的一名队员;单位没勾选也可以先登记名单。 */
export function toggleMemberPick(
state: TeamPickState,
teamId: number,
athleteId: number,
picked: boolean,
): TeamPickState {
const current = state.memberIds[teamId] ?? []
const next = picked
? current.includes(athleteId)
? current
: [...current, athleteId]
: current.filter((id) => id !== athleteId)
return { ...state, memberIds: { ...state.memberIds, [teamId]: next } }
}
/** 全选 / 清空单位勾选(传空数组即清空);已登记的队员名单保留。 */
export function setPickedTeams(state: TeamPickState, teamIds: readonly number[]): TeamPickState {
return { ...state, teamIds: [...teamIds] }
}
/** 「全选」的范围:启用中且还没报名的单位。已停用单位要单独勾,避免批量报错。 */
export function selectableTeamIds(
teams: readonly { id: number; active: boolean }[],
registered: ReadonlySet<number>,
): number[] {
return teams.filter((team) => team.active && !registered.has(team.id)).map((team) => team.id)
}
/**
* 勾选状态 → 提交用的单位列表。
*
* 单位按传入顺序(即界面列表顺序)产出,队员也只按运动员列表顺序取启用中的人,
* 保证同一份勾选每次生成的 member_names 完全一致。
*/
export function buildTeamSelections(
state: TeamPickState,
teams: readonly { id: number; code: string }[],
athletes: readonly { id: number; teamId: number; name: string; active: boolean }[],
): 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)
selections.push({ teamCode: team.code, memberNames })
}
return selections
}
/** 多单位团体报名是否放行:至少要勾一个要参加的单位(队员可以为 0 人)。 */
export function teamSelectionAllowsSubmit(pickedTeamCount: number): boolean {
return pickedTeamCount > 0
}
/** 组别下已报名的单位 id;团体行按 组别 + 单位 判断,与列表里的「已报名」徽标同源。 */
export function registeredTeamIds(
entries: readonly { categoryId: number; teamId: number }[],
categoryId: number | undefined,
): Set<number> {
const ids = new Set<number>()
if (categoryId === undefined) return ids
for (const entry of entries) {
if (entry.categoryId === categoryId) ids.add(entry.teamId)
}
return ids
}
/** 组别 + 单位下已报名的运动员 id;团体行 athleteId 为空,不会进集合。 */
export function registeredAthleteIds(
entries: readonly { categoryId: number; teamId: number; athleteId: number | null }[],
categoryId: number | undefined,
teamId: number | undefined,
): Set<number> {
const ids = new Set<number>()
if (categoryId === undefined || teamId === undefined) return ids
for (const entry of entries) {
if (entry.categoryId !== categoryId || entry.teamId !== teamId) continue
if (entry.athleteId !== null) ids.add(entry.athleteId)
}
return ids
}
/** 转成 CSV 文本;服务端用标准 csv 解析器读取,转义必须符合 RFC 4180。 */
export function toCsv(rows: string[][]): string {
return rows.map((row) => row.map(escapeField).join(',')).join('\n')