diff --git a/VERSION b/VERSION index 3dd72cf..1ac8a0d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.5.7 +dev_0.5.8 diff --git a/console/installer/art/1x/brand.bmp b/console/installer/art/1x/brand.bmp index a097737..a05560d 100644 Binary files a/console/installer/art/1x/brand.bmp and b/console/installer/art/1x/brand.bmp differ diff --git a/console/installer/art/2x/brand.bmp b/console/installer/art/2x/brand.bmp index bc3d6de..96819a7 100644 Binary files a/console/installer/art/2x/brand.bmp and b/console/installer/art/2x/brand.bmp differ diff --git a/console/installer/art/version.txt b/console/installer/art/version.txt index d3532a1..659914a 100644 --- a/console/installer/art/version.txt +++ b/console/installer/art/version.txt @@ -1 +1 @@ -0.5.7 +0.5.8 diff --git a/console/package.json b/console/package.json index 03cf6d2..2f7c1dc 100644 --- a/console/package.json +++ b/console/package.json @@ -1,7 +1,7 @@ { "name": "scoreboard-console", "productName": "Scoreboard Console", - "version": "0.5.7", + "version": "0.5.8", "private": true, "author": "AskaEth", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", diff --git a/docs/升级说明.md b/docs/升级说明.md index 1186194..d637eed 100644 --- a/docs/升级说明.md +++ b/docs/升级说明.md @@ -6,7 +6,46 @@ --- -## dev_0.5.7(当前版本) +## dev_0.5.8(当前版本) + +### 一句话 + +**没有数据迁移,升级后不用重配任何东西**:给「发布」加了一个**口径开关** —— 分轮次比赛(预赛 / 复赛 / 决赛)时,**只有决赛那次发布才计分发牌**;预赛发布**只更新名次、不计分**。这样同一个项目的多轮成绩可以各发各的,而榜单上只认最后那一轮的分。 + +### ① 新增:「以决赛成绩发布」开关 + +- **位置**:成绩录入页(以及**成绩审核页**)的「发布」按钮旁边,一个勾选框:**「以决赛成绩发布(勾选才计分发牌)」**,**默认不勾**。旁边有一个小徽标随时告诉你当前是哪种口径:**只更新名次(不计分)** / **计分发布(算分发牌)**。 +- **不勾(默认)**:这次发布**只更新名次** —— 名次会照常算出来、写进系统(预赛也要排名次),但**不产生积分、不发奖牌**。 +- **勾上**:与升级前**完全一样** —— 算分、发牌、上榜。 +- **勾选是"这一次发布"的口径,不是页面设置**:发完自动复位(免得您换到别的小项时,把预赛当成决赛算分)。 +- ⚠️ **不勾时不会动已有的分**:如果这个小项之前已经**按决赛发布过**(榜上已有分),现在再用「只更新名次」发一次(比如改个名次),那份积分与奖牌会**原样保留、不随名次变动**。界面上会明确提示这一点(见下)。 + +### ② 界面会告诉你「这一组现在在榜上是什么」 + +发过一次之后,录入页会常驻显示一行**在榜口径**: + +- 「**本组在榜上只有名次**(上次是按只更新名次发布的)」——还没有分; +- 「**本组在榜上是已计分的成绩**(已有 X 分来自上一次按决赛发布)」——分已经在榜上。 + +文案说得比代码准:系统不记「上次勾了什么」,它看的是**榜面上有没有分**这个事实。 + +**确认框里也会写清这次会发生什么**:勾选时标题是「确认按决赛成绩发布该组?」,不勾时是「确认发布该组(只更新名次)?」;不勾时不会再报「影响 N 个单位、最大分差 X 分」(那是计分才有的效果),而已有在榜分时会加一句提醒。 + +### ③ 两条使用上的提醒 + +1. **两个发布入口都有这个开关**:成绩录入页与成绩审核页 —— 从哪一页发布口径都一样(否则从审核页发布预赛会被悄悄算成分)。 +2. **只在"分轮次"的项目上用**:一次就比完的项目照旧**勾上**发布(与以前一样),别漏勾 —— 漏勾的话名次会更新,但**不会计分**,榜上不会有这一项的分(界面上有徽标和在榜口径提示,发布前看一眼就不会错)。 + +### ④ 升级后 5 分钟核对 + +1. 打开一个**已经发布过成绩**的小项,看发布按钮旁边的徽标与那行「在榜口径」提示是否说得对(有分的应显示「已计分的成绩」); +2. 找一个可以试的项目:不勾选发布一次,确认**名次更新了、分数没动**; +3. 再勾选发布一次,确认**分数按决赛口径正常算出来**; +4. 其余照旧:**不需要重新导入规则包,也不需要重配任何东西**。 + +--- + +## dev_0.5.7 ### 一句话 diff --git a/server/internal/domain/scoring.go b/server/internal/domain/scoring.go index fb85e0f..ac1c587 100644 --- a/server/internal/domain/scoring.go +++ b/server/internal/domain/scoring.go @@ -13,6 +13,13 @@ const rankEpsilon = 1e-9 type ScoreOptions struct { // IncludeDrafts 为 true 时草稿成绩也参与计分;默认只算已发布(草稿不上屏也不计分)。 IncludeDrafts bool + // RankOnly 为 true 时本次只产出名次、不产出任何流水分值(发布侧的「只更新名次」)。 + // + // 与「小项没有计分通道」那条退化路径同一个出口:名次照算(预赛排名次是刚需), + // 但每条名次的 Points 归零、Ledger 为空、破纪录加分也不产生。 + // 区别只在于通道**仍然逐条照原样参与排名**——退化路径会把小项压成一条合成通道, + // 那会改掉名次口径与主体,不是这里想要的。 + RankOnly bool } // EventScoreInput 是一次小项(某组别)计分的全部输入,引擎只读不改。 @@ -91,12 +98,13 @@ func ScoreEvent(in EventScoreInput) (EventScore, error) { } } var out EventScore - rankOnly := len(in.Channels) == 0 - // 只排名不计分:scored=false 或没有计分通道时都不产出任何流水(含破纪录加分)。 - scoreEnabled := in.Event.Scored && !rankOnly + noChannels := len(in.Channels) == 0 + // 只排名不计分:scored=false、没有计分通道、或本次声明「只更新名次」时都不产出任何流水 + // (含破纪录加分)。 + scoreEnabled := in.Event.Scored && !noChannels && !in.Options.RankOnly channels := sortedChannels(in.Channels) - if rankOnly { + if noChannels { channels = []ScoreChannel{rankOnlyChannel(in)} } diff --git a/server/internal/httpapi/handlers_results.go b/server/internal/httpapi/handlers_results.go index b2fb33d..4d285b2 100644 --- a/server/internal/httpapi/handlers_results.go +++ b/server/internal/httpapi/handlers_results.go @@ -51,7 +51,7 @@ func (s *server) saveDraftHandler(w http.ResponseWriter, r *http.Request) (any, } func (s *server) publishHandler(w http.ResponseWriter, r *http.Request) (any, error) { - var in publishPayload + var in publishResultsPayload if err := decodeJSON(r, &in); err != nil { return nil, err } diff --git a/server/internal/httpapi/payload.go b/server/internal/httpapi/payload.go index bac771a..b593f33 100644 --- a/server/internal/httpapi/payload.go +++ b/server/internal/httpapi/payload.go @@ -33,6 +33,20 @@ type publishPayload struct { RequestID string `json:"requestId"` } +// publishResultsPayload 是 POST /api/results/publish 的请求体:在发布请求体上多一个发布口径。 +// +// 单独一个类型(而不是把字段塞进 publishPayload)是因为「撤销发布 / 立即结算」没有 +// 「按决赛发布」这回事:共用一个结构只会让它们静默吃下一个毫无作用的字段。 +type publishResultsPayload struct { + publishPayload + // RankOnly 为 true 时本次发布只更新名次:写 results.rank,但不产生积分流水、 + // 不重建通道名次、不发奖牌、不动榜单分数(名次照旧更新——预赛也要排名次)。 + // + // 缺省 / false = 计分发布,与改动前逐字节一致:老客户端(不带这个字段)与勾选了 + // 「以决赛成绩发布」的新客户端走的是同一条路。 + RankOnly bool `json:"rankOnly"` +} + type voidResultPayload struct { Reason string `json:"reason"` RequestID string `json:"requestId"` diff --git a/server/internal/httpapi/publish_rank_only_test.go b/server/internal/httpapi/publish_rank_only_test.go new file mode 100644 index 0000000..f474a4b --- /dev/null +++ b/server/internal/httpapi/publish_rank_only_test.go @@ -0,0 +1,379 @@ +package httpapi_test + +import ( + "fmt" + "net/http" + "testing" +) + +// 本文件钉住「以决赛成绩发布」这个新勾选项(发布请求的 rankOnly 字段)。 +// +// 口径: +// - 缺省 / false = 与改动前逐字节一致的计分发布(算分、发牌、上榜); +// - true = 只更新名次:写 results.rank,不写积分流水、不重建 channel_ranks、不动榜单。 +// +// 前半段(golden 快照)是「勾上时 = 现状路径」的门槛:改动前先把计分发布的整份写库结果 +// 落成基线,改动后逐字节对拍。后半段是新行为的红绿用例。 + +// ---------- 计分发布的整份快照(改动前采集的基线) ---------- + +// snapshotMode 是「发布请求里 rankOnly 怎么给」的三种给法。 +type snapshotMode int + +const ( + // rankOnlyOmitted 完全不带这个字段:老客户端 / 老脚本的调用形态。 + rankOnlyOmitted snapshotMode = iota + // rankOnlyFalse 显式 false:新客户端「勾了以决赛成绩发布」的调用形态。 + rankOnlyFalse + // rankOnlyTrue 显式 true:新客户端「没勾」的调用形态。 + rankOnlyTrue +) + +// publishWith 按 mode 调发布接口(rankOnly 完全按 mode 决定带不带、带什么)。 +func publishWith(t *testing.T, env *testEnv, client *http.Client, eventID int64, tag string, mode snapshotMode) { + t.Helper() + body := map[string]any{"eventId": eventID, "requestId": tag + "-publish"} + switch mode { + case rankOnlyOmitted: + case rankOnlyFalse: + body["rankOnly"] = false + case rankOnlyTrue: + body["rankOnly"] = true + } + status, resp := env.do(client, http.MethodPost, "/api/results/publish", body, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("发布应 200,得到 %d %+v", status, resp.Error) + } +} + +// legacyScenarioSnapshot 复刻「改动前」的计分发布现场,并把结果整份 dump 成可逐字节比较的文本。 +// +// 场景刻意铺宽一点,让快照覆盖发布真正会写的东西: +// - 单通道个人项目(按成绩自动排名 → results.rank / 流水 / channel_ranks / 榜单奖牌); +// - 双通道项目(个人自动 + 团体手工),团体名次按单位录入(这类流水没有报名项, +// 按组别重算时的作废范围与其它流水不同)。 +// +// 快照里的 id、分数、名次全部来自数据库自增与引擎输出,跑两次逐字节一致(本文件下方用例自证)。 +func legacyScenarioSnapshot(t *testing.T, mode snapshotMode) string { + t.Helper() + env, ids, client := recorderWithSeed(t) + + // 场景一:单通道个人项目(seed 自带),两条成绩。 + if status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + "requestId": "snap-1-draft", + "results": []map[string]any{ + {"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"}, + }, + }, true); status != http.StatusOK { + t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error) + } + publishWith(t, env, client, ids.event, "snap-1", mode) + + // 场景二:双通道(个人自动 + 团体手工),团体名次按单位录入。 + eventID, categoryID, entries := seedDualChannelTeamEvent(t, env, ids) + if status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": eventID, + "categoryId": categoryID, + "requestId": "snap-2-draft", + "results": []map[string]any{ + {"entryId": entries[0], "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"}, + {"entryId": entries[1], "resultValue": 80, "resultText": "80", "unit": "次", "status": "ok"}, + {"entryId": entries[2], "resultValue": 70, "resultText": "70", "unit": "次", "status": "ok"}, + }, + }, true); status != http.StatusOK { + t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error) + } + var teamC int64 + if err := env.ev.QueryRow(`SELECT id FROM teams WHERE code = 'C'`).Scan(&teamC); err != nil { + t.Fatalf("读 C 队: %v", err) + } + if status, resp := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{ + {"teamId": ids.teamA, "rank": 2}, + {"teamId": ids.teamB, "rank": 1}, + {"teamId": teamC, "rank": 3}, + }); status != http.StatusOK { + t.Fatalf("保存团体名次应 200,得到 %d %+v", status, resp.Error) + } + publishWith(t, env, client, eventID, "snap-2", mode) + + var b []byte + b = append(b, fmt.Sprintf("### results(id / entry_id / rank / value / state)\n%s\n", + tableDump(t, env, `SELECT id, entry_id, COALESCE(rank, -1) AS rank, COALESCE(result_value, -1) AS value, state + FROM results ORDER BY entry_id`))...) + b = append(b, fmt.Sprintf("### score_ledger(全部列,含作废)\n%s\n", + tableDump(t, env, `SELECT id, team_id, COALESCE(athlete_id, -1) AS athlete, COALESCE(entry_id, -1) AS entry, + COALESCE(result_id, -1) AS result, source_type, source_id, points, count_to_team, voided + FROM score_ledger ORDER BY id`))...) + b = append(b, fmt.Sprintf("### channel_ranks\n%s\n", + tableDump(t, env, `SELECT id, event_id, category_id, channel_id, basis, scope, subject_key, + COALESCE(entry_id, -1) AS entry, COALESCE(result_id, -1) AS result, + COALESCE(athlete_id, -1) AS athlete, COALESCE(team_id, -1) AS team, + rank, points, COALESCE(result_value, -1) AS value, COALESCE(status, '-') AS status + FROM channel_ranks ORDER BY id`))...) + b = append(b, fmt.Sprintf("### channel_manual_ranks\n%s\n", + tableDump(t, env, `SELECT id, event_id, category_id, team_id, scope, basis, rank + FROM channel_manual_ranks ORDER BY id`))...) + b = append(b, fmt.Sprintf("### result_revisions(action / message)\n%s\n", + tableDump(t, env, `SELECT id, event_id, action, COALESCE(message, '') AS message + FROM result_revisions ORDER BY id`))...) + + status, resp := env.do(client, http.MethodGet, "/api/standings", nil, false) + if status != http.StatusOK { + t.Fatalf("读积分榜应 200,得到 %d", status) + } + b = append(b, fmt.Sprintf("### GET /api/standings\n%s\n", resp.Data)...) + status, resp = env.do(client, http.MethodGet, "/api/standings/athletes", nil, false) + if status != http.StatusOK { + t.Fatalf("读运动员榜应 200,得到 %d", status) + } + b = append(b, fmt.Sprintf("### GET /api/standings/athletes\n%s\n", resp.Data)...) + return string(b) +} + +// legacyPublishSnapshotGolden 是**改动前**(HEAD)跑同一段场景落下来的基线。 +// 采集方式:把这棵树停在改动前,用与 legacyScenarioSnapshot 逐行相同的代码把 dump 写盘 +// (~/.tmp/sbdump/publish_before.txt),再原样贴进来;跑两次确认逐字节一致后才作为基线。 +const legacyPublishSnapshotGolden = `### results(id / entry_id / rank / value / state) +id=1 entry_id=1 rank=1 value=11.58 state=published +id=2 entry_id=2 rank=2 value=11.92 state=published +id=3 entry_id=3 rank=-1 value=90 state=published +id=4 entry_id=4 rank=-1 value=80 state=published +id=5 entry_id=5 rank=-1 value=70 state=published + +### score_ledger(全部列,含作废) +id=1 team_id=1 athlete=1 entry=1 result=1 source_type=result source_id=1 points=10 count_to_team=1 voided=0 +id=2 team_id=2 athlete=2 entry=2 result=2 source_type=result source_id=2 points=8 count_to_team=1 voided=0 +id=3 team_id=2 athlete=-1 entry=-1 result=-1 source_type=result source_id=3 points=15 count_to_team=1 voided=1 +id=4 team_id=1 athlete=-1 entry=-1 result=-1 source_type=result source_id=3 points=10 count_to_team=1 voided=1 +id=5 team_id=3 athlete=-1 entry=-1 result=-1 source_type=result source_id=3 points=8 count_to_team=1 voided=1 +id=6 team_id=1 athlete=-1 entry=3 result=3 source_type=result source_id=3 points=10 count_to_team=1 voided=0 +id=7 team_id=2 athlete=-1 entry=4 result=4 source_type=result source_id=4 points=8 count_to_team=1 voided=0 +id=8 team_id=3 athlete=-1 entry=5 result=5 source_type=result source_id=5 points=6 count_to_team=1 voided=0 +id=9 team_id=2 athlete=-1 entry=-1 result=-1 source_type=result source_id=3 points=15 count_to_team=1 voided=0 +id=10 team_id=1 athlete=-1 entry=-1 result=-1 source_type=result source_id=3 points=10 count_to_team=1 voided=0 +id=11 team_id=3 athlete=-1 entry=-1 result=-1 source_type=result source_id=3 points=8 count_to_team=1 voided=0 + +### channel_ranks +id=1 event_id=1 category_id=1 channel_id=1 basis=result scope=athlete subject_key=e:1 entry=1 result=1 athlete=1 team=1 rank=1 points=10 value=11.58 status=ok +id=2 event_id=1 category_id=1 channel_id=1 basis=result scope=athlete subject_key=e:2 entry=2 result=2 athlete=2 team=2 rank=2 points=8 value=11.92 status=ok +id=6 event_id=2 category_id=2 channel_id=2 basis=result scope=athlete subject_key=e:3 entry=3 result=3 athlete=-1 team=1 rank=1 points=10 value=90 status=ok +id=7 event_id=2 category_id=2 channel_id=2 basis=result scope=athlete subject_key=e:4 entry=4 result=4 athlete=-1 team=2 rank=2 points=8 value=80 status=ok +id=8 event_id=2 category_id=2 channel_id=2 basis=result scope=athlete subject_key=e:5 entry=5 result=5 athlete=-1 team=3 rank=3 points=6 value=70 status=ok +id=9 event_id=2 category_id=2 channel_id=3 basis=manual scope=team subject_key=t:2 entry=-1 result=-1 athlete=-1 team=2 rank=1 points=15 value=-1 status=- +id=10 event_id=2 category_id=2 channel_id=3 basis=manual scope=team subject_key=t:1 entry=-1 result=-1 athlete=-1 team=1 rank=2 points=10 value=-1 status=- +id=11 event_id=2 category_id=2 channel_id=3 basis=manual scope=team subject_key=t:3 entry=-1 result=-1 athlete=-1 team=3 rank=3 points=8 value=-1 status=- + +### channel_manual_ranks +id=1 event_id=2 category_id=2 team_id=1 scope=team basis=manual rank=2 +id=2 event_id=2 category_id=2 team_id=2 scope=team basis=manual rank=1 +id=3 event_id=2 category_id=2 team_id=3 scope=team basis=manual rank=3 + +### result_revisions(action / message) +id=1 event_id=1 action=publish message= +id=2 event_id=2 action=publish message= + +### GET /api/standings +[{"teamId":2,"teamCode":"B","teamName":"第二分公司","shortName":"二司","color":null,"logoPath":null,"rank":1,"medalRank":2,"gold":1,"silver":2,"bronze":0,"medalTotal":3,"totalPoints":31,"rankChange":null},{"teamId":1,"teamCode":"A","teamName":"第一分公司","shortName":"一司","color":null,"logoPath":null,"rank":2,"medalRank":1,"gold":2,"silver":1,"bronze":0,"medalTotal":3,"totalPoints":30,"rankChange":null},{"teamId":3,"teamCode":"C","teamName":"第三分公司","shortName":"三司","color":null,"logoPath":null,"rank":3,"medalRank":3,"gold":0,"silver":0,"bronze":2,"medalTotal":2,"totalPoints":14,"rankChange":null}] +### GET /api/standings/athletes +[{"rank":1,"athleteId":1,"athleteName":"张伟","gender":"男","teamId":1,"teamCode":"A","teamName":"第一分公司","shortName":"一司","logoPath":null,"totalPoints":10,"gold":1,"silver":0,"bronze":0},{"rank":2,"athleteId":2,"athleteName":"李强","gender":"男","teamId":2,"teamCode":"B","teamName":"第二分公司","shortName":"二司","logoPath":null,"totalPoints":8,"gold":0,"silver":1,"bronze":0}] +` + +// 勾上(显式 false)与不带字段(老客户端)都必须与改动前的字节完全一致: +// 名次、流水(含作废行与 id)、通道名次、团体手工名次、修订记录、两张榜。 +func TestPublishScoringSnapshotMatchesPreChangeBaseline(t *testing.T) { + for _, tc := range []struct { + name string + mode snapshotMode + }{ + {"不带 rankOnly 字段(老客户端)", rankOnlyOmitted}, + {"显式 rankOnly=false(勾了以决赛成绩发布)", rankOnlyFalse}, + } { + t.Run(tc.name, func(t *testing.T) { + got := legacyScenarioSnapshot(t, tc.mode) + if got != legacyPublishSnapshotGolden { + t.Fatalf("计分发布的写库结果与改动前基线不一致:\n--- 改动前 ---\n%s\n--- 现在 ---\n%s", legacyPublishSnapshotGolden, got) + } + }) + } +} + +// ---------- 只更新名次(rankOnly=true) ---------- + +// 非决赛发布:名次照旧更新(results.rank 写回),但不产生积分流水、不重建通道名次、不动榜单。 +func TestPublishRankOnlyUpdatesRanksWithoutPoints(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + if status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + "requestId": "ro-draft", + "results": []map[string]any{ + {"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"}, + }, + }, true); status != http.StatusOK { + t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error) + } + publishWith(t, env, client, ids.event, "ro", rankOnlyTrue) + + // 名次照旧更新:预赛也要排名次。 + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 2, ids.event) + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, 2, ids.event) + if got := rankOfEntry(t, env, ids.entryA); got != 1 { + t.Fatalf("最快的应第 1 名,得到 %d", got) + } + if got := rankOfEntry(t, env, ids.entryB); got != 2 { + t.Fatalf("第二快的应第 2 名,得到 %d", got) + } + + // 不产生积分流水,也不落通道名次(通道名次是奖牌与榜单明细的来源,落了就等于发牌)。 + assertCount(t, env, `SELECT COUNT(*) FROM score_ledger`, 0) + assertCount(t, env, `SELECT COUNT(*) FROM channel_ranks`, 0) + + // 榜单:总分与奖牌都不动。 + for _, teamID := range []int64{ids.teamA, ids.teamB} { + if got := teamPoints(t, env, client, teamID); got != 0 { + t.Fatalf("单位 %d 的总分应为 0,得到 %v", teamID, got) + } + } + medals := medalsByTeam(t, env) + for _, teamID := range []int64{ids.teamA, ids.teamB} { + if m := medals[teamID]; m.Gold != 0 || m.Silver != 0 || m.Bronze != 0 { + t.Fatalf("单位 %d 不该拿到奖牌,得到 %+v", teamID, m) + } + } + + // 已发布成绩的分下发里也不该出现分数。 + status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/results?eventId=%d", ids.event), nil, false) + if status != http.StatusOK || !resp.OK { + t.Fatalf("读成绩列表应 200,得到 %d %+v", status, resp.Error) + } + page := decodeData[struct { + Items []map[string]any `json:"items"` + }](t, resp) + if len(page.Items) != 2 { + t.Fatalf("成绩列表应有 2 条,得到 %d", len(page.Items)) + } + for _, row := range page.Items { + if points, ok := row["points"]; ok && points != nil { + t.Fatalf("只更新名次的发布不该给出得分,得到 %v(成绩 %v)", points, row["id"]) + } + } +} + +// 非决赛发布不许动已有的积分流水:决赛那份分是有效的,不该被一次预赛发布抹掉。 +// +// 这里刻意造出「名次变了、分还是旧的」:先把决赛成绩计分发布,再改成绩、按只更新名次发布。 +// 名次会跟着新成绩换位,但流水与通道名次保持决赛那一版(并在界面上提示,见录入页确认框)。 +func TestPublishRankOnlyKeepsExistingScoringLedger(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + if status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + "requestId": "keep-draft", + "results": []map[string]any{ + {"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"}, + }, + }, true); status != http.StatusOK { + t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error) + } + // 决赛:勾上发布(= 现状路径)。 + publishWith(t, env, client, ids.event, "keep-final", rankOnlyFalse) + ledgerBefore := tableDump(t, env, `SELECT id, team_id, entry_id, points, voided FROM score_ledger ORDER BY id`) + ranksBefore := tableDump(t, env, `SELECT id, subject_key, rank, points FROM channel_ranks ORDER BY id`) + standingsBefore := standingsDump(t, env, client) + + // 成绩改过来(现场就是「退回草稿改完再发布」,这里直接改库,把被测面收窄到发布本身)。 + env.exec(t, `UPDATE results SET result_value = 11.92, result_text = '11.92' WHERE entry_id = ?`, ids.entryA) + env.exec(t, `UPDATE results SET result_value = 11.58, result_text = '11.58' WHERE entry_id = ?`, ids.entryB) + + // 预赛:不勾,只更新名次。 + publishWith(t, env, client, ids.event, "keep-prelim", rankOnlyTrue) + + // 名次跟着新成绩换了位。 + if got := rankOfEntry(t, env, ids.entryA); got != 2 { + t.Fatalf("改了成绩后 A 应退到第 2 名,得到 %d", got) + } + if got := rankOfEntry(t, env, ids.entryB); got != 1 { + t.Fatalf("改了成绩后 B 应升到第 1 名,得到 %d", got) + } + // 决赛那份流水一个字节都不能动(id 与 points 都不变,也不许新作废)。 + if got := tableDump(t, env, `SELECT id, team_id, entry_id, points, voided FROM score_ledger ORDER BY id`); got != ledgerBefore { + t.Fatalf("只更新名次的发布动了已有流水:\n--- 发布前 ---\n%s\n--- 发布后 ---\n%s", ledgerBefore, got) + } + // 通道名次(奖牌与榜单明细的来源)同样不动。 + if got := tableDump(t, env, `SELECT id, subject_key, rank, points FROM channel_ranks ORDER BY id`); got != ranksBefore { + t.Fatalf("只更新名次的发布重建了通道名次:\n--- 发布前 ---\n%s\n--- 发布后 ---\n%s", ranksBefore, got) + } + // 榜单总分与奖牌不动。 + if got := standingsDump(t, env, client); got != standingsBefore { + t.Fatalf("只更新名次的发布改了榜单:\n--- 发布前 ---\n%s\n--- 发布后 ---\n%s", standingsBefore, got) + } +} + +// 预赛(只更新名次)之后照样能按决赛发布:名次、流水、奖牌一次补齐。 +func TestPublishFinalAfterRankOnlyAwardsPoints(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + if status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + "requestId": "flow-draft", + "results": []map[string]any{ + {"entryId": ids.entryA, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "resultValue": 12.00, "resultText": "12.00", "unit": "s", "status": "ok"}, + }, + }, true); status != http.StatusOK { + t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error) + } + publishWith(t, env, client, ids.event, "flow-prelim", rankOnlyTrue) + assertCount(t, env, `SELECT COUNT(*) FROM score_ledger`, 0) + if got := rankOfEntry(t, env, ids.entryB); got != 1 { + t.Fatalf("预赛里 B 应第 1 名,得到 %d", got) + } + + // 决赛成绩(现场是改完再发一次),勾上发布。 + env.exec(t, `UPDATE results SET result_value = 11.58, result_text = '11.58' WHERE entry_id = ?`, ids.entryA) + env.exec(t, `UPDATE results SET result_value = 11.92, result_text = '11.92' WHERE entry_id = ?`, ids.entryB) + publishWith(t, env, client, ids.event, "flow-final", rankOnlyFalse) + + if got := rankOfEntry(t, env, ids.entryA); got != 1 { + t.Fatalf("决赛里 A 应第 1 名,得到 %d", got) + } + if got := rankOfEntry(t, env, ids.entryB); got != 2 { + t.Fatalf("决赛里 B 应第 2 名,得到 %d", got) + } + if got := teamPoints(t, env, client, ids.teamA); got != 10 { + t.Fatalf("决赛后 A 单位应 10 分,得到 %v", got) + } + if got := teamPoints(t, env, client, ids.teamB); got != 8 { + t.Fatalf("决赛后 B 单位应 8 分,得到 %v", got) + } + medals := medalsByTeam(t, env) + if m := medals[ids.teamA]; m.Gold != 1 { + t.Fatalf("决赛后 A 单位应 1 金,得到 %+v", m) + } + if m := medals[ids.teamB]; m.Silver != 1 { + t.Fatalf("决赛后 B 单位应 1 银,得到 %+v", m) + } + assertCount(t, env, `SELECT COUNT(*) FROM channel_ranks`, 2) +} + +// standingsDump 把两张榜的读数摊成一段可比对文本(榜单是发布对外可见的最终产物)。 +func standingsDump(t *testing.T, env *testEnv, client *http.Client) string { + t.Helper() + out := "" + for _, path := range []string{"/api/standings", "/api/standings/athletes"} { + status, resp := env.do(client, http.MethodGet, path, nil, false) + if status != http.StatusOK || !resp.OK { + t.Fatalf("读 %s 应 200,得到 %d %+v", path, status, resp.Error) + } + out += path + "\n" + string(resp.Data) + "\n" + } + return out +} diff --git a/server/internal/httpapi/rank_only_heal_test.go b/server/internal/httpapi/rank_only_heal_test.go new file mode 100644 index 0000000..a60f665 --- /dev/null +++ b/server/internal/httpapi/rank_only_heal_test.go @@ -0,0 +1,44 @@ +package httpapi_test + +import ( + "bytes" + "net/http" + "testing" +) + +// 只更新名次的那一批成绩,不许被「老库升级自愈」的全量重算算成分。 +// +// 自愈的判据里有 channel_ranks 为空这一条,而只更新名次的发布恰恰不写 channel_ranks, +// 一旦自愈照旧触发,现场重启一次服务端预赛就会变成计分发布(还会发出奖牌)。 +func TestHealDoesNotScoreRankOnlyPublish(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + if status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + "requestId": "heal-ro-draft", + "results": []map[string]any{ + {"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"}, + }, + }, true); status != http.StatusOK { + t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error) + } + publishWith(t, env, client, ids.event, "heal-ro", rankOnlyTrue) + + // 这正是老库的形态(channel_ranks 空 + 有已发布名次),只差没有流水。 + if n := channelRankCount(t, env); n != 0 { + t.Fatalf("只更新名次的发布不该写 channel_ranks,得到 %d 行", n) + } + var logs bytes.Buffer + if res := runHeal(t, env, &logs); res.Triggered { + t.Fatalf("流水为空的库不该触发全量重算(会把预赛算成分):%+v\n%s", res, logs.String()) + } + assertCount(t, env, `SELECT COUNT(*) FROM score_ledger`, 0) + if got := rankOfEntry(t, env, ids.entryA); got != 1 { + t.Fatalf("自愈不该动名次:A 应仍是第 1 名,得到 %d", got) + } + if got := teamPoints(t, env, client, ids.teamA); got != 0 { + t.Fatalf("自愈后 A 单位仍应 0 分,得到 %v", got) + } +} diff --git a/server/internal/httpapi/recompute_heal.go b/server/internal/httpapi/recompute_heal.go index d7b7e69..45c3510 100644 --- a/server/internal/httpapi/recompute_heal.go +++ b/server/internal/httpapi/recompute_heal.go @@ -74,14 +74,20 @@ func HealChannelRanksOnce(ctx context.Context, opts Options) (HealResult, error) // channelRankHealNeeded 读出自愈的两个判据,返回「有已发布名次的小项数」与是否需要触发。 // 两条读数走同一次查询,避免两次查询之间库被改动导致判据互不自洽。 +// +// 第三个判据(流水)是「只更新名次」发布加进来的:自愈补的是**老库升级后没回填的名次**, +// 那种库一定结算过(发布时会写流水)。而只更新名次的发布不写任何流水,光看前两个判据 +// 分不出「老库」与「只排名发布过的新库」——现场只要重启一次服务端,自愈的全量重算就会把 +// 预赛成绩算成分、发出奖牌,正好抵消掉这个功能。流水为空时不动它(名次与奖牌本来也没被结算过)。 func channelRankHealNeeded(ctx context.Context, event *sql.DB) (eventCount int, needed bool, err error) { - var rankRows, publishedRankedEvents int64 + var rankRows, publishedRankedEvents, scoredLedgerRows int64 if err := event.QueryRowContext(ctx, `SELECT (SELECT COUNT(*) FROM channel_ranks), (SELECT COUNT(DISTINCT event_id) FROM results - WHERE state = 'published' AND rank IS NOT NULL AND rank > 0)`, - ).Scan(&rankRows, &publishedRankedEvents); err != nil { + WHERE state = 'published' AND rank IS NOT NULL AND rank > 0), + (SELECT COUNT(*) FROM score_ledger WHERE source_type IN ('result', 'record_bonus'))`, + ).Scan(&rankRows, &publishedRankedEvents, &scoredLedgerRows); err != nil { return 0, false, fmt.Errorf("检查通道名次自愈条件: %w", err) } - return int(publishedRankedEvents), rankRows == 0 && publishedRankedEvents > 0, nil + return int(publishedRankedEvents), rankRows == 0 && publishedRankedEvents > 0 && scoredLedgerRows > 0, nil } diff --git a/server/internal/httpapi/scoring.go b/server/internal/httpapi/scoring.go index 1337bc3..db5c2fb 100644 --- a/server/internal/httpapi/scoring.go +++ b/server/internal/httpapi/scoring.go @@ -344,8 +344,19 @@ func loadRecordBreaksQ(ctx context.Context, q queryer, eventID int64) ([]domain. // ---------- 计分 ---------- -// computeEventScore 计算某小项(或某组别)的流水与名次明细。 +// computeEventScore 是缺省口径(计分发布)的计分,供发布之外的各条重算路径使用。 +// +// ⚠️ 口径只有**发布这一次动作**带得上(见 publishResults 的 RankOnly):本函数是「按此刻已发布 +// 的成绩重算 = 计分」,被 立即结算 / 作废成绩 / 批量退赛 / 撤销发布 / 改队员与份额 / 改积分模板 / +// 规则包导入 共用。也就是说:一批「只更新名次」发布的成绩,在这些动作里照样会被算分——它们算的 +// 本来就是「此刻 results 里已发布的成绩」,与改动前一致(改动前这些路径也一样会算)。 +// 现场若要把某一组彻底排除在计分外,正确做法是把该组退回草稿(撤销发布),而不是只靠勾选。 func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64) (domain.EventScore, error) { + return computeEventScoreOpts(ctx, q, eventID, categoryID, domain.ScoreOptions{}) +} + +// computeEventScoreOpts 计算某小项(或某组别)的流水与名次明细,并带上引擎口径。 +func computeEventScoreOpts(ctx context.Context, q queryer, eventID, categoryID int64, opts domain.ScoreOptions) (domain.EventScore, error) { ev, err := loadEventQ(ctx, q, eventID) if err != nil { return domain.EventScore{}, err @@ -403,6 +414,7 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64 RecordBreaks: breaks, // 每个组别只拿自己组别的团体名次:名次在组别内有效,串组会算出别人的分。 TeamRanks: teamRanks[cat.ID], + Options: opts, }) if err != nil { return domain.EventScore{}, mapDomainError(err) @@ -805,6 +817,21 @@ func skipNote(skipped int) string { return fmt.Sprintf("跳过 %d 条只填了成绩文本、没有成绩值的草稿(本次未上屏,补上成绩值后重新发布)", skipped) } +// publishNote 是本条发布写进修订说明的文案:只更新名次时必须留一句——榜单一点没动, +// 事后复盘(变更历史)得能看出这一批是预赛口径,而不是「发布失败」或「没算分」。 +// 计分发布照旧只带跳过提示,一个字符都不加(老行为不许多出文案)。 +func publishNote(rankOnly bool, skipped int) string { + skip := skipNote(skipped) + if !rankOnly { + return skip + } + const rankOnlyNote = "本次按「只更新名次」发布:名次已更新,未计分、未发奖牌,榜单分数与已有积分不变" + if skip == "" { + return rankOnlyNote + } + return rankOnlyNote + ";" + skip +} + // liveOnPublishQ 读「发布后是否立即结算」:缺键 / 脏值一律按开启处理,保证历史行为。 func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) { var raw string @@ -815,7 +842,7 @@ func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) { return boolDefault(raw, true), nil } -func (s *server) publishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) { +func (s *server) publishResults(ctx context.Context, in publishResultsPayload, userID int64) ([]resultDTO, error) { if in.EventID == 0 { return nil, badRequest("eventId 必填", nil) } @@ -883,14 +910,29 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i // 名次也要等结算才写:名次(results.rank 与 channel_ranks)只在这里落, // 分批写名次会让奖牌列跟着每批发布跳动,而总分来自流水、不写流水就不动, // 同一张榜上「有金牌但 0 分」自相矛盾。所以延迟期间只落成绩,名次一并等结算。 - score, err := computeEventScore(ctx, tx, in.EventID, catID) + score, err := computeEventScoreOpts(ctx, tx, in.EventID, catID, domain.ScoreOptions{RankOnly: in.RankOnly}) if err != nil { return nil, err } if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { return nil, err } - if err := writeLedgerTx(ctx, tx, score, in.EventID, catID, userID, "发布重算"); err != nil { + if in.RankOnly { + // 只更新名次:名次已写回 results.rank(上面那一步),到此为止——流水与通道名次一个 + // 字节都不动。 + // + // 为什么不顺手重建通道名次:channel_ranks 只存名次,奖牌是「rank 在前三 + 小项结算 + // 奖牌 + 通道发牌」算出来的,**不看分数**。预赛名次写进去 = 给预赛发奖牌;反过来, + // 本组上次若已按决赛计分发布过,重建会把那批名次(连同决赛的奖牌)抹掉,而决赛那份 + // 分还留在流水里 —— 榜上就会「分还在、牌没了」。 + // + // 为什么不顺手写流水:writeLedgerTx 会先把范围内的旧流水整批作废再写新的, + // 而决赛那份积分是有效的,不该被一次预赛发布抹掉。 + // + // 于是本次发布只动 results.rank:录入页的名次列、大屏最新成绩、分项成绩表读的都是它。 + // 名单/明细里「名次」关联不到 channel_ranks 时本来就回落到 results.rank(见 + // breakdownRankExpr / athleteDetailRankExpr),所以能看到的都跟着更新。 + } else if err := writeLedgerTx(ctx, tx, score, in.EventID, catID, userID, "发布重算"); err != nil { return nil, err } } @@ -917,14 +959,16 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i if err != nil { return nil, err } - if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, skipNote(skipped)); err != nil { + if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, publishNote(in.RankOnly, skipped)); err != nil { return nil, err } if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil { return nil, err } // 没结算就不推榜单:延迟期间榜单保持上一次结算的状态,大屏不该因为一批成绩而跳动。 - if settle { + // 只更新名次时榜单同样不推:分数与奖牌一个数字都没变,推一版「没变的榜单」只会让现场 + // 以为榜单跟着这批成绩动了。成绩本身的变更由上面 recordChanges 推出去,大屏据此重拉名次。 + if settle && !in.RankOnly { if err := s.bumpStandings(ctx, tx, userID); err != nil { return nil, err } diff --git a/web/src/admin/pages/EntryPage.vue b/web/src/admin/pages/EntryPage.vue index c84afcc..786eabf 100644 --- a/web/src/admin/pages/EntryPage.vue +++ b/web/src/admin/pages/EntryPage.vue @@ -119,6 +119,17 @@ import { type HeatFilter, } from './entryHeat' import { adminApi, sampleDataBlockedNotice } from '../api/client' +import { + FINAL_PUBLISH_LABEL, + FINAL_PUBLISH_TITLE, + PREVIEW_RANK_ONLY_HINT, + boardModeNote, + publishModeBadge, + publishModeConfirm, + publishModeHint, + publishedPointsOnBoard, + rankOnlyOf, +} from './entryPublish' import { computeEventScore, type ScorableRow } from '../api/scoring' import EntryPicker from '../components/baseData/EntryPicker.vue' import { entryRowLabel } from '../components/baseData/entrySelection' @@ -168,6 +179,13 @@ interface EditableRow { isTie: boolean note: string resultState: ResultState | null + /** + * 服务端给出的这一条成绩在榜上的分(= 该成绩的流水合计;草稿与没结算过的都是 null)。 + * + * 录入页只拿它判断「本组在榜上是不是已计分的成绩」(见 boardModeNote): + * 名次与分对不上时(名次变了、分还是旧的)必须让操作员看得见。 + */ + points: number | null original: string } @@ -220,6 +238,13 @@ const publishing = ref(false) const unpublishing = ref(false) const publishDialog = ref(false) const unpublishDialog = ref(false) +/** + * 「以决赛成绩发布」勾选状态,**默认不勾**(不勾 = 本次只更新名次)。 + * + * 每次发布成功后就复位成不勾:勾选是一次发布的口径,不是这个页面的设置。留着勾选状态, + * 换到别的小项再点发布就会把一批预赛成绩当成决赛算分——那是这个功能最贵的错法。 + */ +const finalPublish = ref(false) /** 分批结算状态:延迟结算开启时,当前组可能「已发布但还没结算」。 */ const settlement = ref(null) const settling = ref(false) @@ -535,6 +560,20 @@ const allPublished = computed( ) const publishedCount = computed(() => visibleRows.value.filter((row) => row.resultState === 'published').length) +/** 整个组别已发布的条数(在榜上的口径是「这一组」的事,不随批次筛选变小)。 */ +const categoryPublishedCount = computed( + () => categoryRows.value.filter((row) => row.resultState === 'published').length, +) +/** + * 本组在榜上的分(已发布成绩的流水分合计)。 + * + * 按**整个组别**(categoryRows)数,而不是当前批次筛选后的可见行:筛了某个批次去看时, + * 在榜上的口径不该跟着变小(否则会出现「本组只有名次」这种假话)。 + */ +const pointsOnBoard = computed(() => publishedPointsOnBoard(categoryRows.value)) +/** 本组在榜上的口径(预赛 / 决赛)那句话;一条都没发布过时为 null。 */ +const boardNote = computed(() => boardModeNote(categoryPublishedCount.value, pointsOnBoard.value)) + /** * 当前 (小项, 组别) 的未结算状态:非 null 才提示。 * @@ -844,12 +883,22 @@ const publishMessage = computed(() => { const scope = heatScoped.value ? '注意:发布按「小项 + 组别」整体生效,其它批次已保存的草稿也会一起发布。' : '' - // 延迟结算下「发布即重算积分榜」不再成立,确认框不能再说反话。 - const tail = - settlement.value?.liveOnPublish === false + // 「影响几个单位、最大分差多少」说的是一次计分发布的效果:勾上时照旧报这两个数, + // 不勾时报出来就是假话(这次根本不计分),换成口径说明(见 publishModeConfirm)。 + const scored = finalPublish.value + ? `本次影响 ${affectedTeams.value} 个单位,最大分差变化 ${formatPoints(maxDelta.value)} 分。` + : '' + const mode = publishModeConfirm(finalPublish.value, pointsOnBoard.value) + // 延迟结算下「发布即重算积分榜」不再成立;只更新名次时这句同样要说对。 + const deferred = settlement.value?.liveOnPublish === false + const tail = finalPublish.value + ? deferred ? '发布后成绩立即上屏;延迟结算已开启,名次与榜单等该组全部录完后再更新。' : '发布后立即上屏并重算积分榜。' - return `${head}${scope}本次影响 ${affectedTeams.value} 个单位,最大分差变化 ${formatPoints(maxDelta.value)} 分。${tail}` + : deferred + ? '发布后成绩立即上屏;延迟结算已开启,名次等该组全部录完后再更新,榜单分数不变。' + : '发布后成绩立即上屏;本次不改动榜单。' + return `${head}${scope}${scored}${mode}${tail}` }) /** 团体 / 接力行没有 athleteName(服务端 omitempty 不下发),回落到队员名单首人 / 单位。 */ @@ -933,6 +982,8 @@ function toRow(entry: EntryWithResult): EditableRow { isTie: result?.isTie ?? false, note: result?.note ?? '', resultState: result?.state ?? null, + // 老服务端不下发 points(见 attachResultPoints)时按 null = 榜上没分。 + points: result?.points ?? null, original: '', } row.original = snapshotOf(entry) @@ -1714,6 +1765,8 @@ async function confirmPublish(): Promise { publishing.value = true const published = pendingRows.value.length const missing = unrecordedRows.value.length + // 这一下的口径在按下确认时就定下来:请求字段与提示都用它,避免中途被复位带偏。 + const scoring = finalPublish.value try { // 发布作用于服务端已保存的草稿,所以必须先整组落草稿再发布(07 第 3 节)。 // 只提交未发布的行,已发布行由服务端锁定。 @@ -1725,14 +1778,24 @@ async function confirmPublish(): Promise { await adminApi.publish({ eventId: selectedEventId.value, categoryId: selectedCategoryId.value ?? undefined, + rankOnly: rankOnlyOf(finalPublish.value), }) publishDialog.value = false + // 勾选是一次发布的口径,不是页面设置:发完就复位成「只更新名次」, + // 免得换个小项再点发布时把预赛成绩当成决赛算分。 + finalPublish.value = false await reloadAfterSettlementChange() // 延迟结算下这次发布可能只落成绩:提示要如实说清楚,别让现场以为榜单已经跟着变了。 const deferred = settlement.value?.liveOnPublish === false const leftover = missing > 0 ? `本次发布 ${published} 人;其余 ${missing} 人保持未发布,补录后再次发布即可` : null - if (deferred && pendingSettlement.value !== null) { + if (!scoring) { + // 只更新名次:这句话是现场唯一的回执,不能套用「积分榜已更新」。 + toast.success( + '已发布本批成绩(只更新名次)', + `名次已更新;本次不计分、不发奖牌,榜单分数不变${leftover === null ? '' : `。${leftover}`}`, + ) + } else if (deferred && pendingSettlement.value !== null) { toast.success('已发布本批成绩', `延迟结算:名次与榜单等该组全部录完后再更新${leftover === null ? '' : `;${leftover}`}`) } else { toast.success('发布成功,积分榜已更新', leftover) @@ -2323,6 +2386,28 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i