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

+179
View File
@@ -5,8 +5,18 @@ import {
OPTIONAL_COLUMNS,
REQUIRED_COLUMNS,
buildSelectionRows,
buildTeamSelectionRows,
buildTeamSelections,
emptyTeamPick,
entryRowLabel,
registeredAthleteIds,
registeredTeamIds,
selectableTeamIds,
selectionAllowsSubmit,
setPickedTeams,
teamSelectionAllowsSubmit,
toggleMemberPick,
toggleTeamPick,
toCsv,
} from '../components/baseData/entrySelection'
@@ -114,6 +124,175 @@ test('团体 / 对抗项目 0 勾选可提交,个人项目必须勾人', () =>
assert.equal(selectionAllowsSubmit('individual', 1), true)
})
// 团体报名改成「一次勾多个单位,每个单位一条报名」:产出必须与勾选顺序、列表顺序都稳定。
test('buildTeamSelectionRows 多个单位各一行,队员名单按 / 拼接,0 队员留空', () => {
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: [
{ teamCode: 'F1', memberNames: ['王芳', '刘洋'] },
{ teamCode: 'F2', memberNames: [] },
{ teamCode: 'F3', memberNames: ['张伟'] },
],
})
assert.equal(rows.length, 4, '表头 + 3 个单位')
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
assert.equal(rows[1]?.length, 11)
for (const row of rows.slice(1)) {
assert.equal(row[3], '', '团体行的 athlete_name 必须为空,服务端才按单位 upsert')
}
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', 'registered', '王芳/刘洋'])
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'registered', ''])
assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', 'registered', '张伟'])
})
test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可覆盖', () => {
const empty = buildTeamSelectionRows({ eventCode: 'BB', categoryName: '混合组', teams: [] })
assert.equal(empty.length, 1, '不勾单位就不产出数据行')
const scratched = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
status: 'scratched',
teams: [{ teamCode: 'F1', memberNames: [] }],
})
assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', 'scratched', ''])
})
test('toggleTeamPick 勾选 / 取消单位,取消不清队员名单', () => {
let state = emptyTeamPick()
state = toggleTeamPick(state, 1, true)
state = toggleTeamPick(state, 2, true)
state = toggleMemberPick(state, 1, 11, true)
assert.deepEqual(state.teamIds, [1, 2])
assert.deepEqual(toggleTeamPick(state, 2, true).teamIds, [1, 2], '重复勾选幂等')
state = toggleTeamPick(state, 1, false)
assert.deepEqual(state.teamIds, [2])
assert.deepEqual(state.memberIds[1], [11], '取消勾选保留队员名单')
assert.deepEqual(toggleTeamPick(state, 1, true).memberIds[1], [11], '重新勾选恢复原名单')
})
test('toggleMemberPick 单位没勾选也能先登记队员,各单位名单互不影响', () => {
let state = toggleMemberPick(emptyTeamPick(), 3, 31, true)
state = toggleMemberPick(state, 3, 32, true)
state = toggleMemberPick(state, 4, 41, true)
assert.deepEqual(state.teamIds, [], '只登记队员不会顺手勾上单位')
assert.deepEqual(state.memberIds[3], [31, 32])
assert.deepEqual(state.memberIds[4], [41])
assert.deepEqual(toggleMemberPick(state, 3, 31, true).memberIds[3], [31, 32], '重复勾选幂等')
state = toggleMemberPick(state, 3, 31, false)
assert.deepEqual(state.memberIds[3], [32])
assert.deepEqual(state.memberIds[4], [41], '改一个单位的名单不动其它单位')
})
test('setPickedTeams 全选 / 清空只改单位勾选,保留已登记的队员', () => {
let state = toggleMemberPick(emptyTeamPick(), 1, 11, true)
state = setPickedTeams(state, [1, 2, 3])
assert.deepEqual(state.teamIds, [1, 2, 3])
assert.deepEqual(state.memberIds[1], [11])
state = setPickedTeams(state, [])
assert.deepEqual(state.teamIds, [])
assert.deepEqual(state.memberIds[1], [11], '清空后名单还在,重新勾上即恢复')
})
test('selectableTeamIds 只含启用中且未报名的单位', () => {
const teams = [
{ id: 1, active: true },
{ id: 2, active: false },
{ id: 3, active: true },
]
assert.deepEqual(selectableTeamIds(teams, new Set([3])), [1], '已停用与已报名的都不进「全选」')
assert.deepEqual(selectableTeamIds(teams, new Set()), [1, 3])
assert.deepEqual(selectableTeamIds(teams, new Set([1, 3])), [], '全报过之后全选为空')
assert.deepEqual(selectableTeamIds([], new Set([1])), [])
})
test('buildTeamSelections 只产出勾选单位的队员,并按列表顺序取启用中的人', () => {
const teams = [
{ id: 1, code: 'F1' },
{ id: 2, code: 'F2' },
{ id: 3, code: 'F3' },
]
const athletes = [
{ id: 11, teamId: 1, name: '张伟', active: true },
{ id: 12, teamId: 1, name: '李强', active: true },
{ id: 13, teamId: 1, name: '王芳', active: false },
{ id: 21, teamId: 2, name: '刘洋', active: true },
]
let state = toggleTeamPick(emptyTeamPick(), 2, true)
state = toggleTeamPick(state, 1, true)
state = toggleMemberPick(state, 1, 12, true)
state = toggleMemberPick(state, 1, 13, true)
state = toggleMemberPick(state, 1, 11, true)
state = toggleMemberPick(state, 3, 99, true)
assert.deepEqual(buildTeamSelections(state, teams, athletes), [
{ teamCode: 'F1', memberNames: ['张伟', '李强'] },
{ teamCode: 'F2', memberNames: [] },
])
assert.deepEqual(
buildTeamSelections(setPickedTeams(state, []), teams, athletes),
[],
'没勾单位就没有要提交的行',
)
})
test('teamSelectionAllowsSubmit 至少勾一个单位,队员可以为 0 人', () => {
assert.equal(teamSelectionAllowsSubmit(0), false, '一个单位都不勾不能提交')
assert.equal(teamSelectionAllowsSubmit(1), true, '只报单位(0 队员)可以提交')
assert.equal(teamSelectionAllowsSubmit(5), true)
})
// 端到端拼接:勾选状态 → 多单位报名行 → 送进导入接口的 CSV 文本。
// 服务端按表头名字取值,列数与转义必须和导入表一致。
test('勾选状态转成 CSV 后表头与导入表一致,每个单位一行', () => {
const teams = [
{ id: 1, code: 'F1' },
{ id: 2, code: 'F2' },
]
const athletes = [
{ id: 11, teamId: 1, name: '张,伟', active: true },
{ id: 12, teamId: 1, name: '李强', active: true },
{ id: 21, teamId: 2, name: '王芳', active: true },
]
let state = toggleTeamPick(emptyTeamPick(), 1, true)
state = toggleTeamPick(state, 2, true)
state = toggleMemberPick(state, 1, 11, true)
state = toggleMemberPick(state, 1, 12, true)
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: buildTeamSelections(state, teams, athletes),
})
assert.equal(rows[1]?.length, REQUIRED_COLUMNS.length)
assert.equal(rows[2]?.length, REQUIRED_COLUMNS.length)
const lines = toCsv(rows).split('\n')
assert.equal(lines[0], REQUIRED_COLUMNS.join(','))
assert.equal(lines.length, 3, '表头 + 两个单位')
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,registered,"张,伟/李强"', '带逗号的姓名按 RFC 4180 转义')
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,registered,', '只报单位时 member_names 留空')
})
test('registeredTeamIds 按组别取已报名单位;registeredAthleteIds 只认具体运动员', () => {
const entries = [
{ categoryId: 7, teamId: 1, athleteId: 11 },
{ categoryId: 7, teamId: 1, athleteId: 12 },
{ categoryId: 7, teamId: 2, athleteId: null },
{ categoryId: 8, teamId: 3, athleteId: 13 },
]
assert.deepEqual([...registeredTeamIds(entries, 7)].sort((a, b) => a - b), [1, 2])
assert.deepEqual([...registeredTeamIds(entries, 9)], [], '别的组别不算已报名')
assert.deepEqual([...registeredTeamIds(entries, undefined)], [], '没选组别时没有已报名')
assert.deepEqual([...registeredAthleteIds(entries, 7, 1)].sort((a, b) => a - b), [11, 12])
assert.deepEqual([...registeredAthleteIds(entries, 7, 2)], [], '团体行不带运动员')
assert.deepEqual([...registeredAthleteIds(entries, 8, 3)], [13])
assert.deepEqual([...registeredAthleteIds(entries, undefined, 1)], [])
})
// 团体 / 接力行服务端不下发 athleteName(omitempty),回落到队员 / 单位名,
// 否则防呆校验的文案会变成「:缺少成绩」。
test('entryRowLabel 团体行回落到队员首人 / 单位', () => {