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:
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')
|
||||
|
||||
Reference in new issue
Block a user