From 592e47aef7193d7606df06f6db5bc5b4312f31fb Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 10 Sep 2026 11:19:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B0=8F=E9=A1=B9=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=8A=A0=E3=80=8C=E4=B8=AA=E4=BA=BA+=E5=9B=A2=E4=BD=93?= =?UTF-8?q?=E3=80=8D+=20=E6=8A=A5=E5=90=8D=E5=AF=BC=E5=85=A5=E6=8B=86?= =?UTF-8?q?=E5=9B=A2=E4=BD=93/=E4=B8=AA=E4=BA=BA=E4=B8=A4=E9=83=A8?= =?UTF-8?q?=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 小项类型: - 迁移 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 文件通过。 --- docs/使用手册.md | 4 +- server/internal/domain/scoring.go | 1 + server/internal/domain/scoring_test.go | 35 ++ server/internal/domain/types.go | 3 + server/internal/httpapi/repo_test.go | 41 ++ server/internal/rules/diff_test.go | 60 ++ ...0016_events_event_type_individual_team.sql | 17 + server/migrations/event/migration_test.go | 239 ++++++++ web/src/admin/api/mock.ts | 37 +- .../admin/components/baseData/EntryPicker.vue | 516 ++++++++++++++---- .../baseData/SportsEventsSection.vue | 10 +- .../components/baseData/entrySelection.ts | 171 +++++- web/src/admin/tests/entry-selection.test.ts | 179 ++++++ .../admin/tests/event-type-options.test.ts | 46 ++ web/src/display/guards.ts | 2 +- .../display/tests/guards-event-type.test.ts | 29 + web/src/shared/rules.ts | 2 +- web/src/shared/tests/rules.test.ts | 41 ++ web/src/shared/types.ts | 2 +- 19 files changed, 1318 insertions(+), 117 deletions(-) create mode 100644 server/migrations/event/0016_events_event_type_individual_team.sql create mode 100644 server/migrations/event/migration_test.go create mode 100644 web/src/admin/tests/event-type-options.test.ts create mode 100644 web/src/display/tests/guards-event-type.test.ts create mode 100644 web/src/shared/tests/rules.test.ts diff --git a/docs/使用手册.md b/docs/使用手册.md index 7a6ade4..d201b87 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -261,10 +261,12 @@ ### 10.2 大项 / 小项 1. 左侧选择或新增大项(编码、名称、简称、默认积分模板、排序); -2. 右侧维护该大项下的小项:点「新增小项」,填写编码、名称、类型(个人 / 团体 / 对抗比赛)、录入方式(自动排名 / 手动名次)、成绩类型(时间 / 距离 / 计数 / 得分 / 无成绩)、比较方式(越小越好 / 越大越好 / 不比较)、状态、是否计分等; +2. 右侧维护该大项下的小项:点「新增小项」,填写编码、名称、类型(个人 / 团体 / 对抗比赛 / 个人+团体)、录入方式(自动排名 / 手动名次)、成绩类型(时间 / 距离 / 计数 / 得分 / 无成绩)、比较方式(越小越好 / 越大越好 / 不比较)、状态、是否计分等; 3. 小项行上的「配置组别」「配置计分通道」可直接跳到对应分区; 4. 删除大项 / 小项会二次确认。 +> 「个人+团体」用于团体与个人赛事合并的项目:会同时按个人通道与团体通道记分,具体通道见 10.4「计分通道」。 + ### 10.3 组别 1. 先在上方选中一个小项; diff --git a/server/internal/domain/scoring.go b/server/internal/domain/scoring.go index 3a97ea1..ba0810e 100644 --- a/server/internal/domain/scoring.go +++ b/server/internal/domain/scoring.go @@ -109,6 +109,7 @@ func ScoreEvent(in EventScoreInput) (EventScore, error) { // rankOnlyChannel 在没有计分通道时,按小项自身的录入方式推导排名口径: // manual 直接录名次,其余按成绩自动排名;名次记给个人,团体/对抗项目记给单位。 +// 「个人+团体」的双计靠两条通道表达,这条退化路径只排个人榜,故不入单位分支。 func rankOnlyChannel(in EventScoreInput) ScoreChannel { basis := BasisResult if in.Event.ResultMode == ResultModeManual { diff --git a/server/internal/domain/scoring_test.go b/server/internal/domain/scoring_test.go index 53a479f..d50046d 100644 --- a/server/internal/domain/scoring_test.go +++ b/server/internal/domain/scoring_test.go @@ -379,6 +379,41 @@ func TestRankOnlyRespectsResultMode(t *testing.T) { } } +// TestIndividualTeamDualChannel 验证「个人+团体」按配置的两条通道双计: +// 同一份成绩同时产出个人通道与团体通道的名次(类型本身不参与计算)。 +func TestIndividualTeamDualChannel(t *testing.T) { + entries, results := fixture(lineupValues(11.5, 11.8, 12.0)) + ev := individualEvent(1) + ev.EventType = EventTypeIndividualTeam + + es := mustScore(t, EventScoreInput{ + Event: ev, Category: testCategory(), + Channels: []ScoreChannel{ + athleteResultChannel(1), + {ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2}, + }, + Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()}, + Entries: entries, Results: results, + }) + + byScope := make(map[Scope]map[int64]int, 2) + for _, rr := range es.Ranked { + if byScope[rr.Scope] == nil { + byScope[rr.Scope] = make(map[int64]int, len(es.Ranked)) + } + byScope[rr.Scope][rr.EntryID] = rr.Rank + } + for _, scope := range []Scope{ScopeAthlete, ScopeTeam} { + ranks := byScope[scope] + if ranks[1] != 1 || ranks[2] != 2 || ranks[3] != 3 { + t.Fatalf("%s 通道名次 = %v,期望 1/2/3", scope, ranks) + } + } + if len(es.Ledger) != 6 { + t.Errorf("双通道流水条数 = %d,期望 6(3 条个人 + 3 条团体)", len(es.Ledger)) + } +} + // TestScoreEventErrors 覆盖配置类错误,避免错配置静默算错分。 func TestScoreEventErrors(t *testing.T) { entries, results := fixture(lineup(1)) diff --git a/server/internal/domain/types.go b/server/internal/domain/types.go index 1f8b3cd..6c2f3bb 100644 --- a/server/internal/domain/types.go +++ b/server/internal/domain/types.go @@ -12,6 +12,9 @@ const ( EventTypeIndividual EventType = "individual" EventTypeTeam EventType = "team" EventTypeMatch EventType = "match" + // EventTypeIndividualTeam 个人+团体:团体与个人赛事合并的项目,个人榜与团体榜同时记分。 + // 双计靠两条计分通道(athlete + team)表达,不在这里做特殊计算。 + EventTypeIndividualTeam EventType = "individual_team" ) // ResultMode 成绩录入方式:auto 录成绩后自动排名,manual 直接录名次。 diff --git a/server/internal/httpapi/repo_test.go b/server/internal/httpapi/repo_test.go index 53a4d98..79a3d3f 100644 --- a/server/internal/httpapi/repo_test.go +++ b/server/internal/httpapi/repo_test.go @@ -88,6 +88,47 @@ func TestEventWritesWriteChangeLog(t *testing.T) { } } +// 小项类型新增「个人+团体」:REST 写接口要能存下来并原样返回,不被当成未知类型。 +func TestEventAcceptsIndividualTeamType(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + status, env2 := env.do(client, http.MethodPost, "/api/events", map[string]any{ + "sportId": ids.sport, "code": "TEAM_PULLUP", "name": "团体引体向上", + "eventType": "individual_team", "resultType": "count", "compare": "desc", "scored": true, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("建「个人+团体」项目应 200,得到 %d %+v", status, env2.Error) + } + created := decodeData[map[string]any](t, env2) + if created["eventType"] != "individual_team" { + t.Fatalf("建回的 eventType = %v,期望 individual_team", created["eventType"]) + } + eventID := int64(created["id"].(float64)) + + status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d", eventID), nil, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("读项目应 200,得到 %d %+v", status, env2.Error) + } + if got := decodeData[map[string]any](t, env2); got["eventType"] != "individual_team" { + t.Fatalf("读回的 eventType = %v,期望 individual_team", got["eventType"]) + } + + status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), map[string]any{ + "sportId": ids.sport, "code": "TEAM_PULLUP", "name": "团体引体向上", + "eventType": "team", "resultType": "count", "compare": "desc", "scored": true, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("改回团体应 200,得到 %d %+v", status, env2.Error) + } + if got := decodeData[map[string]any](t, env2); got["eventType"] != "team" { + t.Fatalf("改回的 eventType = %v,期望 team", got["eventType"]) + } +} + // 证件号是人员的唯一标识:可读写、重复 409、空值不互斥(部分唯一索引)。 func TestAthleteIDCardUnique(t *testing.T) { env := newTestEnv(t) diff --git a/server/internal/rules/diff_test.go b/server/internal/rules/diff_test.go index e2cd67f..737d5b5 100644 --- a/server/internal/rules/diff_test.go +++ b/server/internal/rules/diff_test.go @@ -257,3 +257,63 @@ func TestResolveTargetsClearsChannelsForUnscored(t *testing.T) { } }) } + +// 「个人+团体」模板:match.eventType 支持 individual_team,并按 athlete + team 两条通道展开。 +// 校验层不认识这个取值时会直接报错,因此 Parse 成功本身就是「不报未知类型」的断言。 +const individualTeamPackYAML = `format: scoreboard-rules +formatVersion: 1 +meta: { code: demo, name: 个人+团体模板 } +standings: + individualToTeamWeight: 1 + tieBreak: [points, gold] +profiles: + - code: individual_top6 + name: 个人项目前六名 + awardRanks: 1 + tieMode: skip + rules: + - { rank: 1, points: 10 } + - code: team_top6 + name: 团体项目前六名 + awardRanks: 1 + tieMode: skip + rules: + - { rank: 1, points: 15 } +channelTemplates: + - match: { eventType: individual_team } + channels: + - { scope: athlete, basis: result, profile: individual_top6 } + - { scope: team, basis: manual, profile: team_top6 } +` + +func TestResolveTargetsIndividualTeamTemplate(t *testing.T) { + cur := testSnapshot(t, + []Profile{ + {Code: "individual_top6", Name: "个人项目前六名", AwardRanks: 1, TieMode: domain.TieSkip, Rules: []Rule{{1, 10}}}, + {Code: "team_top6", Name: "团体项目前六名", AwardRanks: 1, TieMode: domain.TieSkip, Rules: []Rule{{1, 15}}}, + }, + []EventInfo{ + {ID: 1, Code: "EVT_BOTH", Name: "团体引体向上", EventType: domain.EventTypeIndividualTeam, + ResultType: domain.ResultTypeCount, Scored: true, Multiplier: 1, + Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}}, + }) + + next, err := Parse([]byte(individualTeamPackYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + targets, _, err := resolveTargets(cur, next) + if err != nil { + t.Fatalf("resolveTargets: %v", err) + } + got := targets["EVT_BOTH"] + if len(got) != 2 { + t.Fatalf("individual_team 应展开成两条通道,实际 %+v", got) + } + if got[0].Scope != domain.ScopeAthlete || got[0].Basis != domain.BasisResult || got[0].Profile != "individual_top6" { + t.Errorf("个人通道 = %+v", got[0]) + } + if got[1].Scope != domain.ScopeTeam || got[1].Basis != domain.BasisManual || got[1].Profile != "team_top6" { + t.Errorf("团体通道 = %+v", got[1]) + } +} diff --git a/server/migrations/event/0016_events_event_type_individual_team.sql b/server/migrations/event/0016_events_event_type_individual_team.sql new file mode 100644 index 0000000..57f8412 --- /dev/null +++ b/server/migrations/event/0016_events_event_type_individual_team.sql @@ -0,0 +1,17 @@ +-- 小项类型新增「个人+团体」(individual_team):团体与个人赛事合并的项目, +-- 默认同时走个人通道与团体通道。event_type 的取值由 0004 的 CHECK 限定, +-- SQLite 改不了 CHECK,必须换一份表定义。 +-- +-- events 被多张子表引用,其中 records / record_breaks / result_revisions 是 +-- ON DELETE CASCADE,score_ledger / point_adjustments 是 ON DELETE SET NULL, +-- 而迁移在事务内执行:PRAGMA foreign_keys 关不掉,直接 DROP 又会触发级联/置空, +-- defer_foreign_keys 则会让事务在提交时报未解决的外键。因此这里沿用 SQLite 的 +-- writable_schema 就地改写 events 的建表语句——只改 schema 文本,不动任何数据。 +PRAGMA writable_schema = ON; + +UPDATE sqlite_master + SET sql = replace(sql, '''match''', '''match'', ''individual_team''') + WHERE type = 'table' AND name = 'events'; + +-- RESET 让本连接立刻按新定义重解析 schema(否则缓存里仍是旧 CHECK),并关闭 writable_schema。 +PRAGMA writable_schema = RESET; diff --git a/server/migrations/event/migration_test.go b/server/migrations/event/migration_test.go new file mode 100644 index 0000000..0b8dd19 --- /dev/null +++ b/server/migrations/event/migration_test.go @@ -0,0 +1,239 @@ +package event + +import ( + "database/sql" + "io/fs" + "path/filepath" + "sort" + "testing" + + _ "modernc.org/sqlite" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/store" + "git.yeij.top/AskaEth/Scoreboard/server/migrations" +) + +// 0016 迁移:events.event_type 增加 individual_team。events 被多张子表以级联/置空引用, +// 迁移用 writable_schema 就地改写建表语句,本文件的用例守住「数据不丢 + 新 CHECK 生效」。 + +func openEventDB(t *testing.T) *sql.DB { + t.Helper() + + path := filepath.Join(t.TempDir(), "event.db") + dsn := "file:" + path + + "?_pragma=journal_mode(WAL)" + + "&_pragma=foreign_keys(1)" + + "&_pragma=busy_timeout(5000)" + + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("打开赛事库: %v", err) + } + // 与 store.Open 一致:单连接。迁移改的是连接内的 schema 缓存,必须在这个前提下验证。 + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + return db +} + +// applyBefore 执行 0016 之前的迁移并登记到 schema_migrations, +// 模拟老库在升级前的状态(store.MigrateEvent 只能补跑未登记的迁移)。 +func applyBefore(t *testing.T, db *sql.DB, name string) { + t.Helper() + + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); err != nil { + t.Fatalf("创建 schema_migrations: %v", err) + } + fsys, err := fs.Sub(migrations.EventFS, "event") + if err != nil { + t.Fatalf("定位迁移目录: %v", err) + } + names, err := fs.Glob(fsys, "*.sql") + if err != nil { + t.Fatalf("列出迁移: %v", err) + } + sort.Strings(names) + for _, n := range names { + if n >= name { + continue + } + body, err := fs.ReadFile(fsys, n) + if err != nil { + t.Fatalf("读取迁移 %s: %v", n, err) + } + if _, err := db.Exec(string(body)); err != nil { + t.Fatalf("应用迁移 %s: %v", n, err) + } + // 与 store.migrate 一样登记,升级时才能只补跑 0016。 + if _, err := db.Exec(`INSERT INTO schema_migrations(name) VALUES (?)`, n); err != nil { + t.Fatalf("登记迁移 %s: %v", n, err) + } + } +} + +func countRows(t *testing.T, db *sql.DB, table string) int { + t.Helper() + + var n int + if err := db.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil { + t.Fatalf("统计 %s: %v", table, err) + } + return n +} + +// 全量迁移(run 两次验幂等)后,events 应接受 individual_team、拒绝未知取值。 +func TestMigrateEventAcceptsIndividualTeam(t *testing.T) { + db := openEventDB(t) + + for i := 1; i <= 2; i++ { + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("第 %d 次迁移: %v", i, err) + } + } + + if _, err := db.Exec(`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`); err != nil { + t.Fatalf("插入大项: %v", err) + } + // 原有三种类型与新增类型都要放行。 + for _, ok := range []string{"individual", "team", "match", "individual_team"} { + if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, ?, '合法', ?)`, + "OK_"+ok, ok); err != nil { + t.Errorf("event_type %q 应被接受: %v", ok, err) + } + } + for _, bad := range []string{"", "bogus", "Individual_Team"} { + if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, ?, '非法', ?)`, + "BAD_"+bad, bad); err == nil { + t.Errorf("非法 event_type %q 应被拒绝", bad) + } + } +} + +// 老库升级:升级前的数据一行都不能少,外键与索引保持有效,新类型可用。 +func TestMigration0016PreservesExistingRows(t *testing.T) { + db := openEventDB(t) + applyBefore(t, db, "0016") + + stmts := []string{ + `INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`, + `INSERT INTO sports(code, name) VALUES ('SP1', '田径')`, + `INSERT INTO events(sport_id, code, name, event_type, venue, remark) + VALUES (1, 'E1', '100米跑', 'individual', '田径场', '备注')`, + `INSERT INTO events(sport_id, code, name, event_type) VALUES (1, 'E2', '团体引体向上', 'team')`, + `INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`, + `INSERT INTO event_score_channels(event_id, scope, basis, profile_id) VALUES (1, 'athlete', 'result', 1)`, + `INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`, + `INSERT INTO results(event_id, entry_id, rank, result_text, state) VALUES (1, 1, 1, '11.58', 'published')`, + `INSERT INTO records(event_id, level, holder_name) VALUES (1, 'meet', '张三')`, + `INSERT INTO record_breaks(event_id, entry_id, level) VALUES (1, 1, 'meet')`, + `INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points) + VALUES (1, 1, 1, 1, 'result', 1, 10)`, + `INSERT INTO point_adjustments(team_id, delta, category, reason, event_id) + VALUES (1, 5, 'bonus', '测试', 1)`, + `INSERT INTO result_revisions(id, parent_id, event_id, action, snapshot_json) + VALUES (1, NULL, 1, 'publish', '{}'), (2, 1, 1, 'edit', '{}')`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } + + // 走真实升级路径:store.MigrateEvent 只补跑未登记的 0016。 + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("升级迁移: %v", err) + } + if got := countRows(t, db, "schema_migrations"); got != 16 { + t.Errorf("迁移登记数 = %d,期望 16", got) + } + + for table, want := range map[string]int{ + "events": 2, "event_categories": 1, "event_score_channels": 1, "entries": 1, + "results": 1, "records": 1, "record_breaks": 1, "score_ledger": 1, + "point_adjustments": 1, "result_revisions": 2, + } { + if got := countRows(t, db, table); got != want { + t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want) + } + } + + var ( + id int64 + code string + et string + venue sql.NullString + remark sql.NullString + ) + if err := db.QueryRow(`SELECT id, code, event_type, venue, remark FROM events WHERE id = 1`). + Scan(&id, &code, &et, &venue, &remark); err != nil { + t.Fatalf("读 events: %v", err) + } + if id != 1 || code != "E1" || et != "individual" || !venue.Valid || !remark.Valid { + t.Errorf("events 未原样保留: id=%d code=%q type=%q venue=%v remark=%v", id, code, et, venue, remark) + } + if got := countRows(t, db, "events"); got != 2 { + t.Errorf("events 行数 = %d,期望 2", got) + } + // 被 SET NULL / CASCADE 波及的两列必须回填。 + var linked int + if err := db.QueryRow(`SELECT count(*) FROM score_ledger WHERE event_id = 1`).Scan(&linked); err != nil { + t.Fatalf("查 score_ledger: %v", err) + } + if linked != 1 { + t.Errorf("score_ledger.event_id 未回填,命中 %d 行", linked) + } + if err := db.QueryRow(`SELECT count(*) FROM point_adjustments WHERE event_id = 1`).Scan(&linked); err != nil { + t.Fatalf("查 point_adjustments: %v", err) + } + if linked != 1 { + t.Errorf("point_adjustments.event_id 未回填,命中 %d 行", linked) + } + if err := db.QueryRow(`SELECT count(*) FROM result_revisions WHERE parent_id = 1`).Scan(&linked); err != nil { + t.Fatalf("查 result_revisions: %v", err) + } + if linked != 1 { + t.Errorf("result_revisions 父子关系未保留,子行 %d", linked) + } + + // 迁移后的连接(单连接池)必须按新定义校验;RESET 没生效时这里会失败。 + if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type) + VALUES (1, 'E3', '双计项', 'individual_team')`); err != nil { + t.Errorf("迁移后 individual_team 应被接受: %v", err) + } + if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type) + VALUES (1, 'E4', '非法', 'bogus')`); err == nil { + t.Error("迁移后非法 event_type 应被拒绝") + } + // 自增序列延续,避免后续插入撞号。 + var newID int64 + if err := db.QueryRow(`SELECT id FROM events WHERE code = 'E3'`).Scan(&newID); err != nil { + t.Fatalf("读新项目: %v", err) + } + if newID <= 2 { + t.Errorf("events 自增序列未延续,新 id = %d", newID) + } + + // 索引随建表语句一起被保留(writable_schema 不改索引)。 + var indexCount int + if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name IN + ('idx_events_sport_id', 'idx_events_status')`).Scan(&indexCount); err != nil { + t.Fatalf("查索引: %v", err) + } + if indexCount != 2 { + t.Errorf("events 索引缺失,命中 %d 个", indexCount) + } + // 子表外键仍然生效。 + if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id) VALUES (999, 1, 1)`); err == nil { + t.Error("entries.event_id 外键应仍然生效") + } + rows, err := db.Query(`PRAGMA foreign_key_check`) + if err != nil { + t.Fatalf("外键检查: %v", err) + } + defer func() { _ = rows.Close() }() + if rows.Next() { + t.Error("迁移后存在外键悬挂") + } +} diff --git a/web/src/admin/api/mock.ts b/web/src/admin/api/mock.ts index a547447..eada610 100644 --- a/web/src/admin/api/mock.ts +++ b/web/src/admin/api/mock.ts @@ -219,6 +219,18 @@ const EVENT_SEEDS: EventSeed[] = [ scored: false, categoryName: '混合组', }, + { + id: 108, + sportId: 3, + code: 'TPULL', + name: '团体引体向上(个人+团体)', + eventType: 'individual_team', + resultMode: 'auto', + resultType: 'count', + compare: 'desc', + scored: true, + categoryName: '混合组', + }, ] const SETTINGS: EventSettings = { @@ -315,14 +327,23 @@ class MockDb { sortOrder: 1, })) - this.channels = EVENT_SEEDS.map((seed) => ({ - id: seed.id * 10 + 1, - eventId: seed.id, - scope: seed.eventType === 'individual' ? 'athlete' : 'team', - basis: seed.resultMode === 'manual' ? 'manual' : 'result', - profileId: seed.eventType === 'individual' ? 1 : 2, - sortOrder: 1, - })) + // 「个人+团体」同时配个人通道与团体通道,其余类型单通道。 + this.channels = EVENT_SEEDS.flatMap((seed): ScoreChannel[] => { + if (seed.eventType === 'individual_team') { + return [ + { id: seed.id * 10 + 1, eventId: seed.id, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1 }, + { id: seed.id * 10 + 2, eventId: seed.id, scope: 'team', basis: 'manual', profileId: 2, sortOrder: 2 }, + ] + } + return [{ + id: seed.id * 10 + 1, + eventId: seed.id, + scope: seed.eventType === 'individual' ? 'athlete' : 'team', + basis: seed.resultMode === 'manual' ? 'manual' : 'result', + profileId: seed.eventType === 'individual' ? 1 : 2, + sortOrder: 1, + }] + }) for (const seed of EVENT_SEEDS) this.seedEntries(seed) for (const seed of EVENT_SEEDS) this.seedResults(seed) diff --git a/web/src/admin/components/baseData/EntryPicker.vue b/web/src/admin/components/baseData/EntryPicker.vue index 5966f42..8e4b373 100644 --- a/web/src/admin/components/baseData/EntryPicker.vue +++ b/web/src/admin/components/baseData/EntryPicker.vue @@ -5,11 +5,31 @@ import MxIcon from '../MxIcon.vue' import { adminApi } from '../../api/client' import { useBaseData } from './useBaseData' import { useToastStore } from '../../stores/toast' -import { buildSelectionRows, selectionAllowsSubmit, toCsv, type SelectionMode } from './entrySelection' +import { + buildSelectionRows, + buildTeamSelectionRows, + buildTeamSelections, + emptyTeamPick, + registeredAthleteIds, + registeredTeamIds, + selectableTeamIds, + selectionAllowsSubmit, + setPickedTeams, + teamSelectionAllowsSubmit, + toggleMemberPick, + toggleTeamPick, + toCsv, + type SelectionMode, + type TeamPickState, +} from './entrySelection' /** * 从已有运动员名单里勾选报名。 * + * 分「个人报名」「团体报名」两部分:小项类型决定默认展示哪一部分,也可以手动切换。 + * 个人部分一人一条报名;团体部分从已配置的参赛单位里多选要参加的单位, + * 每个单位一条报名,队员名单可以先空着再补(部分单位不参加,默认一个都不勾)。 + * * 勾选结果不走新接口:转成与导入表头一致的 CSV 后复用 /entries/import, * 服务端的匹配、upsert 与行级校验只有一份实现。 */ @@ -19,24 +39,40 @@ const toast = useToastStore() const eventId = ref('') const categoryId = ref('') -const teamId = ref('') -const search = ref('') -const checked = ref([]) const entries = ref([]) const entriesLoading = ref(false) const submitting = ref(false) const error = ref(null) +// 个人报名 +const individualTeamId = ref('') +const search = ref('') +const checked = ref([]) + +// 团体报名 +const teamPick = ref(emptyTeamPick()) +const expandedTeams = ref([]) +const memberSearch = ref('') + const eventIdNum = computed(() => (eventId.value === '' ? null : Number(eventId.value))) const currentEvent = computed(() => data.eventById(eventIdNum.value)) -const mode = computed(() => (currentEvent.value?.eventType === 'individual' ? 'individual' : 'team')) const categories = computed(() => data.categoriesOf(eventIdNum.value)) const currentCategory = computed(() => categories.value.find((category) => String(category.id) === categoryId.value)) -const currentTeam = computed(() => data.teams.value.find((team) => String(team.id) === teamId.value)) +const currentTeam = computed(() => data.teams.value.find((team) => String(team.id) === individualTeamId.value)) + +/** 小项类型对应的部分:个人项目 → 个人报名,团体 / 对抗项目 → 团体报名。 */ +const eventMode = computed(() => (currentEvent.value?.eventType === 'individual' ? 'individual' : 'team')) +const tab = ref('individual') +/** 手动切到与小项类型不符的部分时提示一句,避免报错时看不出原因。 */ +const tabMismatch = computed(() => currentEvent.value !== undefined && tab.value !== eventMode.value) + +function setTab(next: SelectionMode): void { + tab.value = next +} /** 只列启用中的运动员:停用为软删除,新报名不再可选。 */ const teamAthletes = computed(() => - data.athletes.value.filter((athlete) => athlete.active && String(athlete.teamId) === teamId.value), + data.athletes.value.filter((athlete) => athlete.active && String(athlete.teamId) === individualTeamId.value), ) const filteredAthletes = computed(() => { @@ -52,43 +88,117 @@ const filteredAthletes = computed(() => { /** 勾选顺序可能与列表顺序不同,提交前统一按列表顺序取,团体队员名单才稳定。 */ const checkedAthletes = computed(() => teamAthletes.value.filter((athlete) => checked.value.includes(athlete.id))) -/** 个人项目按 athleteId 判断已报名;团体项目按单位整体判断。 */ -const registeredAthleteIds = computed(() => { - const ids = new Set() - for (const entry of entries.value) { - if (entry.categoryId !== currentCategory.value?.id) continue - if (entry.teamId !== currentTeam.value?.id) continue - if (entry.athleteId !== null) ids.add(entry.athleteId) - } - return ids -}) - -const teamRegistered = computed(() => - entries.value.some( - (entry) => entry.categoryId === currentCategory.value?.id && entry.teamId === currentTeam.value?.id, - ), +const registeredTeams = computed(() => registeredTeamIds(entries.value, currentCategory.value?.id)) +const registeredAthletes = computed(() => + registeredAthleteIds(entries.value, currentCategory.value?.id, currentTeam.value?.id), +) +const individualTeamRegistered = computed( + () => currentTeam.value !== undefined && registeredTeams.value.has(currentTeam.value.id), ) -const canSubmit = computed( +/** 团体部分要提交的单位:单位与队员都按列表顺序产出。 */ +const teamSelections = computed(() => buildTeamSelections(teamPick.value, data.teams.value, data.athletes.value)) +const pickedTeamCount = computed(() => teamSelections.value.length) +/** 「全选」的范围:启用中且还没报名的单位。 */ +const selectableTeams = computed(() => selectableTeamIds(data.teams.value, registeredTeams.value)) + +/** 展开的单位里可勾选的队员;搜索只作用于展开的名单。 */ +const membersByTeam = computed(() => { + const keyword = memberSearch.value.trim().toLowerCase() + const map = new Map() + for (const athlete of data.athletes.value) { + if (!athlete.active) continue + if ( + keyword !== '' && + !athlete.name.toLowerCase().includes(keyword) && + !(athlete.employeeNo ?? '').toLowerCase().includes(keyword) + ) { + continue + } + const list = map.get(athlete.teamId) + if (list === undefined) map.set(athlete.teamId, [athlete]) + else list.push(athlete) + } + return map +}) + +function teamMembersOf(teamId: number): Athlete[] { + return membersByTeam.value.get(teamId) ?? [] +} + +const canSubmitIndividual = computed( () => data.canWrite.value && currentEvent.value !== undefined && currentCategory.value !== undefined && currentTeam.value !== undefined && - // 团体 / 对抗项目允许只报单位(0 勾选也放行),个人项目必须勾人。 - selectionAllowsSubmit(mode.value, checkedAthletes.value.length) && + selectionAllowsSubmit('individual', checkedAthletes.value.length) && !submitting.value, ) -function isRegistered(athlete: Athlete): boolean { - return mode.value === 'individual' && registeredAthleteIds.value.has(athlete.id) +const canSubmitTeam = computed( + () => + data.canWrite.value && + currentEvent.value !== undefined && + currentCategory.value !== undefined && + teamSelectionAllowsSubmit(pickedTeamCount.value) && + !submitting.value, +) + +function isTeamPicked(teamId: number): boolean { + return teamPick.value.teamIds.includes(teamId) } -/** 个人项目的行必须带证件号或号码布;团体行运动员为空,不受影响。 */ +function isMemberPicked(teamId: number, athleteId: number): boolean { + return (teamPick.value.memberIds[teamId] ?? []).includes(athleteId) +} + +function memberCount(teamId: number): number { + return teamPick.value.memberIds[teamId]?.length ?? 0 +} + +function isExpanded(teamId: number): boolean { + return expandedTeams.value.includes(teamId) +} + +/** 已报名的单位勾上却没登记队员时,提交会把原名单覆盖成空,提前在行上提醒一句。 */ +function clearsRoster(teamId: number): boolean { + return isTeamPicked(teamId) && registeredTeams.value.has(teamId) && memberCount(teamId) === 0 +} + +function toggleExpand(teamId: number): void { + expandedTeams.value = isExpanded(teamId) + ? expandedTeams.value.filter((id) => id !== teamId) + : [...expandedTeams.value, teamId] +} + +/** 手动勾选单位时顺手展开队员列表;「全选」不展开,否则一次铺开几十个面板。 */ +function onTeamCheck(teamId: number, event: Event): void { + const input = event.target + if (!(input instanceof HTMLInputElement)) return + teamPick.value = toggleTeamPick(teamPick.value, teamId, input.checked) + if (input.checked && !isExpanded(teamId)) expandedTeams.value = [...expandedTeams.value, teamId] +} + +function onMemberCheck(teamId: number, athleteId: number, event: Event): void { + const input = event.target + if (!(input instanceof HTMLInputElement)) return + teamPick.value = toggleMemberPick(teamPick.value, teamId, athleteId, input.checked) +} + +function selectAllTeams(): void { + teamPick.value = setPickedTeams(teamPick.value, selectableTeams.value) +} + +function clearTeams(): void { + teamPick.value = setPickedTeams(teamPick.value, []) +} + +/** 个人报名的行必须带证件号或号码布;两者都没有时服务端会跳过该行。 */ function missingIdentifier(athlete: Athlete): boolean { const noIdCard = athlete.idCard === null || athlete.idCard === '' const noBib = athlete.bib === null || athlete.bib === '' - return mode.value === 'individual' && noIdCard && noBib + return noIdCard && noBib } async function loadEntries(id: number): Promise { @@ -103,9 +213,30 @@ async function loadEntries(id: number): Promise { } } -async function syncEvent(id: string): Promise { +/** 转成导入表头一致的 CSV 后复用导入接口;提交后重拉已报名,徽标与计数都是服务端的结果。 */ +async function importSelection(rows: string[][], id: number): Promise { + const file = new File([toCsv(rows)], 'selection.csv', { type: 'text/csv' }) + const result = await data.importEntries(file) + toast.success('报名已加入', `新增 ${result.created} · 更新 ${result.updated} · 跳过 ${result.skipped}`) + if (result.errors.length > 0) error.value = result.errors.join(';') + await loadEntries(id) +} + +function resetTeamPick(): void { + teamPick.value = emptyTeamPick() + expandedTeams.value = [] + memberSearch.value = '' +} + +function resetSelection(): void { checked.value = [] search.value = '' + individualTeamId.value = '' + resetTeamPick() +} + +async function syncEvent(id: string): Promise { + resetSelection() categoryId.value = '' entries.value = [] error.value = null @@ -114,6 +245,8 @@ async function syncEvent(id: string): Promise { await data.loadEventDetail(Number(id)) const first = data.categoriesOf(Number(id))[0] categoryId.value = first === undefined ? '' : String(first.id) + // 换小项就回到该类型对应的部分,省得在上一部分里继续填。 + tab.value = eventMode.value await loadEntries(Number(id)) } @@ -122,17 +255,22 @@ watch(eventId, (id) => { }) // 换组别 / 换单位后勾选不再适用,清空避免把上一组的名单带过来。 -watch([categoryId, teamId], () => { +watch([categoryId, individualTeamId], () => { checked.value = [] }) -async function submit(): Promise { +watch(categoryId, () => { + teamPick.value = emptyTeamPick() + expandedTeams.value = [] +}) + +async function submitIndividual(): Promise { const event = currentEvent.value const category = currentCategory.value const team = currentTeam.value const chosen = checkedAthletes.value if (event === undefined || category === undefined || team === undefined) return - if (!selectionAllowsSubmit(mode.value, chosen.length)) return + if (!selectionAllowsSubmit('individual', chosen.length)) return submitting.value = true error.value = null @@ -141,15 +279,38 @@ async function submit(): Promise { eventCode: event.code, categoryName: category.name, teamCode: team.code, - mode: mode.value, + mode: 'individual', athletes: chosen.map((athlete) => ({ name: athlete.name, idCard: athlete.idCard, bib: athlete.bib })), }) - const file = new File([toCsv(rows)], 'selection.csv', { type: 'text/csv' }) - const result = await data.importEntries(file) - toast.success('报名已加入', `新增 ${result.created} · 更新 ${result.updated} · 跳过 ${result.skipped}`) - if (result.errors.length > 0) error.value = result.errors.join(';') + await importSelection(rows, event.id) checked.value = [] - await loadEntries(event.id) + } catch (caught) { + const message = caught instanceof Error ? caught.message : '加入报名失败' + error.value = message + toast.error('加入报名失败', message) + } finally { + submitting.value = false + } +} + +async function submitTeam(): Promise { + const event = currentEvent.value + const category = currentCategory.value + const teams = teamSelections.value + if (event === undefined || category === undefined) return + if (!teamSelectionAllowsSubmit(teams.length)) return + + submitting.value = true + error.value = null + try { + const rows = buildTeamSelectionRows({ + eventCode: event.code, + categoryName: category.name, + teams, + }) + await importSelection(rows, event.id) + // 提交成功后清空勾选:已报名的单位会转为「已报名」而不可再勾。 + resetTeamPick() } catch (caught) { const message = caught instanceof Error ? caught.message : '加入报名失败' error.value = message @@ -164,11 +325,28 @@ async function submit(): Promise {
从已有名单勾选报名 - 该单位已报名 + 该单位已报名 + + 已勾选 {{ pickedTeamCount }} 个单位 + 读取已报名…
+
+ + +
+
- -
-
-
@@ -205,56 +372,178 @@ async function submit(): Promise { 当前不可写入(未登录或服务端未连接),勾选与提交已停用。
-

先选择小项、组别和单位,再勾选运动员。

+

先选择小项和组别,再按小项类型填报。

该小项还没有组别,请先在「组别」分区配置后再报名。

-

再选择一个单位,即可看到该单位的运动员。

@@ -263,6 +552,9 @@ async function submit(): Promise {