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

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

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

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

No files matched your search

+3 -1
View File
@@ -261,10 +261,12 @@
### 10.2 大项 / 小项
1. 左侧选择或新增大项(编码、名称、简称、默认积分模板、排序);
2. 右侧维护该大项下的小项:点「新增小项」,填写编码、名称、类型(个人 / 团体 / 对抗比赛)、录入方式(自动排名 / 手动名次)、成绩类型(时间 / 距离 / 计数 / 得分 / 无成绩)、比较方式(越小越好 / 越大越好 / 不比较)、状态、是否计分等;
2. 右侧维护该大项下的小项:点「新增小项」,填写编码、名称、类型(个人 / 团体 / 对抗比赛 / 个人+团体)、录入方式(自动排名 / 手动名次)、成绩类型(时间 / 距离 / 计数 / 得分 / 无成绩)、比较方式(越小越好 / 越大越好 / 不比较)、状态、是否计分等;
3. 小项行上的「配置组别」「配置计分通道」可直接跳到对应分区;
4. 删除大项 / 小项会二次确认。
> 「个人+团体」用于团体与个人赛事合并的项目:会同时按个人通道与团体通道记分,具体通道见 10.4「计分通道」。
### 10.3 组别
1. 先在上方选中一个小项;
+1
View File
@@ -109,6 +109,7 @@ func ScoreEvent(in EventScoreInput) (EventScore, error) {
// rankOnlyChannel 在没有计分通道时,按小项自身的录入方式推导排名口径:
// manual 直接录名次,其余按成绩自动排名;名次记给个人,团体/对抗项目记给单位。
// 「个人+团体」的双计靠两条通道表达,这条退化路径只排个人榜,故不入单位分支。
func rankOnlyChannel(in EventScoreInput) ScoreChannel {
basis := BasisResult
if in.Event.ResultMode == ResultModeManual {
+35
View File
@@ -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))
+3
View File
@@ -12,6 +12,9 @@ const (
EventTypeIndividual EventType = "individual"
EventTypeTeam EventType = "team"
EventTypeMatch EventType = "match"
// EventTypeIndividualTeam 个人+团体:团体与个人赛事合并的项目,个人榜与团体榜同时记分。
// 双计靠两条计分通道(athlete + team)表达,不在这里做特殊计算。
EventTypeIndividualTeam EventType = "individual_team"
)
// ResultMode 成绩录入方式:auto 录成绩后自动排名,manual 直接录名次。
+41
View File
@@ -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)
+60
View File
@@ -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;
+239
View File
@@ -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("迁移后存在外键悬挂")
}
}
+29 -8
View File
@@ -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)
+424 -92
View File
@@ -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,56 +372,178 @@ 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>
<div class="bd-toolbar">
<div class="bd-toolbar-left">
<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="搜索姓名 / 工号" />
<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>
</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>
<span class="bd-note">
已勾选 {{ checkedAthletes.length }} 人{{ mode === 'team' && checkedAthletes.length === 0 ? '(团体项目可留空)' : '' }}
</span>
</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': registeredAthletes.has(athlete.id) }"
>
<input
v-model="checked"
type="checkbox"
:value="athlete.id"
: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="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">
{{ teamAthletes.length === 0 ? '该单位没有启用中的运动员' : '没有匹配的运动员' }}
</div>
</div>
<div class="picker-list">
<label
v-for="athlete in filteredAthletes"
:key="athlete.id"
class="bd-check picker-row"
:class="{ 'is-registered': isRegistered(athlete) }"
>
<input
v-model="checked"
type="checkbox"
:value="athlete.id"
:disabled="!data.canWrite.value || isRegistered(athlete) || 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 v-else-if="missingIdentifier(athlete)" class="mx-badge mx-badge-warning">缺证件号/号码布</span>
</label>
<div v-if="filteredAthletes.length === 0" class="bd-note picker-empty">
{{ teamAthletes.length === 0 ? '该单位没有启用中的运动员' : '没有匹配的运动员' }}
<p class="bd-note">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</p>
<p 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>
</div>
<p class="bd-note">
<template v-if="mode === 'individual'">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</template>
<template v-else>团体 / 对抗项目:勾选的人合成一条报名(一个人也不勾则只报单位),队员名单按列表顺序写入;已报名时再次提交会更新队员名单。</template>
</p>
<p class="bd-note">报名会自动带上该运动员的证件号(优先)或号码布;种子等字段仍通过下方报名表导入填写。</p>
<p v-if="mode === 'individual'" class="bd-note">证件号与号码布都没有的运动员不能报名个人项目,请先在「运动员」分区补齐其中一项。</p>
<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,22 +99,169 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
return rows
}
const names = input.memberNames ?? input.athletes.map((athlete) => athlete.name)
rows.push([
input.eventCode,
input.categoryName,
input.teamCode,
'',
'',
'',
'',
'',
'',
return buildTeamSelectionRows({
eventCode: input.eventCode,
categoryName: input.categoryName,
status,
names.join('/'),
])
teams: [{ teamCode: input.teamCode, memberNames: names }],
})
}
/** 一个单位的团体报名:只带单位编码与可选队员名单。 */
export interface TeamSelection {
teamCode: string
/** 队员姓名;空数组表示只报单位,名单之后可以再补。 */
memberNames: string[]
}
export interface TeamSelectionInput {
eventCode: string
categoryName: string
status?: string
/** 一次提交可以覆盖多个单位,勾了哪些单位就产出哪些行。 */
teams: TeamSelection[]
}
/**
* 多单位团体报名:每个单位一行,队员姓名进 member_names。
*
* 服务端对 athlete_name 为空的行只按 (组别, 单位, athlete_id IS NULL) upsert,
* 所以同组别 + 单位仍然只留一条报名,重复提交是更新(队员留空时即清空)队员名单而不是新增。
*/
export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] {
const status = input.status ?? 'registered'
const rows: string[][] = [[...REQUIRED_COLUMNS]]
for (const team of input.teams) {
rows.push([
input.eventCode,
input.categoryName,
team.teamCode,
'',
'',
'',
'',
'',
'',
status,
team.memberNames.join('/'),
])
}
return rows
}
// ── 团体多单位勾选:状态与产出 ──────────────────────────────────────────────
/**
* 团体报名的勾选状态:哪些单位参加 + 每个单位要登记的队员。
*
* 单位与队员分开存:取消勾选单位不清队员名单,重新勾上即恢复,避免手滑丢名单;
* 产出(buildTeamSelections)只取已勾选单位的队员。
*/
export interface TeamPickState {
teamIds: number[]
memberIds: Record<number, number[]>
}
export function emptyTeamPick(): TeamPickState {
return { teamIds: [], memberIds: {} }
}
/** 勾选 / 取消一个要参加的单位。 */
export function toggleTeamPick(state: TeamPickState, teamId: number, picked: boolean): TeamPickState {
const teamIds = picked
? state.teamIds.includes(teamId)
? state.teamIds
: [...state.teamIds, teamId]
: state.teamIds.filter((id) => id !== teamId)
return { ...state, teamIds }
}
/** 勾选 / 取消某单位的一名队员;单位没勾选也可以先登记名单。 */
export function toggleMemberPick(
state: TeamPickState,
teamId: number,
athleteId: number,
picked: boolean,
): TeamPickState {
const current = state.memberIds[teamId] ?? []
const next = picked
? current.includes(athleteId)
? current
: [...current, athleteId]
: current.filter((id) => id !== athleteId)
return { ...state, memberIds: { ...state.memberIds, [teamId]: next } }
}
/** 全选 / 清空单位勾选(传空数组即清空);已登记的队员名单保留。 */
export function setPickedTeams(state: TeamPickState, teamIds: readonly number[]): TeamPickState {
return { ...state, teamIds: [...teamIds] }
}
/** 「全选」的范围:启用中且还没报名的单位。已停用单位要单独勾,避免批量报错。 */
export function selectableTeamIds(
teams: readonly { id: number; active: boolean }[],
registered: ReadonlySet<number>,
): number[] {
return teams.filter((team) => team.active && !registered.has(team.id)).map((team) => team.id)
}
/**
* 勾选状态 → 提交用的单位列表。
*
* 单位按传入顺序(即界面列表顺序)产出,队员也只按运动员列表顺序取启用中的人,
* 保证同一份勾选每次生成的 member_names 完全一致。
*/
export function buildTeamSelections(
state: TeamPickState,
teams: readonly { id: number; code: string }[],
athletes: readonly { id: number; teamId: number; name: string; active: boolean }[],
): TeamSelection[] {
const picked = new Set(state.teamIds)
const selections: TeamSelection[] = []
for (const team of teams) {
if (!picked.has(team.id)) continue
const memberIds = new Set(state.memberIds[team.id] ?? [])
const memberNames = athletes
.filter((athlete) => athlete.active && athlete.teamId === team.id && memberIds.has(athlete.id))
.map((athlete) => athlete.name)
selections.push({ teamCode: team.code, memberNames })
}
return selections
}
/** 多单位团体报名是否放行:至少要勾一个要参加的单位(队员可以为 0 人)。 */
export function teamSelectionAllowsSubmit(pickedTeamCount: number): boolean {
return pickedTeamCount > 0
}
/** 组别下已报名的单位 id;团体行按 组别 + 单位 判断,与列表里的「已报名」徽标同源。 */
export function registeredTeamIds(
entries: readonly { categoryId: number; teamId: number }[],
categoryId: number | undefined,
): Set<number> {
const ids = new Set<number>()
if (categoryId === undefined) return ids
for (const entry of entries) {
if (entry.categoryId === categoryId) ids.add(entry.teamId)
}
return ids
}
/** 组别 + 单位下已报名的运动员 id;团体行 athleteId 为空,不会进集合。 */
export function registeredAthleteIds(
entries: readonly { categoryId: number; teamId: number; athleteId: number | null }[],
categoryId: number | undefined,
teamId: number | undefined,
): Set<number> {
const ids = new Set<number>()
if (categoryId === undefined || teamId === undefined) return ids
for (const entry of entries) {
if (entry.categoryId !== categoryId || entry.teamId !== teamId) continue
if (entry.athleteId !== null) ids.add(entry.athleteId)
}
return ids
}
/** 转成 CSV 文本;服务端用标准 csv 解析器读取,转义必须符合 RFC 4180。 */
export function toCsv(rows: string[][]): string {
return rows.map((row) => row.map(escapeField).join(',')).join('\n')
+179
View File
@@ -5,8 +5,18 @@ import {
OPTIONAL_COLUMNS,
REQUIRED_COLUMNS,
buildSelectionRows,
buildTeamSelectionRows,
buildTeamSelections,
emptyTeamPick,
entryRowLabel,
registeredAthleteIds,
registeredTeamIds,
selectableTeamIds,
selectionAllowsSubmit,
setPickedTeams,
teamSelectionAllowsSubmit,
toggleMemberPick,
toggleTeamPick,
toCsv,
} from '../components/baseData/entrySelection'
@@ -114,6 +124,175 @@ test('团体 / 对抗项目 0 勾选可提交,个人项目必须勾人', () =>
assert.equal(selectionAllowsSubmit('individual', 1), true)
})
// 团体报名改成「一次勾多个单位,每个单位一条报名」:产出必须与勾选顺序、列表顺序都稳定。
test('buildTeamSelectionRows 多个单位各一行,队员名单按 / 拼接,0 队员留空', () => {
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: [
{ teamCode: 'F1', memberNames: ['王芳', '刘洋'] },
{ teamCode: 'F2', memberNames: [] },
{ teamCode: 'F3', memberNames: ['张伟'] },
],
})
assert.equal(rows.length, 4, '表头 + 3 个单位')
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
assert.equal(rows[1]?.length, 11)
for (const row of rows.slice(1)) {
assert.equal(row[3], '', '团体行的 athlete_name 必须为空,服务端才按单位 upsert')
}
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', 'registered', '王芳/刘洋'])
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'registered', ''])
assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', 'registered', '张伟'])
})
test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可覆盖', () => {
const empty = buildTeamSelectionRows({ eventCode: 'BB', categoryName: '混合组', teams: [] })
assert.equal(empty.length, 1, '不勾单位就不产出数据行')
const scratched = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
status: 'scratched',
teams: [{ teamCode: 'F1', memberNames: [] }],
})
assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', 'scratched', ''])
})
test('toggleTeamPick 勾选 / 取消单位,取消不清队员名单', () => {
let state = emptyTeamPick()
state = toggleTeamPick(state, 1, true)
state = toggleTeamPick(state, 2, true)
state = toggleMemberPick(state, 1, 11, true)
assert.deepEqual(state.teamIds, [1, 2])
assert.deepEqual(toggleTeamPick(state, 2, true).teamIds, [1, 2], '重复勾选幂等')
state = toggleTeamPick(state, 1, false)
assert.deepEqual(state.teamIds, [2])
assert.deepEqual(state.memberIds[1], [11], '取消勾选保留队员名单')
assert.deepEqual(toggleTeamPick(state, 1, true).memberIds[1], [11], '重新勾选恢复原名单')
})
test('toggleMemberPick 单位没勾选也能先登记队员,各单位名单互不影响', () => {
let state = toggleMemberPick(emptyTeamPick(), 3, 31, true)
state = toggleMemberPick(state, 3, 32, true)
state = toggleMemberPick(state, 4, 41, true)
assert.deepEqual(state.teamIds, [], '只登记队员不会顺手勾上单位')
assert.deepEqual(state.memberIds[3], [31, 32])
assert.deepEqual(state.memberIds[4], [41])
assert.deepEqual(toggleMemberPick(state, 3, 31, true).memberIds[3], [31, 32], '重复勾选幂等')
state = toggleMemberPick(state, 3, 31, false)
assert.deepEqual(state.memberIds[3], [32])
assert.deepEqual(state.memberIds[4], [41], '改一个单位的名单不动其它单位')
})
test('setPickedTeams 全选 / 清空只改单位勾选,保留已登记的队员', () => {
let state = toggleMemberPick(emptyTeamPick(), 1, 11, true)
state = setPickedTeams(state, [1, 2, 3])
assert.deepEqual(state.teamIds, [1, 2, 3])
assert.deepEqual(state.memberIds[1], [11])
state = setPickedTeams(state, [])
assert.deepEqual(state.teamIds, [])
assert.deepEqual(state.memberIds[1], [11], '清空后名单还在,重新勾上即恢复')
})
test('selectableTeamIds 只含启用中且未报名的单位', () => {
const teams = [
{ id: 1, active: true },
{ id: 2, active: false },
{ id: 3, active: true },
]
assert.deepEqual(selectableTeamIds(teams, new Set([3])), [1], '已停用与已报名的都不进「全选」')
assert.deepEqual(selectableTeamIds(teams, new Set()), [1, 3])
assert.deepEqual(selectableTeamIds(teams, new Set([1, 3])), [], '全报过之后全选为空')
assert.deepEqual(selectableTeamIds([], new Set([1])), [])
})
test('buildTeamSelections 只产出勾选单位的队员,并按列表顺序取启用中的人', () => {
const teams = [
{ id: 1, code: 'F1' },
{ id: 2, code: 'F2' },
{ id: 3, code: 'F3' },
]
const athletes = [
{ id: 11, teamId: 1, name: '张伟', active: true },
{ id: 12, teamId: 1, name: '李强', active: true },
{ id: 13, teamId: 1, name: '王芳', active: false },
{ id: 21, teamId: 2, name: '刘洋', active: true },
]
let state = toggleTeamPick(emptyTeamPick(), 2, true)
state = toggleTeamPick(state, 1, true)
state = toggleMemberPick(state, 1, 12, true)
state = toggleMemberPick(state, 1, 13, true)
state = toggleMemberPick(state, 1, 11, true)
state = toggleMemberPick(state, 3, 99, true)
assert.deepEqual(buildTeamSelections(state, teams, athletes), [
{ teamCode: 'F1', memberNames: ['张伟', '李强'] },
{ teamCode: 'F2', memberNames: [] },
])
assert.deepEqual(
buildTeamSelections(setPickedTeams(state, []), teams, athletes),
[],
'没勾单位就没有要提交的行',
)
})
test('teamSelectionAllowsSubmit 至少勾一个单位,队员可以为 0 人', () => {
assert.equal(teamSelectionAllowsSubmit(0), false, '一个单位都不勾不能提交')
assert.equal(teamSelectionAllowsSubmit(1), true, '只报单位(0 队员)可以提交')
assert.equal(teamSelectionAllowsSubmit(5), true)
})
// 端到端拼接:勾选状态 → 多单位报名行 → 送进导入接口的 CSV 文本。
// 服务端按表头名字取值,列数与转义必须和导入表一致。
test('勾选状态转成 CSV 后表头与导入表一致,每个单位一行', () => {
const teams = [
{ id: 1, code: 'F1' },
{ id: 2, code: 'F2' },
]
const athletes = [
{ id: 11, teamId: 1, name: '张,伟', active: true },
{ id: 12, teamId: 1, name: '李强', active: true },
{ id: 21, teamId: 2, name: '王芳', active: true },
]
let state = toggleTeamPick(emptyTeamPick(), 1, true)
state = toggleTeamPick(state, 2, true)
state = toggleMemberPick(state, 1, 11, true)
state = toggleMemberPick(state, 1, 12, true)
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: buildTeamSelections(state, teams, athletes),
})
assert.equal(rows[1]?.length, REQUIRED_COLUMNS.length)
assert.equal(rows[2]?.length, REQUIRED_COLUMNS.length)
const lines = toCsv(rows).split('\n')
assert.equal(lines[0], REQUIRED_COLUMNS.join(','))
assert.equal(lines.length, 3, '表头 + 两个单位')
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,registered,"张,伟/李强"', '带逗号的姓名按 RFC 4180 转义')
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,registered,', '只报单位时 member_names 留空')
})
test('registeredTeamIds 按组别取已报名单位;registeredAthleteIds 只认具体运动员', () => {
const entries = [
{ categoryId: 7, teamId: 1, athleteId: 11 },
{ categoryId: 7, teamId: 1, athleteId: 12 },
{ categoryId: 7, teamId: 2, athleteId: null },
{ categoryId: 8, teamId: 3, athleteId: 13 },
]
assert.deepEqual([...registeredTeamIds(entries, 7)].sort((a, b) => a - b), [1, 2])
assert.deepEqual([...registeredTeamIds(entries, 9)], [], '别的组别不算已报名')
assert.deepEqual([...registeredTeamIds(entries, undefined)], [], '没选组别时没有已报名')
assert.deepEqual([...registeredAthleteIds(entries, 7, 1)].sort((a, b) => a - b), [11, 12])
assert.deepEqual([...registeredAthleteIds(entries, 7, 2)], [], '团体行不带运动员')
assert.deepEqual([...registeredAthleteIds(entries, 8, 3)], [13])
assert.deepEqual([...registeredAthleteIds(entries, undefined, 1)], [])
})
// 团体 / 接力行服务端不下发 athleteName(omitempty),回落到队员 / 单位名,
// 否则防呆校验的文案会变成「:缺少成绩」。
test('entryRowLabel 团体行回落到队员首人 / 单位', () => {
@@ -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'])
})
+1 -1
View File
@@ -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)
}
})
+1 -1
View File
@@ -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 {
+41
View File
@@ -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)
}
})
+1 -1
View File
@@ -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'