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
+1279
-78
No files matched your search
+3
-1
@@ -261,10 +261,12 @@
|
||||
### 10.2 大项 / 小项
|
||||
|
||||
1. 左侧选择或新增大项(编码、名称、简称、默认积分模板、排序);
|
||||
2. 右侧维护该大项下的小项:点「新增小项」,填写编码、名称、类型(个人 / 团体 / 对抗比赛)、录入方式(自动排名 / 手动名次)、成绩类型(时间 / 距离 / 计数 / 得分 / 无成绩)、比较方式(越小越好 / 越大越好 / 不比较)、状态、是否计分等;
|
||||
2. 右侧维护该大项下的小项:点「新增小项」,填写编码、名称、类型(个人 / 团体 / 对抗比赛 / 个人+团体)、录入方式(自动排名 / 手动名次)、成绩类型(时间 / 距离 / 计数 / 得分 / 无成绩)、比较方式(越小越好 / 越大越好 / 不比较)、状态、是否计分等;
|
||||
3. 小项行上的「配置组别」「配置计分通道」可直接跳到对应分区;
|
||||
4. 删除大项 / 小项会二次确认。
|
||||
|
||||
> 「个人+团体」用于团体与个人赛事合并的项目:会同时按个人通道与团体通道记分,具体通道见 10.4「计分通道」。
|
||||
|
||||
### 10.3 组别
|
||||
|
||||
1. 先在上方选中一个小项;
|
||||
|
||||
@@ -109,6 +109,7 @@ func ScoreEvent(in EventScoreInput) (EventScore, error) {
|
||||
|
||||
// rankOnlyChannel 在没有计分通道时,按小项自身的录入方式推导排名口径:
|
||||
// manual 直接录名次,其余按成绩自动排名;名次记给个人,团体/对抗项目记给单位。
|
||||
// 「个人+团体」的双计靠两条通道表达,这条退化路径只排个人榜,故不入单位分支。
|
||||
func rankOnlyChannel(in EventScoreInput) ScoreChannel {
|
||||
basis := BasisResult
|
||||
if in.Event.ResultMode == ResultModeManual {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -12,6 +12,9 @@ const (
|
||||
EventTypeIndividual EventType = "individual"
|
||||
EventTypeTeam EventType = "team"
|
||||
EventTypeMatch EventType = "match"
|
||||
// EventTypeIndividualTeam 个人+团体:团体与个人赛事合并的项目,个人榜与团体榜同时记分。
|
||||
// 双计靠两条计分通道(athlete + team)表达,不在这里做特殊计算。
|
||||
EventTypeIndividualTeam EventType = "individual_team"
|
||||
)
|
||||
|
||||
// ResultMode 成绩录入方式:auto 录成绩后自动排名,manual 直接录名次。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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("迁移后存在外键悬挂")
|
||||
}
|
||||
}
|
||||
@@ -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) => ({
|
||||
// 「个人+团体」同时配个人通道与团体通道,其余类型单通道。
|
||||
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)
|
||||
|
||||
@@ -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<number[]>([])
|
||||
const entries = ref<EntryWithResult[]>([])
|
||||
const entriesLoading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// 个人报名
|
||||
const individualTeamId = ref('')
|
||||
const search = ref('')
|
||||
const checked = ref<number[]>([])
|
||||
|
||||
// 团体报名
|
||||
const teamPick = ref<TeamPickState>(emptyTeamPick())
|
||||
const expandedTeams = ref<number[]>([])
|
||||
const memberSearch = ref('')
|
||||
|
||||
const eventIdNum = computed(() => (eventId.value === '' ? null : Number(eventId.value)))
|
||||
const currentEvent = computed(() => data.eventById(eventIdNum.value))
|
||||
const mode = computed<SelectionMode>(() => (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<SelectionMode>(() => (currentEvent.value?.eventType === 'individual' ? 'individual' : 'team'))
|
||||
const tab = ref<SelectionMode>('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<number>()
|
||||
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<number, Athlete[]>()
|
||||
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<void> {
|
||||
@@ -103,9 +213,30 @@ async function loadEntries(id: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncEvent(id: string): Promise<void> {
|
||||
/** 转成导入表头一致的 CSV 后复用导入接口;提交后重拉已报名,徽标与计数都是服务端的结果。 */
|
||||
async function importSelection(rows: string[][], id: number): Promise<void> {
|
||||
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<void> {
|
||||
resetSelection()
|
||||
categoryId.value = ''
|
||||
entries.value = []
|
||||
error.value = null
|
||||
@@ -114,6 +245,8 @@ async function syncEvent(id: string): Promise<void> {
|
||||
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<void> {
|
||||
watch(categoryId, () => {
|
||||
teamPick.value = emptyTeamPick()
|
||||
expandedTeams.value = []
|
||||
})
|
||||
|
||||
async function submitIndividual(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">从已有名单勾选报名</span>
|
||||
<span v-if="mode === 'team' && teamRegistered" class="mx-badge mx-badge-warning">该单位已报名</span>
|
||||
<span v-if="tab === 'individual' && individualTeamRegistered" class="mx-badge mx-badge-warning">该单位已报名</span>
|
||||
<span v-else-if="tab === 'team' && pickedTeamCount > 0" class="mx-badge mx-badge-info">
|
||||
已勾选 {{ pickedTeamCount }} 个单位
|
||||
</span>
|
||||
<span v-else-if="entriesLoading" class="mx-badge mx-badge-neutral">读取已报名…</span>
|
||||
</div>
|
||||
|
||||
<div class="mx-card-body">
|
||||
<div class="mx-tabs mx-tabs-pill picker-tabs">
|
||||
<button
|
||||
class="mx-tab"
|
||||
:class="{ active: tab === 'individual' }"
|
||||
type="button"
|
||||
@click="setTab('individual')"
|
||||
>
|
||||
个人报名
|
||||
</button>
|
||||
<button class="mx-tab" :class="{ active: tab === 'team' }" type="button" @click="setTab('team')">
|
||||
团体报名
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bd-toolbar">
|
||||
<div class="bd-toolbar-left">
|
||||
<select v-model="eventId" class="mx-cell-input bd-select" :disabled="!data.canWrite.value">
|
||||
@@ -187,17 +365,6 @@ async function submit(): Promise<void> {
|
||||
{{ category.name }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="teamId" class="mx-cell-input bd-select" :disabled="!data.canWrite.value || eventId === ''">
|
||||
<option value="" disabled>选择单位</option>
|
||||
<option v-for="team in data.teams.value" :key="team.id" :value="String(team.id)">
|
||||
{{ team.name }}{{ team.active ? '' : '(已停用)' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="bd-toolbar-right">
|
||||
<button class="mx-btn mx-btn-primary" type="button" :disabled="!canSubmit" @click="submit">
|
||||
<MxIcon name="entry" :size="15" /> {{ submitting ? '加入中…' : '加入报名' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -205,43 +372,64 @@ async function submit(): Promise<void> {
|
||||
<MxIcon name="lock" :size="14" />
|
||||
当前不可写入(未登录或服务端未连接),勾选与提交已停用。
|
||||
</div>
|
||||
<p v-else-if="eventId === ''" class="bd-note">先选择小项、组别和单位,再勾选运动员。</p>
|
||||
<p v-else-if="eventId === ''" class="bd-note">先选择小项和组别,再按小项类型填报。</p>
|
||||
<p v-else-if="categories.length === 0" class="bd-note">该小项还没有组别,请先在「组别」分区配置后再报名。</p>
|
||||
<p v-else-if="teamId === ''" class="bd-note">再选择一个单位,即可看到该单位的运动员。</p>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="tabMismatch" class="bd-note">
|
||||
该小项配置为{{ eventMode === 'individual' ? '个人项目' : '团体 / 对抗项目' }},当前用的是{{
|
||||
tab === 'individual' ? '个人报名' : '团体报名'
|
||||
}},生成的报名行以当前部分为准。
|
||||
</p>
|
||||
|
||||
<!-- 个人报名:一人一条报名 -->
|
||||
<template v-if="tab === 'individual'">
|
||||
<div class="bd-toolbar">
|
||||
<div class="bd-toolbar-left">
|
||||
<select v-model="individualTeamId" class="mx-cell-input bd-select" :disabled="!data.canWrite.value">
|
||||
<option value="" disabled>选择单位</option>
|
||||
<option v-for="team in data.teams.value" :key="team.id" :value="String(team.id)">
|
||||
{{ team.name }}{{ team.active ? '' : '(已停用)' }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="mx-input-wrap bd-search">
|
||||
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
|
||||
<input v-model="search" class="mx-input" placeholder="搜索姓名 / 工号" />
|
||||
</div>
|
||||
<span class="bd-note">
|
||||
已勾选 {{ checkedAthletes.length }} 人{{ mode === 'team' && checkedAthletes.length === 0 ? '(团体项目可留空)' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bd-toolbar-right">
|
||||
<span class="bd-note">已勾选 {{ checkedAthletes.length }} 人</span>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary"
|
||||
type="button"
|
||||
:disabled="!canSubmitIndividual"
|
||||
@click="submitIndividual"
|
||||
>
|
||||
<MxIcon name="entry" :size="15" /> {{ submitting ? '加入中…' : '加入报名' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="mode === 'team'" class="bd-note">
|
||||
团体 / 对抗项目按单位报名:队员名单可选,一名队员都不勾也能把本单位的报名建出来,名单可以之后再来补。
|
||||
</p>
|
||||
|
||||
<p v-if="individualTeamId === ''" class="bd-note">选择一个单位,即可看到该单位的运动员。</p>
|
||||
<template v-else>
|
||||
<div class="picker-list">
|
||||
<label
|
||||
v-for="athlete in filteredAthletes"
|
||||
:key="athlete.id"
|
||||
class="bd-check picker-row"
|
||||
:class="{ 'is-registered': isRegistered(athlete) }"
|
||||
:class="{ 'is-registered': registeredAthletes.has(athlete.id) }"
|
||||
>
|
||||
<input
|
||||
v-model="checked"
|
||||
type="checkbox"
|
||||
:value="athlete.id"
|
||||
:disabled="!data.canWrite.value || isRegistered(athlete) || missingIdentifier(athlete)"
|
||||
:disabled="!data.canWrite.value || registeredAthletes.has(athlete.id) || missingIdentifier(athlete)"
|
||||
/>
|
||||
<span class="picker-name">{{ athlete.name }}</span>
|
||||
<span class="bd-note">{{ athlete.employeeNo ?? '—' }}{{ athlete.bib ? ` · 号码布 ${athlete.bib}` : '' }}</span>
|
||||
<span v-if="isRegistered(athlete)" class="mx-badge mx-badge-neutral">已报名</span>
|
||||
<span class="bd-note">
|
||||
{{ athlete.employeeNo ?? '—' }}{{ athlete.bib ? ` · 号码布 ${athlete.bib}` : '' }}
|
||||
</span>
|
||||
<span v-if="registeredAthletes.has(athlete.id)" class="mx-badge mx-badge-neutral">已报名</span>
|
||||
<span v-else-if="missingIdentifier(athlete)" class="mx-badge mx-badge-warning">缺证件号/号码布</span>
|
||||
</label>
|
||||
<div v-if="filteredAthletes.length === 0" class="bd-note picker-empty">
|
||||
@@ -249,12 +437,113 @@ async function submit(): Promise<void> {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="bd-note">
|
||||
<template v-if="mode === 'individual'">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</template>
|
||||
<template v-else>团体 / 对抗项目:勾选的人合成一条报名(一个人也不勾则只报单位),队员名单按列表顺序写入;已报名时再次提交会更新队员名单。</template>
|
||||
</p>
|
||||
<p class="bd-note">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</p>
|
||||
<p class="bd-note">报名会自动带上该运动员的证件号(优先)或号码布;种子等字段仍通过下方报名表导入填写。</p>
|
||||
<p v-if="mode === 'individual'" class="bd-note">证件号与号码布都没有的运动员不能报名个人项目,请先在「运动员」分区补齐其中一项。</p>
|
||||
<p class="bd-note">证件号与号码布都没有的运动员不能报名个人项目,请先在「运动员」分区补齐其中一项。</p>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 团体报名:多选参赛单位,每个单位一条报名 -->
|
||||
<template v-else>
|
||||
<div class="bd-toolbar">
|
||||
<div class="bd-toolbar-left">
|
||||
<button
|
||||
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!data.canWrite.value"
|
||||
@click="selectAllTeams"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!data.canWrite.value || pickedTeamCount === 0"
|
||||
@click="clearTeams"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<span class="bd-note">已勾选 {{ pickedTeamCount }} 个参赛单位</span>
|
||||
<div v-if="expandedTeams.length > 0" class="mx-input-wrap bd-search">
|
||||
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
|
||||
<input v-model="memberSearch" class="mx-input" placeholder="搜索队员姓名 / 工号" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="bd-toolbar-right">
|
||||
<button class="mx-btn mx-btn-primary" type="button" :disabled="!canSubmitTeam" @click="submitTeam">
|
||||
<MxIcon name="entry" :size="15" /> {{ submitting ? '加入中…' : '加入报名' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="bd-note">
|
||||
团体 / 对抗项目按单位报名:从已配置的参赛单位里勾选参加本组别的单位(默认一个都不勾,
|
||||
部分单位不参加就不勾它)。每个单位生成一条报名,队员名单可选——展开某个单位即可登记队员,
|
||||
一个队员都不勾则只报单位。「全选」只勾启用中且未报名的单位,已停用单位要单独勾。
|
||||
</p>
|
||||
|
||||
<div class="team-list">
|
||||
<div
|
||||
v-for="team in data.teams.value"
|
||||
:key="team.id"
|
||||
class="team-pick"
|
||||
:class="{ 'is-picked': isTeamPicked(team.id) }"
|
||||
>
|
||||
<div class="team-row">
|
||||
<label class="bd-check team-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isTeamPicked(team.id)"
|
||||
:disabled="!data.canWrite.value"
|
||||
@change="onTeamCheck(team.id, $event)"
|
||||
/>
|
||||
<span class="picker-name">{{ team.name }}{{ team.active ? '' : '(已停用)' }}</span>
|
||||
<span class="bd-note bd-mono">{{ team.code }}</span>
|
||||
</label>
|
||||
<div class="team-actions">
|
||||
<span v-if="registeredTeams.has(team.id)" class="mx-badge mx-badge-neutral">已报名</span>
|
||||
<span v-if="memberCount(team.id) > 0" class="mx-badge mx-badge-info">
|
||||
队员 {{ memberCount(team.id) }} 人
|
||||
</span>
|
||||
<span v-if="clearsRoster(team.id)" class="mx-badge mx-badge-warning">提交将清空原名单</span>
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="toggleExpand(team.id)">
|
||||
{{ isExpanded(team.id) ? '收起队员' : '登记队员' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isExpanded(team.id)" class="team-members">
|
||||
<div class="picker-list member-list">
|
||||
<label v-for="athlete in teamMembersOf(team.id)" :key="athlete.id" class="bd-check picker-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isMemberPicked(team.id, athlete.id)"
|
||||
:disabled="!data.canWrite.value"
|
||||
@change="onMemberCheck(team.id, athlete.id, $event)"
|
||||
/>
|
||||
<span class="picker-name">{{ athlete.name }}</span>
|
||||
<span class="bd-note">
|
||||
{{ athlete.employeeNo ?? '—' }}{{ athlete.bib ? ` · 号码布 ${athlete.bib}` : '' }}
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="teamMembersOf(team.id).length === 0" class="bd-note picker-empty">
|
||||
{{ memberSearch.trim() === '' ? '该单位没有启用中的运动员' : '没有匹配的运动员' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="data.teams.value.length === 0" class="bd-note picker-empty">
|
||||
还没有配置参赛单位,请先在「参赛单位」分区添加。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="bd-note">
|
||||
每个单位生成一条报名(同组别 + 单位只留一条),队员名单按列表顺序写入。
|
||||
已报名的单位也勾得上:再次提交会用本次登记的队员覆盖原名单,一个队员都不勾就会把原名单清空,
|
||||
所以「全选」会跳过已报名的单位,避免批量提交时误清名单;需要改名单时单独勾这一个单位。
|
||||
</p>
|
||||
<p class="bd-note">队员名单只存姓名,不会带动运动员档案;证件号、号码布与种子等字段仍通过下方报名表导入填写。</p>
|
||||
</template>
|
||||
|
||||
<div v-if="error !== null" class="mx-badge mx-badge-error picker-error">{{ error }}</div>
|
||||
</template>
|
||||
@@ -263,6 +552,9 @@ async function submit(): Promise<void> {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.picker-tabs {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.picker-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
@@ -292,6 +584,46 @@ async function submit(): Promise<void> {
|
||||
.picker-empty {
|
||||
padding: 12px 0;
|
||||
}
|
||||
.team-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.team-pick {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s3);
|
||||
}
|
||||
.team-pick.is-picked {
|
||||
border-color: var(--mx-primary);
|
||||
background: var(--mx-primary-glow);
|
||||
}
|
||||
.team-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.team-check {
|
||||
min-width: 0;
|
||||
}
|
||||
.team-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.team-members {
|
||||
padding: 8px 10px 2px;
|
||||
border-top: 1px dashed var(--mx-divider);
|
||||
}
|
||||
.member-list {
|
||||
max-height: 240px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.readonly-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -30,6 +30,7 @@ const EVENT_TYPE_OPTIONS: Array<{ value: EventType; label: string }> = [
|
||||
{ value: 'individual', label: '个人' },
|
||||
{ value: 'team', label: '团体' },
|
||||
{ value: 'match', label: '对抗 / 比赛' },
|
||||
{ value: 'individual_team', label: '个人+团体' },
|
||||
]
|
||||
const RESULT_MODE_OPTIONS: Array<{ value: ResultMode; label: string }> = [
|
||||
{ value: 'auto', label: '自动排名(录成绩)' },
|
||||
@@ -259,6 +260,13 @@ const eventConfirmOpen = ref(false)
|
||||
const eventConfirmTarget = ref<Event | null>(null)
|
||||
const eventConfirmPending = ref(false)
|
||||
|
||||
// 「个人+团体」要说明双通道口径;其余类型维持原提示。
|
||||
const eventTypeHint = computed(() =>
|
||||
eventForm.value.eventType === 'individual_team'
|
||||
? '会同时按个人通道与团体通道记分;具体通道可在「计分通道」页调整'
|
||||
: '仅用于展示归类',
|
||||
)
|
||||
|
||||
function emptyEventForm(): EventForm {
|
||||
return {
|
||||
sportId: '',
|
||||
@@ -625,7 +633,7 @@ async function confirmDeleteEvent(): Promise<void> {
|
||||
<FormField label="名称" required :error="eventErrors.name" wide>
|
||||
<input v-model="eventForm.name" class="mx-cell-input" placeholder="例如 男子 100 米" />
|
||||
</FormField>
|
||||
<FormField label="项目类型" hint="仅用于展示归类">
|
||||
<FormField label="项目类型" :hint="eventTypeHint">
|
||||
<select v-model="eventForm.eventType" class="mx-cell-input">
|
||||
<option v-for="option in EVENT_TYPE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
|
||||
@@ -99,10 +99,43 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
|
||||
return rows
|
||||
}
|
||||
const names = input.memberNames ?? input.athletes.map((athlete) => athlete.name)
|
||||
return buildTeamSelectionRows({
|
||||
eventCode: input.eventCode,
|
||||
categoryName: input.categoryName,
|
||||
status,
|
||||
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,
|
||||
input.teamCode,
|
||||
team.teamCode,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
@@ -110,11 +143,125 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
|
||||
'',
|
||||
'',
|
||||
status,
|
||||
names.join('/'),
|
||||
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')
|
||||
|
||||
@@ -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 团体行回落到队员首人 / 单位', () => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { EventType } from '@/shared'
|
||||
import { mockDb } from '../api/mock'
|
||||
|
||||
// 「个人+团体」:类型联合、小项编辑下拉、示例数据三处都要认得它。
|
||||
// 项目没有组件测试工具,下拉部分按仓库既有做法直接读源码断言。
|
||||
const SECTION_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/SportsEventsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('EventType 联合类型包含 individual_team', () => {
|
||||
const eventType: EventType = 'individual_team'
|
||||
assert.equal(eventType, 'individual_team')
|
||||
})
|
||||
|
||||
test('小项编辑的项目类型下拉包含「个人+团体」', () => {
|
||||
const block = SECTION_SOURCE.match(/const EVENT_TYPE_OPTIONS[^=]*= \[([\s\S]*?)\n\]/)?.[1]
|
||||
assert.ok(block !== undefined, '找不到 EVENT_TYPE_OPTIONS 定义')
|
||||
|
||||
for (const [value, label] of [
|
||||
['individual', '个人'],
|
||||
['team', '团体'],
|
||||
['match', '对抗 / 比赛'],
|
||||
['individual_team', '个人+团体'],
|
||||
] as const) {
|
||||
assert.ok(block.includes(`value: '${value}'`), `下拉缺少 ${value}`)
|
||||
assert.ok(block.includes(`label: '${label}'`), `下拉缺少 ${value} 的文案`)
|
||||
}
|
||||
})
|
||||
|
||||
test('选中「个人+团体」时提示双通道口径', () => {
|
||||
assert.match(SECTION_SOURCE, /eventForm\.value\.eventType === 'individual_team'/)
|
||||
assert.match(SECTION_SOURCE, /会同时按个人通道与团体通道记分/)
|
||||
assert.match(SECTION_SOURCE, /计分通道/)
|
||||
})
|
||||
|
||||
test('示例数据里的「个人+团体」小项配了个人与团体两条通道', () => {
|
||||
const event = mockDb.eventDetail(108)
|
||||
assert.equal(event.eventType, 'individual_team')
|
||||
|
||||
const scopes = (event.channels ?? []).map((channel) => channel.scope)
|
||||
assert.deepEqual(scopes, ['athlete', 'team'])
|
||||
})
|
||||
@@ -73,7 +73,7 @@ export function oneOf<T extends string>(value: unknown, allowed: readonly T[], f
|
||||
: fallback
|
||||
}
|
||||
|
||||
const EVENT_TYPES = ['individual', 'team', 'match'] as const satisfies readonly EventType[]
|
||||
const EVENT_TYPES = ['individual', 'team', 'match', 'individual_team'] as const satisfies readonly EventType[]
|
||||
const RESULT_MODES = ['auto', 'manual'] as const satisfies readonly ResultMode[]
|
||||
const RESULT_TYPES = ['time', 'distance', 'count', 'score', 'none'] as const satisfies readonly ResultType[]
|
||||
const COMPARE_MODES = ['asc', 'desc', 'none'] as const satisfies readonly CompareMode[]
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { parseEvent } from '../guards'
|
||||
|
||||
// 大屏快照里的 eventType 不能因为不认识新类型就回落到 individual:
|
||||
// 「个人+团体」不是个人项目,降级会让大屏按错误口径展示。
|
||||
test('parseEvent 原样保留 individual_team', () => {
|
||||
const event = parseEvent({
|
||||
id: 1,
|
||||
sportId: 3,
|
||||
code: 'TPULL',
|
||||
name: '团体引体向上(个人+团体)',
|
||||
eventType: 'individual_team',
|
||||
resultMode: 'auto',
|
||||
resultType: 'count',
|
||||
compare: 'desc',
|
||||
scored: true,
|
||||
status: 'pending',
|
||||
})
|
||||
|
||||
assert.equal(event.eventType, 'individual_team')
|
||||
})
|
||||
|
||||
test('parseEvent 仍接受原有三种类型', () => {
|
||||
for (const eventType of ['individual', 'team', 'match'] as const) {
|
||||
const event = parseEvent({ id: 1, sportId: 1, code: 'E', name: '示例', eventType })
|
||||
assert.equal(event.eventType, eventType)
|
||||
}
|
||||
})
|
||||
@@ -49,7 +49,7 @@ const TIE_MODES: readonly TieMode[] = ['skip', 'average', 'higher']
|
||||
const RECORD_BONUS_MODES: readonly RecordBonusMode[] = ['max', 'sum']
|
||||
const SCOPES: readonly ChannelScope[] = ['athlete', 'team']
|
||||
const BASES: readonly ChannelBasis[] = ['result', 'manual', 'aggregate']
|
||||
const EVENT_TYPES: readonly EventType[] = ['individual', 'team', 'match']
|
||||
const EVENT_TYPES: readonly EventType[] = ['individual', 'team', 'match', 'individual_team']
|
||||
const RESULT_TYPES: readonly ResultType[] = ['time', 'distance', 'count', 'score', 'none']
|
||||
|
||||
export function normalizeRulePack(raw: unknown): RulePack | null {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { normalizeRulePack } from '../rules'
|
||||
|
||||
// 「个人+团体」是服务端新增的小项类型;规则包模板按 eventType 匹配它时必须原样保留,
|
||||
// 否则前端归一化会把 match.eventType 丢掉,模板会退化成「匹配全部项目」。
|
||||
test('规则包模板保留 eventType: individual_team', () => {
|
||||
const pack = normalizeRulePack({
|
||||
format: 'scoreboard-rules',
|
||||
formatVersion: 1,
|
||||
meta: { code: 'demo', name: '示例规则' },
|
||||
profiles: [{ code: 'individual_top6', name: '个人项目前六名', awardRanks: 1, tieMode: 'skip', rules: [{ rank: 1, points: 10 }] }],
|
||||
channelTemplates: [
|
||||
{
|
||||
match: { eventType: 'individual_team' },
|
||||
channels: [
|
||||
{ scope: 'athlete', basis: 'result', profile: 'individual_top6' },
|
||||
{ scope: 'team', basis: 'manual', profile: 'individual_top6' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
assert.ok(pack !== null)
|
||||
assert.equal(pack.channelTemplates[0]?.match.eventType, 'individual_team')
|
||||
assert.equal(pack.channelTemplates[0]?.channels.length, 2)
|
||||
})
|
||||
|
||||
test('规则包模板仍接受原有三种类型', () => {
|
||||
for (const eventType of ['individual', 'team', 'match'] as const) {
|
||||
const pack = normalizeRulePack({
|
||||
format: 'scoreboard-rules',
|
||||
formatVersion: 1,
|
||||
meta: { code: 'demo', name: '示例规则' },
|
||||
profiles: [{ code: 'p', name: '模板', awardRanks: 1, tieMode: 'skip', rules: [{ rank: 1, points: 1 }] }],
|
||||
channelTemplates: [{ match: { eventType }, channels: [{ scope: 'athlete', basis: 'result', profile: 'p' }] }],
|
||||
})
|
||||
assert.ok(pack !== null)
|
||||
assert.equal(pack.channelTemplates[0]?.match.eventType, eventType)
|
||||
}
|
||||
})
|
||||
@@ -50,7 +50,7 @@ export interface Sport {
|
||||
|
||||
export type SportInput = Omit<Sport, 'id'>
|
||||
|
||||
export type EventType = 'individual' | 'team' | 'match'
|
||||
export type EventType = 'individual' | 'team' | 'match' | 'individual_team'
|
||||
export type ResultMode = 'auto' | 'manual'
|
||||
export type ResultType = 'time' | 'distance' | 'count' | 'score' | 'none'
|
||||
export type CompareMode = 'asc' | 'desc' | 'none'
|
||||
|
||||
Reference in new issue
Block a user