feat(publish): 「以决赛成绩发布」开关 —— 非决赛发布只更新名次、不计分;版本 dev_0.5.8
## 需求(主人原话)
> 只有在最后一轮(决赛)的成绩才算分,你给发布按钮边上带一个选项,勾选之后**当前小项/组以决赛成绩发布**,
> 不然的话就**只更新名次(非决赛) 不加分**。
**设计好处**:不引入「轮次」模型,而是**在已有的「发布」动作上加一个口径** —— 分轮次比赛时,同一批人各轮成绩各发各的,
榜单只认决赛那一轮的分。引擎本来就有一条「只排名不计分」的通路,这次是**把它在发布入口露出来**,没有另造算分分支。
## 契约
`POST /api/results/publish` 请求体新增 **`rankOnly: boolean`**:
- **缺省 / `false`** = 计分发布,**与改动前逐字节一致**(老客户端不带这个字段走现状路径);
- **`true`** = 本次只更新名次。
**只更新名次时写**:`results.state/verified_by/verified_at`(照旧)、`results.rank`(预赛排名次是刚需)、
整小项录完时的 `events.status`、`result_revisions`(message 写明本次口径)、`change_log(entity=result)`(大屏据此重拉名次)。
**不写**:`score_ledger`(**既不加,也不作废任何已有流水**)、`channel_ranks`(不重建也不删除)、榜单快照版本。
⇒ 于是会出现预期的「**名次变了、分还是旧的**」(本组上次按决赛发布过时)—— 确认框与录入页常驻提示都点名说了这件事。
## 界面
- 录入页「发布」按钮**左边**:勾选框「**以决赛成绩发布(勾选才计分发牌)**」,**默认不勾**;
旁边徽标随状态切「**只更新名次(不计分)**」(警示色)/「**计分发布(算分发牌)**」(绿色)。
- 确认框:标题切「确认按决赛成绩发布该组?」/「确认发布该组(只更新名次)?」;不勾时不再报「影响 N 个单位、最大分差 X 分」;
已有在榜分时加一句「本组已有 X 分在榜…那份积分与奖牌会原样保留、不随名次变动」。
- **常驻「在榜口径」提示**:系统不记「上次勾了什么」,判据用**榜面事实**(已发布成绩的流水合计)——
「本组在榜上只有名次(上次是按只更新名次发布的)」/「本组在榜上是已计分的成绩(已有 X 分来自上一次按决赛发布)」。
- **发布成功后勾选复位**:勾选是「这一次发布」的口径,不是页面设置(免得换小项时把预赛当决赛算分)。
- ⚠️ **审核页是第二个发布入口**,同样加了口径 —— 否则从审核页发布预赛会被**悄悄算成分**。
## 兼容性(回答「能不能兼容现有数据包」)
- **没有任何数据库变更、没有迁移**:纯行为改动,多带一个请求字段。
- 旧客户端 / 旧数据包 / 旧库照旧工作;不带该字段时与改动前**逐字节一致**(用改动前采集的整份写库 golden 对拍证明)。
- 反向(用新版导出的项目包去旧版本导入)仍是不支持的,与之前几版一致。
## 验证
- **先红后绿**:改动前跑新用例,红在「只更新名次的发布动了已有流水」(贴出 `score_ledger` 前后对比);
- 另有 `rank_only_heal_test.go` 钉住「只更新名次发布过的库,**启动自愈不许把它算成分**」;
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **944 例** 0 fail / Console 68 例 0 fail。
## 文档
`docs/升级说明.md` 新增 `dev_0.5.8` 一节。
## 版本
`dev_0.5.7` → **`dev_0.5.8`**(无数据迁移);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
This commit is contained in:
1 parent
d11cab31db
commit
c5ce1878fc
18 files changed
+1039
-36
No files matched your search
Binary file not shown.
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 309 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
@@ -1 +1 @@
|
|||||||
0.5.7
|
0.5.8
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scoreboard-console",
|
"name": "scoreboard-console",
|
||||||
"productName": "Scoreboard Console",
|
"productName": "Scoreboard Console",
|
||||||
"version": "0.5.7",
|
"version": "0.5.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": "AskaEth",
|
"author": "AskaEth",
|
||||||
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
|
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
|
||||||
|
|||||||
+40
-1
@@ -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
|
||||||
|
|
||||||
### 一句话
|
### 一句话
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ const rankEpsilon = 1e-9
|
|||||||
type ScoreOptions struct {
|
type ScoreOptions struct {
|
||||||
// IncludeDrafts 为 true 时草稿成绩也参与计分;默认只算已发布(草稿不上屏也不计分)。
|
// IncludeDrafts 为 true 时草稿成绩也参与计分;默认只算已发布(草稿不上屏也不计分)。
|
||||||
IncludeDrafts bool
|
IncludeDrafts bool
|
||||||
|
// RankOnly 为 true 时本次只产出名次、不产出任何流水分值(发布侧的「只更新名次」)。
|
||||||
|
//
|
||||||
|
// 与「小项没有计分通道」那条退化路径同一个出口:名次照算(预赛排名次是刚需),
|
||||||
|
// 但每条名次的 Points 归零、Ledger 为空、破纪录加分也不产生。
|
||||||
|
// 区别只在于通道**仍然逐条照原样参与排名**——退化路径会把小项压成一条合成通道,
|
||||||
|
// 那会改掉名次口径与主体,不是这里想要的。
|
||||||
|
RankOnly bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventScoreInput 是一次小项(某组别)计分的全部输入,引擎只读不改。
|
// EventScoreInput 是一次小项(某组别)计分的全部输入,引擎只读不改。
|
||||||
@@ -91,12 +98,13 @@ func ScoreEvent(in EventScoreInput) (EventScore, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var out EventScore
|
var out EventScore
|
||||||
rankOnly := len(in.Channels) == 0
|
noChannels := len(in.Channels) == 0
|
||||||
// 只排名不计分:scored=false 或没有计分通道时都不产出任何流水(含破纪录加分)。
|
// 只排名不计分:scored=false、没有计分通道、或本次声明「只更新名次」时都不产出任何流水
|
||||||
scoreEnabled := in.Event.Scored && !rankOnly
|
// (含破纪录加分)。
|
||||||
|
scoreEnabled := in.Event.Scored && !noChannels && !in.Options.RankOnly
|
||||||
|
|
||||||
channels := sortedChannels(in.Channels)
|
channels := sortedChannels(in.Channels)
|
||||||
if rankOnly {
|
if noChannels {
|
||||||
channels = []ScoreChannel{rankOnlyChannel(in)}
|
channels = []ScoreChannel{rankOnlyChannel(in)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
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 {
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ type publishPayload struct {
|
|||||||
RequestID string `json:"requestId"`
|
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 {
|
type voidResultPayload struct {
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
RequestID string `json:"requestId"`
|
RequestID string `json:"requestId"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,14 +74,20 @@ func HealChannelRanksOnce(ctx context.Context, opts Options) (HealResult, error)
|
|||||||
|
|
||||||
// channelRankHealNeeded 读出自愈的两个判据,返回「有已发布名次的小项数」与是否需要触发。
|
// channelRankHealNeeded 读出自愈的两个判据,返回「有已发布名次的小项数」与是否需要触发。
|
||||||
// 两条读数走同一次查询,避免两次查询之间库被改动导致判据互不自洽。
|
// 两条读数走同一次查询,避免两次查询之间库被改动导致判据互不自洽。
|
||||||
|
//
|
||||||
|
// 第三个判据(流水)是「只更新名次」发布加进来的:自愈补的是**老库升级后没回填的名次**,
|
||||||
|
// 那种库一定结算过(发布时会写流水)。而只更新名次的发布不写任何流水,光看前两个判据
|
||||||
|
// 分不出「老库」与「只排名发布过的新库」——现场只要重启一次服务端,自愈的全量重算就会把
|
||||||
|
// 预赛成绩算成分、发出奖牌,正好抵消掉这个功能。流水为空时不动它(名次与奖牌本来也没被结算过)。
|
||||||
func channelRankHealNeeded(ctx context.Context, event *sql.DB) (eventCount int, needed bool, err error) {
|
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,
|
if err := event.QueryRowContext(ctx,
|
||||||
`SELECT (SELECT COUNT(*) FROM channel_ranks),
|
`SELECT (SELECT COUNT(*) FROM channel_ranks),
|
||||||
(SELECT COUNT(DISTINCT event_id) FROM results
|
(SELECT COUNT(DISTINCT event_id) FROM results
|
||||||
WHERE state = 'published' AND rank IS NOT NULL AND rank > 0)`,
|
WHERE state = 'published' AND rank IS NOT NULL AND rank > 0),
|
||||||
).Scan(&rankRows, &publishedRankedEvents); err != nil {
|
(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 0, false, fmt.Errorf("检查通道名次自愈条件: %w", err)
|
||||||
}
|
}
|
||||||
return int(publishedRankedEvents), rankRows == 0 && publishedRankedEvents > 0, nil
|
return int(publishedRankedEvents), rankRows == 0 && publishedRankedEvents > 0 && scoredLedgerRows > 0, nil
|
||||||
}
|
}
|
||||||
@@ -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) {
|
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)
|
ev, err := loadEventQ(ctx, q, eventID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.EventScore{}, err
|
return domain.EventScore{}, err
|
||||||
@@ -403,6 +414,7 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64
|
|||||||
RecordBreaks: breaks,
|
RecordBreaks: breaks,
|
||||||
// 每个组别只拿自己组别的团体名次:名次在组别内有效,串组会算出别人的分。
|
// 每个组别只拿自己组别的团体名次:名次在组别内有效,串组会算出别人的分。
|
||||||
TeamRanks: teamRanks[cat.ID],
|
TeamRanks: teamRanks[cat.ID],
|
||||||
|
Options: opts,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.EventScore{}, mapDomainError(err)
|
return domain.EventScore{}, mapDomainError(err)
|
||||||
@@ -805,6 +817,21 @@ func skipNote(skipped int) string {
|
|||||||
return fmt.Sprintf("跳过 %d 条只填了成绩文本、没有成绩值的草稿(本次未上屏,补上成绩值后重新发布)", skipped)
|
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 读「发布后是否立即结算」:缺键 / 脏值一律按开启处理,保证历史行为。
|
// liveOnPublishQ 读「发布后是否立即结算」:缺键 / 脏值一律按开启处理,保证历史行为。
|
||||||
func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) {
|
func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) {
|
||||||
var raw string
|
var raw string
|
||||||
@@ -815,7 +842,7 @@ func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) {
|
|||||||
return boolDefault(raw, true), nil
|
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 {
|
if in.EventID == 0 {
|
||||||
return nil, badRequest("eventId 必填", nil)
|
return nil, badRequest("eventId 必填", nil)
|
||||||
}
|
}
|
||||||
@@ -883,14 +910,29 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
|
|||||||
// 名次也要等结算才写:名次(results.rank 与 channel_ranks)只在这里落,
|
// 名次也要等结算才写:名次(results.rank 与 channel_ranks)只在这里落,
|
||||||
// 分批写名次会让奖牌列跟着每批发布跳动,而总分来自流水、不写流水就不动,
|
// 分批写名次会让奖牌列跟着每批发布跳动,而总分来自流水、不写流水就不动,
|
||||||
// 同一张榜上「有金牌但 0 分」自相矛盾。所以延迟期间只落成绩,名次一并等结算。
|
// 同一张榜上「有金牌但 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||||
return nil, err
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -917,14 +959,16 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
|
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// 没结算就不推榜单:延迟期间榜单保持上一次结算的状态,大屏不该因为一批成绩而跳动。
|
// 没结算就不推榜单:延迟期间榜单保持上一次结算的状态,大屏不该因为一批成绩而跳动。
|
||||||
if settle {
|
// 只更新名次时榜单同样不推:分数与奖牌一个数字都没变,推一版「没变的榜单」只会让现场
|
||||||
|
// 以为榜单跟着这批成绩动了。成绩本身的变更由上面 recordChanges 推出去,大屏据此重拉名次。
|
||||||
|
if settle && !in.RankOnly {
|
||||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,17 @@ import {
|
|||||||
type HeatFilter,
|
type HeatFilter,
|
||||||
} from './entryHeat'
|
} from './entryHeat'
|
||||||
import { adminApi, sampleDataBlockedNotice } from '../api/client'
|
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 { computeEventScore, type ScorableRow } from '../api/scoring'
|
||||||
import EntryPicker from '../components/baseData/EntryPicker.vue'
|
import EntryPicker from '../components/baseData/EntryPicker.vue'
|
||||||
import { entryRowLabel } from '../components/baseData/entrySelection'
|
import { entryRowLabel } from '../components/baseData/entrySelection'
|
||||||
@@ -168,6 +179,13 @@ interface EditableRow {
|
|||||||
isTie: boolean
|
isTie: boolean
|
||||||
note: string
|
note: string
|
||||||
resultState: ResultState | null
|
resultState: ResultState | null
|
||||||
|
/**
|
||||||
|
* 服务端给出的这一条成绩在榜上的分(= 该成绩的流水合计;草稿与没结算过的都是 null)。
|
||||||
|
*
|
||||||
|
* 录入页只拿它判断「本组在榜上是不是已计分的成绩」(见 boardModeNote):
|
||||||
|
* 名次与分对不上时(名次变了、分还是旧的)必须让操作员看得见。
|
||||||
|
*/
|
||||||
|
points: number | null
|
||||||
original: string
|
original: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +238,13 @@ const publishing = ref(false)
|
|||||||
const unpublishing = ref(false)
|
const unpublishing = ref(false)
|
||||||
const publishDialog = ref(false)
|
const publishDialog = ref(false)
|
||||||
const unpublishDialog = ref(false)
|
const unpublishDialog = ref(false)
|
||||||
|
/**
|
||||||
|
* 「以决赛成绩发布」勾选状态,**默认不勾**(不勾 = 本次只更新名次)。
|
||||||
|
*
|
||||||
|
* 每次发布成功后就复位成不勾:勾选是一次发布的口径,不是这个页面的设置。留着勾选状态,
|
||||||
|
* 换到别的小项再点发布就会把一批预赛成绩当成决赛算分——那是这个功能最贵的错法。
|
||||||
|
*/
|
||||||
|
const finalPublish = ref(false)
|
||||||
/** 分批结算状态:延迟结算开启时,当前组可能「已发布但还没结算」。 */
|
/** 分批结算状态:延迟结算开启时,当前组可能「已发布但还没结算」。 */
|
||||||
const settlement = ref<SettlementStatus | null>(null)
|
const settlement = ref<SettlementStatus | null>(null)
|
||||||
const settling = ref(false)
|
const settling = ref(false)
|
||||||
@@ -535,6 +560,20 @@ const allPublished = computed(
|
|||||||
)
|
)
|
||||||
const publishedCount = computed(() => visibleRows.value.filter((row) => row.resultState === 'published').length)
|
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 才提示。
|
* 当前 (小项, 组别) 的未结算状态:非 null 才提示。
|
||||||
*
|
*
|
||||||
@@ -844,12 +883,22 @@ const publishMessage = computed(() => {
|
|||||||
const scope = heatScoped.value
|
const scope = heatScoped.value
|
||||||
? '注意:发布按「小项 + 组别」整体生效,其它批次已保存的草稿也会一起发布。'
|
? '注意:发布按「小项 + 组别」整体生效,其它批次已保存的草稿也会一起发布。'
|
||||||
: ''
|
: ''
|
||||||
// 延迟结算下「发布即重算积分榜」不再成立,确认框不能再说反话。
|
// 「影响几个单位、最大分差多少」说的是一次计分发布的效果:勾上时照旧报这两个数,
|
||||||
const tail =
|
// 不勾时报出来就是假话(这次根本不计分),换成口径说明(见 publishModeConfirm)。
|
||||||
settlement.value?.liveOnPublish === false
|
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 不下发),回落到队员名单首人 / 单位。 */
|
/** 团体 / 接力行没有 athleteName(服务端 omitempty 不下发),回落到队员名单首人 / 单位。 */
|
||||||
@@ -933,6 +982,8 @@ function toRow(entry: EntryWithResult): EditableRow {
|
|||||||
isTie: result?.isTie ?? false,
|
isTie: result?.isTie ?? false,
|
||||||
note: result?.note ?? '',
|
note: result?.note ?? '',
|
||||||
resultState: result?.state ?? null,
|
resultState: result?.state ?? null,
|
||||||
|
// 老服务端不下发 points(见 attachResultPoints)时按 null = 榜上没分。
|
||||||
|
points: result?.points ?? null,
|
||||||
original: '',
|
original: '',
|
||||||
}
|
}
|
||||||
row.original = snapshotOf(entry)
|
row.original = snapshotOf(entry)
|
||||||
@@ -1714,6 +1765,8 @@ async function confirmPublish(): Promise<void> {
|
|||||||
publishing.value = true
|
publishing.value = true
|
||||||
const published = pendingRows.value.length
|
const published = pendingRows.value.length
|
||||||
const missing = unrecordedRows.value.length
|
const missing = unrecordedRows.value.length
|
||||||
|
// 这一下的口径在按下确认时就定下来:请求字段与提示都用它,避免中途被复位带偏。
|
||||||
|
const scoring = finalPublish.value
|
||||||
try {
|
try {
|
||||||
// 发布作用于服务端已保存的草稿,所以必须先整组落草稿再发布(07 第 3 节)。
|
// 发布作用于服务端已保存的草稿,所以必须先整组落草稿再发布(07 第 3 节)。
|
||||||
// 只提交未发布的行,已发布行由服务端锁定。
|
// 只提交未发布的行,已发布行由服务端锁定。
|
||||||
@@ -1725,14 +1778,24 @@ async function confirmPublish(): Promise<void> {
|
|||||||
await adminApi.publish({
|
await adminApi.publish({
|
||||||
eventId: selectedEventId.value,
|
eventId: selectedEventId.value,
|
||||||
categoryId: selectedCategoryId.value ?? undefined,
|
categoryId: selectedCategoryId.value ?? undefined,
|
||||||
|
rankOnly: rankOnlyOf(finalPublish.value),
|
||||||
})
|
})
|
||||||
publishDialog.value = false
|
publishDialog.value = false
|
||||||
|
// 勾选是一次发布的口径,不是页面设置:发完就复位成「只更新名次」,
|
||||||
|
// 免得换个小项再点发布时把预赛成绩当成决赛算分。
|
||||||
|
finalPublish.value = false
|
||||||
await reloadAfterSettlementChange()
|
await reloadAfterSettlementChange()
|
||||||
// 延迟结算下这次发布可能只落成绩:提示要如实说清楚,别让现场以为榜单已经跟着变了。
|
// 延迟结算下这次发布可能只落成绩:提示要如实说清楚,别让现场以为榜单已经跟着变了。
|
||||||
const deferred = settlement.value?.liveOnPublish === false
|
const deferred = settlement.value?.liveOnPublish === false
|
||||||
const leftover =
|
const leftover =
|
||||||
missing > 0 ? `本次发布 ${published} 人;其余 ${missing} 人保持未发布,补录后再次发布即可` : null
|
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}`}`)
|
toast.success('已发布本批成绩', `延迟结算:名次与榜单等该组全部录完后再更新${leftover === null ? '' : `;${leftover}`}`)
|
||||||
} else {
|
} else {
|
||||||
toast.success('发布成功,积分榜已更新', leftover)
|
toast.success('发布成功,积分榜已更新', leftover)
|
||||||
@@ -2323,6 +2386,28 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
|||||||
<PageHeader title="成绩录入" :subtitle="selectedEvent?.name ?? '选择左侧项目开始录入'">
|
<PageHeader title="成绩录入" :subtitle="selectedEvent?.name ?? '选择左侧项目开始录入'">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<span v-if="dirty" class="mx-badge mx-badge-warning">有未保存修改</span>
|
<span v-if="dirty" class="mx-badge mx-badge-warning">有未保存修改</span>
|
||||||
|
<!--
|
||||||
|
发布口径的勾选与标记:只影响「发布」这一个动作。
|
||||||
|
默认不勾 = 本次只更新名次(预赛也要排名次,但不计分、不发奖牌、不动榜单);
|
||||||
|
勾上 = 与改动前一样,按决赛成绩算分、发牌、上榜。
|
||||||
|
勾选状态先于发布按钮出现,让操作员在按下去之前就看清楚这一下会发生什么。
|
||||||
|
-->
|
||||||
|
<label class="publish-final-toggle" :title="FINAL_PUBLISH_TITLE">
|
||||||
|
<input
|
||||||
|
v-model="finalPublish"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="!canWrite || data.isMock || publishing"
|
||||||
|
:title="FINAL_PUBLISH_TITLE"
|
||||||
|
/>
|
||||||
|
<span>{{ FINAL_PUBLISH_LABEL }}</span>
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
class="mx-badge publish-mode-badge"
|
||||||
|
:class="finalPublish ? 'mx-badge-success' : 'mx-badge-warning'"
|
||||||
|
:title="publishModeHint(finalPublish)"
|
||||||
|
>
|
||||||
|
{{ publishModeBadge(finalPublish) }}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
class="mx-btn mx-btn-secondary"
|
class="mx-btn mx-btn-secondary"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2336,7 +2421,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
|||||||
class="mx-btn mx-btn-primary"
|
class="mx-btn mx-btn-primary"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="!canWrite || publishing || data.isMock || pendingRows.length === 0"
|
:disabled="!canWrite || publishing || data.isMock || pendingRows.length === 0"
|
||||||
:title="pendingRows.length === 0 ? '没有可发布的成绩:未录行会被自动跳过' : ''"
|
:title="pendingRows.length === 0 ? '没有可发布的成绩:未录行会被自动跳过' : publishModeHint(finalPublish)"
|
||||||
@click="requestPublish"
|
@click="requestPublish"
|
||||||
>
|
>
|
||||||
<MxIcon name="push" :size="15" /> 发布<span v-if="hasPublished && pendingRows.length > 0">本批</span>
|
<MxIcon name="push" :size="15" /> 发布<span v-if="hasPublished && pendingRows.length > 0">本批</span>
|
||||||
@@ -2621,10 +2706,24 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
|||||||
<div v-if="hasPublished" class="published-note">
|
<div v-if="hasPublished" class="published-note">
|
||||||
<MxIcon name="lock" :size="15" />
|
<MxIcon name="lock" :size="15" />
|
||||||
{{ scopeWord }}已有 {{ publishedCount }} 条成绩发布并锁定上屏(共 {{ visibleRows.length }} 条):未发布的行可继续补录,
|
{{ scopeWord }}已有 {{ publishedCount }} 条成绩发布并锁定上屏(共 {{ visibleRows.length }} 条):未发布的行可继续补录,
|
||||||
录完再点一次「发布本批」即可,名次与积分会按已发布成绩整体重算。
|
录完再点一次「发布本批」即可;勾选「以决赛成绩发布」时名次与积分按已发布成绩整体重算。
|
||||||
要改某一条已发布的成绩,直接点那一格(或右侧「修改本条」)退回草稿,改完再重新发布本组。
|
要改某一条已发布的成绩,直接点那一格(或右侧「修改本条」)退回草稿,改完再重新发布本组。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
本组在榜上的口径(预赛还是决赛):只排名 / 已计分一眼看出来。
|
||||||
|
判据是「已发布成绩有没有拿到分」,也就是榜面上的事实——系统不记「上次勾了什么」,
|
||||||
|
而榜单本来就只认流水。它同时点破了只更新名次发布后的「名次变了、分还是旧的」。
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
v-if="boardNote !== null"
|
||||||
|
class="board-mode-note"
|
||||||
|
:class="pointsOnBoard > 0 ? 'is-scored' : 'is-rank-only'"
|
||||||
|
>
|
||||||
|
<MxIcon name="info" :size="15" />
|
||||||
|
<span>{{ boardNote }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="entry-body">
|
<div class="entry-body">
|
||||||
<div class="mx-card table-card">
|
<div class="mx-card table-card">
|
||||||
<div ref="tableRef" class="mx-table-wrap">
|
<div ref="tableRef" class="mx-table-wrap">
|
||||||
@@ -2917,6 +3016,13 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
|||||||
<span>{{ scoreConfigError.message }}</span>
|
<span>{{ scoreConfigError.message }}</span>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<!--
|
||||||
|
预览永远按「计分发布」算分:不勾「以决赛成绩发布」时这些分差这次不会发生,
|
||||||
|
卡片里必须说破,否则操作员会以为按了发布就涨了这些分。
|
||||||
|
-->
|
||||||
|
<p v-if="!finalPublish" class="preview-hint preview-rank-only-hint">
|
||||||
|
{{ PREVIEW_RANK_ONLY_HINT }}
|
||||||
|
</p>
|
||||||
<p class="preview-hint">
|
<p class="preview-hint">
|
||||||
若现在发布,各单位积分变化(最大分差 <strong>{{ formatPoints(maxDelta) }}</strong> 分)
|
若现在发布,各单位积分变化(最大分差 <strong>{{ formatPoints(maxDelta) }}</strong> 分)
|
||||||
</p>
|
</p>
|
||||||
@@ -3064,7 +3170,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="publishDialog"
|
:open="publishDialog"
|
||||||
tone="warning"
|
tone="warning"
|
||||||
title="确认发布该组成绩?"
|
:title="finalPublish ? '确认按决赛成绩发布该组?' : '确认发布该组(只更新名次)?'"
|
||||||
:message="publishMessage"
|
:message="publishMessage"
|
||||||
:details="warnings.map((issue) => issue.message)"
|
:details="warnings.map((issue) => issue.message)"
|
||||||
confirm-text="确认发布"
|
confirm-text="确认发布"
|
||||||
@@ -3682,6 +3788,46 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
|||||||
color: var(--mx-green);
|
color: var(--mx-green);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
/* 发布口径勾选:贴着发布按钮,未勾(只更新名次)用警示色,勾上再用主色。 */
|
||||||
|
.publish-final-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mx-t2);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.publish-final-toggle input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.publish-final-toggle input:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.publish-mode-badge {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
本组在榜上的口径(预赛 / 决赛):只排名用中性色(常态),已计分用绿色(有价值的信息),
|
||||||
|
都可以换行——这句话里带着「已有多少分、不会被改动」这类关键数字,截断就白写了。
|
||||||
|
*/
|
||||||
|
.board-mode-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.board-mode-note.is-rank-only {
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
color: var(--mx-t2);
|
||||||
|
}
|
||||||
|
.board-mode-note.is-scored {
|
||||||
|
background: rgba(52, 211, 153, 0.08);
|
||||||
|
color: var(--mx-green);
|
||||||
|
}
|
||||||
.entry-body {
|
.entry-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 300px;
|
grid-template-columns: minmax(0, 1fr) 300px;
|
||||||
@@ -4048,6 +4194,10 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
|||||||
.preview-rank-dirty {
|
.preview-rank-dirty {
|
||||||
color: var(--mx-amber);
|
color: var(--mx-amber);
|
||||||
}
|
}
|
||||||
|
/* 只更新名次时预览里的分差这次不会发生,用警示色标出来。 */
|
||||||
|
.preview-rank-only-hint {
|
||||||
|
color: var(--mx-amber);
|
||||||
|
}
|
||||||
.preview-card {
|
.preview-card {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ import { adminApi } from '../api/client'
|
|||||||
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
|
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
|
||||||
import { liveOnPublishOf } from '../components/system/systemApi'
|
import { liveOnPublishOf } from '../components/system/systemApi'
|
||||||
import { judgeEntryRow, type EntryRowJudgement } from './entryGuards'
|
import { judgeEntryRow, type EntryRowJudgement } from './entryGuards'
|
||||||
|
import {
|
||||||
|
FINAL_PUBLISH_LABEL,
|
||||||
|
FINAL_PUBLISH_TITLE,
|
||||||
|
publishModeBadge,
|
||||||
|
publishModeConfirm,
|
||||||
|
publishedPointsOnBoard,
|
||||||
|
rankOnlyOf,
|
||||||
|
} from './entryPublish'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useEventStore } from '../stores/event'
|
import { useEventStore } from '../stores/event'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
@@ -63,6 +71,14 @@ interface DraftGroup {
|
|||||||
deltas: TeamDelta[]
|
deltas: TeamDelta[]
|
||||||
affectedTeams: number
|
affectedTeams: number
|
||||||
maxDelta: number
|
maxDelta: number
|
||||||
|
/**
|
||||||
|
* 本组**已经发布**的成绩在榜上的分(服务端给出的流水合计),不是本地算出来的预览值。
|
||||||
|
*
|
||||||
|
* 发布确认框靠它把「只更新名次」的后果说清楚:本组若已按决赛发布过,那份分与奖牌不会被
|
||||||
|
* 这次发布改动(名次会变、分还是旧的)。本地引擎算不出这件事——上一次可能只更新了名次,
|
||||||
|
* 那种成绩在本地照样算得出分,但榜上其实一分没有。
|
||||||
|
*/
|
||||||
|
pointsOnBoard: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TeamDelta {
|
interface TeamDelta {
|
||||||
@@ -85,6 +101,14 @@ const compareMode = ref<'group' | 'row'>('group')
|
|||||||
const publishTarget = ref<DraftGroup | null>(null)
|
const publishTarget = ref<DraftGroup | null>(null)
|
||||||
const publishing = ref(false)
|
const publishing = ref(false)
|
||||||
const returnTarget = ref<DraftGroup | null>(null)
|
const returnTarget = ref<DraftGroup | null>(null)
|
||||||
|
/**
|
||||||
|
* 「以决赛成绩发布」勾选状态,**默认不勾**(不勾 = 本次只更新名次)。
|
||||||
|
*
|
||||||
|
* 与录入页同一口径、同一个模块:两个发布入口都能表达「这一下是预赛还是决赛」,
|
||||||
|
* 否则从审核页发布就成了绕开这条规则的暗门(预赛成绩会被悄悄算分)。
|
||||||
|
* 发布成功后复位成不勾——勾选是一次发布的口径,不是页面设置。
|
||||||
|
*/
|
||||||
|
const finalPublish = ref(false)
|
||||||
/** 退回没有服务端接口(05 §7 未定义),只能本地标记,界面必须说清楚这一点。 */
|
/** 退回没有服务端接口(05 §7 未定义),只能本地标记,界面必须说清楚这一点。 */
|
||||||
const returnedKeys = ref<string[]>([])
|
const returnedKeys = ref<string[]>([])
|
||||||
|
|
||||||
@@ -320,6 +344,10 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
|
|||||||
deltas,
|
deltas,
|
||||||
affectedTeams: deltas.length,
|
affectedTeams: deltas.length,
|
||||||
maxDelta: deltas.reduce((max, item) => Math.max(max, Math.abs(item.delta)), 0),
|
maxDelta: deltas.reduce((max, item) => Math.max(max, Math.abs(item.delta)), 0),
|
||||||
|
// 在榜上的分直接取服务端的字段(result.points),与录入页同一口径。
|
||||||
|
pointsOnBoard: publishedPointsOnBoard(
|
||||||
|
scoped.map((entry) => ({ resultState: entry.result?.state ?? null, points: entry.result?.points ?? null })),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,7 +431,7 @@ function requestPublish(group: DraftGroup): void {
|
|||||||
publishTarget.value = group
|
publishTarget.value = group
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 发布确认框正文:延迟结算下不能说「发布即重算积分榜」。 */
|
/** 发布确认框正文:延迟结算下不能说「发布即重算积分榜」;口径与录入页同源。 */
|
||||||
const publishMessage = computed(() => {
|
const publishMessage = computed(() => {
|
||||||
const group = publishTarget.value
|
const group = publishTarget.value
|
||||||
if (group === null) return ''
|
if (group === null) return ''
|
||||||
@@ -411,22 +439,53 @@ const publishMessage = computed(() => {
|
|||||||
skippedCount(group) > 0
|
skippedCount(group) > 0
|
||||||
? `本组还有 ${skippedCount(group)} 条没有成绩,本次将发布已填的 ${publishableCount(group)} 条;其余保持未发布,补录后再发布即可。`
|
? `本组还有 ${skippedCount(group)} 条没有成绩,本次将发布已填的 ${publishableCount(group)} 条;其余保持未发布,补录后再发布即可。`
|
||||||
: `本次将发布该组全部 ${publishableCount(group)} 条。`
|
: `本次将发布该组全部 ${publishableCount(group)} 条。`
|
||||||
const tail = deferredSettlement.value
|
// 「影响几个单位、最大分差多少」是一次计分发布的效果:勾上时才报(不勾时报出来是假话)。
|
||||||
? '发布后成绩立即上屏;延迟结算已开启,名次与榜单等该组全部录完后才更新。'
|
const scored = finalPublish.value
|
||||||
: '发布后立即上屏并重算积分榜。'
|
? `本次影响 ${group.affectedTeams} 个单位,最大分差 ${formatPoints(group.maxDelta)} 分。`
|
||||||
return `${scope}本次影响 ${group.affectedTeams} 个单位,最大分差 ${formatPoints(group.maxDelta)} 分。${tail}`
|
: ''
|
||||||
|
const mode = publishModeConfirm(finalPublish.value, group.pointsOnBoard)
|
||||||
|
const tail = finalPublish.value
|
||||||
|
? deferredSettlement.value
|
||||||
|
? '发布后成绩立即上屏;延迟结算已开启,名次与榜单等该组全部录完后才更新。'
|
||||||
|
: '发布后立即上屏并重算积分榜。'
|
||||||
|
: deferredSettlement.value
|
||||||
|
? '发布后成绩立即上屏;延迟结算已开启,名次等该组全部录完后才更新,榜单分数不变。'
|
||||||
|
: '发布后成绩立即上屏;本次不改动榜单。'
|
||||||
|
return `${scope}${scored}${mode}${tail}`
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 确认框里的分项明细:只更新名次时那些分差不会发生,不列出来(免得被当成会发生的事)。 */
|
||||||
|
const publishImpactDetails = computed(() => {
|
||||||
|
const group = publishTarget.value
|
||||||
|
if (group === null || !finalPublish.value) return []
|
||||||
|
return group.deltas
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((item) => `${item.name}:${item.delta > 0 ? '+' : ''}${formatPoints(item.delta)} 分`)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function confirmPublish(): Promise<void> {
|
async function confirmPublish(): Promise<void> {
|
||||||
const group = publishTarget.value
|
const group = publishTarget.value
|
||||||
if (group === null) return
|
if (group === null) return
|
||||||
publishing.value = true
|
publishing.value = true
|
||||||
|
// 口径在按下确认时定下来:请求字段与提示都用它。
|
||||||
|
const scoring = finalPublish.value
|
||||||
try {
|
try {
|
||||||
await adminApi.publish({ eventId: group.event.id, categoryId: group.category?.id })
|
await adminApi.publish({
|
||||||
|
eventId: group.event.id,
|
||||||
|
categoryId: group.category?.id,
|
||||||
|
rankOnly: rankOnlyOf(finalPublish.value),
|
||||||
|
})
|
||||||
publishTarget.value = null
|
publishTarget.value = null
|
||||||
|
// 勾选不留在页面上:发完复位成「只更新名次」,免得下一组被当成决赛算分。
|
||||||
|
finalPublish.value = false
|
||||||
await load()
|
await load()
|
||||||
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
||||||
if (deferredSettlement.value) {
|
if (!scoring) {
|
||||||
|
toast.success(
|
||||||
|
'已发布整组(只更新名次)',
|
||||||
|
`名次已更新;本次不计分、不发奖牌,榜单分数不变(${group.eventName} · ${group.categoryName})`,
|
||||||
|
)
|
||||||
|
} else if (deferredSettlement.value) {
|
||||||
toast.success('已发布本批成绩', `延迟结算:${group.categoryName} 全组录完后才更新名次与榜单`)
|
toast.success('已发布本批成绩', `延迟结算:${group.categoryName} 全组录完后才更新名次与榜单`)
|
||||||
} else {
|
} else {
|
||||||
toast.success('发布成功,积分榜已更新', `${group.eventName} · ${group.categoryName}`)
|
toast.success('发布成功,积分榜已更新', `${group.eventName} · ${group.categoryName}`)
|
||||||
@@ -576,6 +635,22 @@ watch(
|
|||||||
>
|
>
|
||||||
<MxIcon name="logout" :size="14" /> 退回
|
<MxIcon name="logout" :size="14" /> 退回
|
||||||
</button>
|
</button>
|
||||||
|
<!--
|
||||||
|
发布口径与录入页同源:审核页也是发布入口,默认不勾 = 本次只更新名次。
|
||||||
|
少了它,从这一页发布就成了绕开「只有决赛才算分」的暗门。
|
||||||
|
-->
|
||||||
|
<label class="publish-final-toggle" :title="FINAL_PUBLISH_TITLE">
|
||||||
|
<input
|
||||||
|
v-model="finalPublish"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="!canWrite || data.isMock || publishing"
|
||||||
|
:title="FINAL_PUBLISH_TITLE"
|
||||||
|
/>
|
||||||
|
<span>{{ FINAL_PUBLISH_LABEL }}</span>
|
||||||
|
</label>
|
||||||
|
<span class="mx-badge" :class="finalPublish ? 'mx-badge-success' : 'mx-badge-warning'">
|
||||||
|
{{ publishModeBadge(finalPublish) }}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
class="mx-btn mx-btn-primary"
|
class="mx-btn mx-btn-primary"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -714,9 +789,9 @@ watch(
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="publishTarget !== null"
|
:open="publishTarget !== null"
|
||||||
tone="warning"
|
tone="warning"
|
||||||
title="确认发布该组成绩?"
|
:title="finalPublish ? '确认按决赛成绩发布该组?' : '确认发布该组(只更新名次)?'"
|
||||||
:message="publishMessage"
|
:message="publishMessage"
|
||||||
:details="publishTarget === null ? [] : publishTarget.deltas.slice(0, 8).map((item) => `${item.name}:${item.delta > 0 ? '+' : ''}${formatPoints(item.delta)} 分`)"
|
:details="publishImpactDetails"
|
||||||
confirm-text="确认发布"
|
confirm-text="确认发布"
|
||||||
:pending="publishing"
|
:pending="publishing"
|
||||||
@confirm="confirmPublish"
|
@confirm="confirmPublish"
|
||||||
@@ -825,8 +900,19 @@ watch(
|
|||||||
.head-actions {
|
.head-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
/* 发布口径勾选:与录入页同一套样式,审核页也是发布入口。 */
|
||||||
|
.publish-final-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mx-t2);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
.compare-bar {
|
.compare-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { formatPoints } from '@/shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「以决赛成绩发布」勾选项的口径与文案。
|
||||||
|
*
|
||||||
|
* 现场只有最后一轮(决赛)的成绩算分,所以发布入口多了一个勾选:
|
||||||
|
* 不勾(默认)= 本次只更新名次(预赛也要排名次),不计分、不发奖牌、榜单分数不变;
|
||||||
|
* 勾上 = 与改动前完全一样:算分、发牌、上榜。
|
||||||
|
*
|
||||||
|
* 单独成一个模块(而不是散在页面里),是为了让「勾选状态 → 请求字段 → 各处文案」这条链子
|
||||||
|
* 能脱离组件单测:文案说反了现场就会按错的口径发布,这是本功能最容易出错的地方。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 勾选框文案:一句话说清勾上之后会发生什么。 */
|
||||||
|
export const FINAL_PUBLISH_LABEL = '以决赛成绩发布(勾选才计分发牌)'
|
||||||
|
|
||||||
|
/** 勾选框的悬停说明:两种口径各会发生什么。 */
|
||||||
|
export const FINAL_PUBLISH_TITLE =
|
||||||
|
'勾上 = 本次按决赛成绩发布:重算名次并计分、发奖牌、榜单跟着更新;不勾(默认)= 本次只更新名次,不计分、不发奖牌,榜单分数不变。'
|
||||||
|
|
||||||
|
/** 请求字段:不勾就是「只更新名次」。 */
|
||||||
|
export function rankOnlyOf(finalPublish: boolean): boolean {
|
||||||
|
return !finalPublish
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 勾选框旁的常驻提示:未勾时必须说清「这一下不计分」。 */
|
||||||
|
export function publishModeHint(finalPublish: boolean): string {
|
||||||
|
return finalPublish
|
||||||
|
? '本次按决赛成绩发布:算分、发奖牌、榜单跟着更新。'
|
||||||
|
: '本次只更新名次:不计分、不发奖牌,榜单分数不变。'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 勾选框旁的短标记(徽标文案),两种口径一眼可辨。 */
|
||||||
|
export function publishModeBadge(finalPublish: boolean): string {
|
||||||
|
return finalPublish ? '计分发布(算分发牌)' : '只更新名次(不计分)'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布确认框里的口径那句。
|
||||||
|
*
|
||||||
|
* pointsOnBoard 是本组此刻在榜上的分数(来自已发布成绩)。它不为 0 时说明**本组之前按决赛
|
||||||
|
* 计分发布过**,而只更新名次不会碰那份流水与奖牌 —— 于是会出现「名次变了、分还是旧的」:
|
||||||
|
* 名次跟着新成绩换位,榜单上的分与牌仍按上一次计分发布算。这一句必须点名,否则操作员会以为
|
||||||
|
* 「发了一次,分自然就跟着变了」。
|
||||||
|
*/
|
||||||
|
export function publishModeConfirm(finalPublish: boolean, pointsOnBoard: number): string {
|
||||||
|
if (finalPublish) {
|
||||||
|
return '本次按决赛成绩发布:重算名次并计分发奖牌,榜单立刻跟着变。'
|
||||||
|
}
|
||||||
|
const base = '本次只更新名次:不计分、不发奖牌,榜单分数不变(录入页预览里的分差,是勾选「以决赛成绩发布」时的效果)。'
|
||||||
|
if (pointsOnBoard <= 0) return base
|
||||||
|
return `${base}注意:本组已有 ${formatPoints(pointsOnBoard)} 分在榜(上次按决赛发布),名次会按这次成绩更新,但那份积分与奖牌会原样保留、不随名次变动。`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「实时积分预览」面板在只更新名次时的口径提示(那里的分差这次不会发生)。 */
|
||||||
|
export const PREVIEW_RANK_ONLY_HINT =
|
||||||
|
'当前是「只更新名次」:这次发布会按上面的名次落库,但不计分——下列分差是勾选「以决赛成绩发布」时的效果。'
|
||||||
|
|
||||||
|
/** 只看已发布成绩在榜上的分(草稿还没结算,不算)。 */
|
||||||
|
export function publishedPointsOnBoard(rows: readonly { resultState: string | null; points: number | null }[]): number {
|
||||||
|
let sum = 0
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.resultState !== 'published') continue
|
||||||
|
const points = row.points ?? 0
|
||||||
|
if (points > 0) sum += points
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本组在榜上的口径(预赛 / 决赛),给录入页那句常驻说明用。
|
||||||
|
*
|
||||||
|
* 一条都没发布过时返回 null:没有在榜成绩就没有口径可谈,别多一行噪声。
|
||||||
|
* 判据是「已发布成绩有没有从流水拿到分」,而不是「上次勾没勾」——系统不记「上次勾了什么」,
|
||||||
|
* 而榜单上的分与牌本来就只认流水,这句话描述的正是榜面事实。
|
||||||
|
*/
|
||||||
|
export function boardModeNote(publishedCount: number, pointsOnBoard: number): string | null {
|
||||||
|
if (publishedCount <= 0) return null
|
||||||
|
if (pointsOnBoard <= 0) {
|
||||||
|
return '本组在榜上只有名次(上次是按「只更新名次」发布的,不计分):要算分、发奖牌,请勾选「以决赛成绩发布」再发布一次。'
|
||||||
|
}
|
||||||
|
return `本组在榜上是已计分的成绩(已有 ${formatPoints(pointsOnBoard)} 分来自上一次按决赛发布):不勾「以决赛成绩发布」再发一次只会更新名次,这些积分与奖牌不会被改动。`
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import {
|
||||||
|
FINAL_PUBLISH_LABEL,
|
||||||
|
boardModeNote,
|
||||||
|
publishModeConfirm,
|
||||||
|
publishModeHint,
|
||||||
|
rankOnlyOf,
|
||||||
|
publishedPointsOnBoard,
|
||||||
|
} from '../pages/entryPublish'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「以决赛成绩发布」勾选项的口径(主人的新能力):
|
||||||
|
* 不勾(默认)= 本次发布只更新名次,不计分、不发奖牌;
|
||||||
|
* 勾上 = 与改动前完全一样(算分、发牌、上榜)。
|
||||||
|
*
|
||||||
|
* 两件事必须钉住:勾选状态真的进了请求(rankOnly),以及界面上任一处都不许把
|
||||||
|
* 「只更新名次」说成会算分(确认框 / 常驻提示 / 预览面板)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
|
||||||
|
const MODULE = readFileSync(new URL('../pages/entryPublish.ts', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('默认不勾:勾选状态取反就是请求里的 rankOnly', () => {
|
||||||
|
assert.equal(rankOnlyOf(false), true, '不勾 = 只更新名次')
|
||||||
|
assert.equal(rankOnlyOf(true), false, '勾上 = 计分发布')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('确认框写明本次是计分发布还是只更新名次', () => {
|
||||||
|
const scored = publishModeConfirm(true, 0)
|
||||||
|
assert.match(scored, /按决赛成绩发布/)
|
||||||
|
assert.match(scored, /计分/)
|
||||||
|
assert.match(scored, /奖牌/)
|
||||||
|
|
||||||
|
const rankOnly = publishModeConfirm(false, 0)
|
||||||
|
assert.match(rankOnly, /只更新名次/)
|
||||||
|
assert.match(rankOnly, /不计分/)
|
||||||
|
assert.match(rankOnly, /不发奖牌/)
|
||||||
|
assert.doesNotMatch(rankOnly, /重算积分榜/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('只更新名次时,本组已有在榜积分必须点名说清「不会被改动」', () => {
|
||||||
|
const withPoints = publishModeConfirm(false, 18)
|
||||||
|
assert.match(withPoints, /已有 18 分/)
|
||||||
|
assert.match(withPoints, /保留/)
|
||||||
|
// 没有在榜积分时不说这句(免得制造不存在的顾虑)。
|
||||||
|
assert.doesNotMatch(publishModeConfirm(false, 0), /已有/)
|
||||||
|
// 计分发布时也不说(那份分本来就该被这次发布改写)。
|
||||||
|
assert.doesNotMatch(publishModeConfirm(true, 18), /保留/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('勾选框旁的常驻提示:不勾时明确说「不计分」', () => {
|
||||||
|
assert.match(publishModeHint(false), /只更新名次/)
|
||||||
|
assert.match(publishModeHint(false), /不计分/)
|
||||||
|
assert.match(publishModeHint(true), /算分/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('勾选框文案本身就说清「勾选才计分发牌」', () => {
|
||||||
|
assert.match(FINAL_PUBLISH_LABEL, /决赛/)
|
||||||
|
assert.match(FINAL_PUBLISH_LABEL, /勾选才计分/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('本组在榜上的口径(预赛 / 决赛)能从录入页看出来', () => {
|
||||||
|
// 一条都没发布过:不显示,避免无信息噪声。
|
||||||
|
assert.equal(boardModeNote(0, 0), null)
|
||||||
|
// 发布过但榜上没分 = 只排名(预赛)。
|
||||||
|
assert.match(boardModeNote(3, 0) ?? '', /只更新名次/)
|
||||||
|
assert.doesNotMatch(boardModeNote(3, 0) ?? '', /已计分/)
|
||||||
|
// 榜上有分 = 已经按决赛计分发布过。
|
||||||
|
assert.match(boardModeNote(3, 18) ?? '', /已计分/)
|
||||||
|
assert.match(boardModeNote(3, 18) ?? '', /18 分/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('在榜积分只数已发布的成绩,草稿不算', () => {
|
||||||
|
assert.equal(publishedPointsOnBoard([]), 0)
|
||||||
|
assert.equal(
|
||||||
|
publishedPointsOnBoard([
|
||||||
|
{ resultState: 'published', points: 10 },
|
||||||
|
{ resultState: 'published', points: 8 },
|
||||||
|
{ resultState: 'draft', points: 6 },
|
||||||
|
{ resultState: null, points: null },
|
||||||
|
]),
|
||||||
|
18,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('录入页:勾选框默认不勾,且勾选状态进了发布请求', () => {
|
||||||
|
assert.match(SOURCE, /const finalPublish = ref\(false\)/)
|
||||||
|
assert.match(SOURCE, /v-model="finalPublish"/)
|
||||||
|
assert.match(SOURCE, /rankOnly: rankOnlyOf\(finalPublish\.value\)/)
|
||||||
|
// 发布请求里不能写死 false(写死 = 永远计分发布,勾选框变成摆设)。
|
||||||
|
assert.doesNotMatch(SOURCE, /rankOnly: false/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('录入页:勾选框与醒目提示就贴在发布按钮边上', () => {
|
||||||
|
assert.match(SOURCE, /class="publish-final-toggle"/)
|
||||||
|
assert.match(SOURCE, /publishModeHint\(finalPublish\)/)
|
||||||
|
// 不勾时必须给出可读的「只更新名次」标记(警示色),不能只靠勾选框本身。
|
||||||
|
assert.match(SOURCE, /publishModeBadge/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('录入页:确认框用同一套文案,且把在榜积分传进去', () => {
|
||||||
|
assert.match(SOURCE, /publishModeConfirm\(finalPublish\.value, pointsOnBoard\.value\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('录入页:实时积分预览要说明「不勾时这些分差不会发生」', () => {
|
||||||
|
assert.match(SOURCE, /preview-rank-only-hint/)
|
||||||
|
assert.match(MODULE, /勾选「以决赛成绩发布」/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('发布沿用旧路径:请求体仍带 eventId / categoryId / requestId', () => {
|
||||||
|
const call = /await adminApi\.publish\(\{[\s\S]{0,220}?\}\)/.exec(SOURCE)?.[0] ?? ''
|
||||||
|
assert.match(call, /eventId: selectedEventId\.value/)
|
||||||
|
assert.match(call, /categoryId: selectedCategoryId\.value \?\? undefined/)
|
||||||
|
assert.match(call, /rankOnly: rankOnlyOf\(finalPublish\.value\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核页(成绩审核 → 「发布整组」)是**第二个发布入口**:它不带上这个口径,
|
||||||
|
* 就成了绕开「只有决赛才算分」的暗门(从这一页发布预赛会被悄悄算分)。
|
||||||
|
*/
|
||||||
|
const REVIEW_SOURCE = readFileSync(new URL('../pages/ReviewPage.vue', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('审核页:同样是默认不勾,勾选状态进发布请求', () => {
|
||||||
|
assert.match(REVIEW_SOURCE, /const finalPublish = ref\(false\)/)
|
||||||
|
assert.match(REVIEW_SOURCE, /v-model="finalPublish"/)
|
||||||
|
assert.match(REVIEW_SOURCE, /rankOnly: rankOnlyOf\(finalPublish\.value\)/)
|
||||||
|
assert.match(REVIEW_SOURCE, /finalPublish\.value = false/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('审核页:确认框同样写明口径,且不列不会发生的分差', () => {
|
||||||
|
assert.match(REVIEW_SOURCE, /publishModeConfirm\(finalPublish\.value, group\.pointsOnBoard\)/)
|
||||||
|
assert.match(REVIEW_SOURCE, /finalPublish \? '确认按决赛成绩发布该组?' : '确认发布该组(只更新名次)?'/)
|
||||||
|
assert.match(REVIEW_SOURCE, /:details="publishImpactDetails"/)
|
||||||
|
// 在榜上的分必须取服务端字段,不能用本地预览算出来的分。
|
||||||
|
assert.match(REVIEW_SOURCE, /publishedPointsOnBoard\(/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('审核页:只在计分发布时才报「影响多少单位、最大分差」', () => {
|
||||||
|
assert.match(REVIEW_SOURCE, /const scored = finalPublish\.value/)
|
||||||
|
assert.match(REVIEW_SOURCE, /if \(group === null \|\| !finalPublish\.value\) return \[\]/)
|
||||||
|
})
|
||||||
@@ -439,6 +439,13 @@ export interface PublishPayload {
|
|||||||
eventId: number
|
eventId: number
|
||||||
categoryId?: number
|
categoryId?: number
|
||||||
requestId?: string
|
requestId?: string
|
||||||
|
/**
|
||||||
|
* 只更新名次:发布成绩并写回名次,但不计分、不发奖牌、不动榜单分数。
|
||||||
|
*
|
||||||
|
* 缺省 / false = 计分发布(与改动前逐字节一致)。撤销发布 / 立即结算不吃这个字段
|
||||||
|
* (它们的请求体是同一形状,服务端只从发布接口读它)。
|
||||||
|
*/
|
||||||
|
rankOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VoidResultPayload {
|
export interface VoidResultPayload {
|
||||||
|
|||||||
Reference in new issue
Block a user