From 72362239c654d454ef20fab8d39f52518cd754d1 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 17 Sep 2026 01:14:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(entry):=20=E5=9B=9E=E5=88=B0=E5=BD=95?= =?UTF-8?q?=E5=85=A5=E9=A1=B5=E6=97=B6=E8=AE=B0=E4=BD=8F=E4=B8=8A=E6=AC=A1?= =?UTF-8?q?=E7=9A=84=E5=B0=8F=E9=A1=B9=EF=BC=88=E6=8C=89=20id=EF=BC=89?= =?UTF-8?q?=EF=BC=9Bfeat:=20=E6=96=B0=E5=A2=9E=E3=80=8C=E6=B8=85=E7=A9=BA?= =?UTF-8?q?=E6=9C=AC=E7=BB=84=20/=20=E6=9C=AC=E6=89=B9=E6=88=90=E7=BB=A9?= =?UTF-8?q?=E3=80=8D=EF=BC=9B=E7=89=88=E6=9C=AC=20dev=5F0.5.12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 一、修「改小项显示名称后,撤销发布按钮消失」 **根因不是改名字。** 我一开始怀疑「某处按 name 关联」,验下来那条路是干净的: 加载录入页报名行的查询、成绩查询、引擎读数据全是按 event_id / category_id / code 关联的 (并补了一条「小项名 + 组别名一起改」的回归用例,证明录入页状态 / 流水 / 榜单逐字节不变)。 真机制:**进入录入页时不记得上次录的是哪个小项**,兜底挑「第一个**待录入**(pending)的小项」。 而**刚发布完的小项状态已经是 published**,恰好被这条兜底排除 → 回去时右侧换成了**另一个小项**, 而「撤销发布」按钮只按「当前表里有没有已发布的行」显示,自然就不在。 ⇒ 现场看到的「左栏仍显示已发布、单位成绩仍有加分、但按钮没了」**三条本来不矛盾 —— 它们说的是两个不同的小项**。 **修法**:`pickInitialEventId()` 记住上次录入的小项(**按 id** 存 localStorage), 优先级 = URL 查询参数 → 上次录入的(**不排除已发布**)→ 第一个 pending → 列表第一个; 记忆失效(小项被删、隐私模式读不到存储)一律退回原兜底,不把人挡在录入页外。 **纯前端,零数据迁移。** 真机复现与复验(CDP 驱动真实浏览器): - 修前:「离开再回来 → 换成了别的小项、没有撤销发布按钮」; - 修后:「离开再回来 → 仍是那个小项、按钮在」。 ## 二、新增「清空本组 / 本批成绩」(危险操作) `POST /api/results/clear`(recorder 及以上 / CSRF / 幂等): - `heat` **省略 = 清整组**、数字 = 该批次、显式 `null` = 只清未分批的行(三种要能分清); - `confirm` 必须为 `true`(**服务端兜底**「没勾选不让提交」,不只是前端拦); - **不提供「清空整个小项」** —— 爆炸半径太大; - 语义 = **先撤销发布 + 再清内容**:成绩行整行归零 → **清整组时一并清掉「团体名次」** (否则会出现「清空了、榜上还有分」)→ 作废旧流水并按剩余已发布内容重写 → **小项状态对称退回**(只在原为 published 时降级,正好避免「左栏写着已发布却没有已发布成绩」) → 写审计 + 推榜。 界面:工具栏「清空本组」常驻;「清空本批」只在筛了批次时出现(没筛批次时它等于清整组,不重复给); 两粒都是危险色,示例数据模式 / 访客只读时禁用并说明原因。 **与「撤销发布」的区别**(界面上都写清了,很容易混): - **撤销发布**:成绩内容**保留**,只把分收回; - **清空**:成绩内容**一起清掉**,分也收回。 ## 三、顺带:按名字关联的同类扫描 扫了 13 处「按名字关联」的地方并逐条判定,结论: **数据关联一律按 id/code**;只有当「人写的名字要在表里对齐」时才按名字 (导入报名表里的组别名、Excel 工作表名、缺证件号时的同名兜底)—— 那几处是**有意为之**、报错也能照着改,**未改**。 ## 四、验证 - 全量自检两轮**全绿、退出码 0**:服务端 14 包 / 前端 **974 例** 0 fail / Console 96 例 0 fail。 - 新增用例:`event_rename_publish_test.go`(改名后状态/流水/榜单逐字节不变)、 `entry-initial-event.test.ts`(8 例)、`results_clear_test.go`、`entry-clear.test.ts`。 ## 版本 dev_0.5.11 → dev_0.5.12(无数据迁移)。 --- VERSION | 2 +- console/installer/art/1x/brand.bmp | Bin 316854 -> 316854 bytes console/installer/art/2x/brand.bmp | Bin 1267254 -> 1267254 bytes console/installer/art/version.txt | 2 +- console/package.json | 2 +- .../httpapi/event_rename_publish_test.go | 82 +++++ server/internal/httpapi/handlers_results.go | 16 + server/internal/httpapi/results_clear.go | 261 +++++++++++++++ server/internal/httpapi/results_clear_test.go | 306 ++++++++++++++++++ server/internal/httpapi/server.go | 2 + web/src/admin/api/client.ts | 11 + web/src/admin/components/ConfirmDialog.vue | 41 ++- web/src/admin/pages/EntryPage.vue | 191 ++++++++++- web/src/admin/pages/entryClear.ts | 121 +++++++ web/src/admin/pages/entryInitialEvent.ts | 95 ++++++ web/src/admin/tests/entry-clear.test.ts | 156 +++++++++ .../admin/tests/entry-initial-event.test.ts | 126 ++++++++ .../tests/entry-sample-data-guard.test.ts | 10 +- web/src/shared/api.ts | 11 + web/src/shared/types.ts | 31 ++ 20 files changed, 1448 insertions(+), 18 deletions(-) create mode 100644 server/internal/httpapi/event_rename_publish_test.go create mode 100644 server/internal/httpapi/results_clear.go create mode 100644 server/internal/httpapi/results_clear_test.go create mode 100644 web/src/admin/pages/entryClear.ts create mode 100644 web/src/admin/pages/entryInitialEvent.ts create mode 100644 web/src/admin/tests/entry-clear.test.ts create mode 100644 web/src/admin/tests/entry-initial-event.test.ts diff --git a/VERSION b/VERSION index 3be45bd..f0f789c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.5.11 +dev_0.5.12 diff --git a/console/installer/art/1x/brand.bmp b/console/installer/art/1x/brand.bmp index 969de11c180f6a64147b3b52b4d518806fe12d76..d3be242ee3319e7c04491d8ee226016aba097935 100644 GIT binary patch delta 409 zcmdnCT6o)P;SJyOgnTr4fyftvw0Qlr`8G!~3NuZXDO@TQW-Q^S%^zYY3Ph2nk`X2n z5oVG>`a=HNo1+;8nI^v}UM8F3>8-)jmg2FzHLEAfZ*oEC^wOvlM-^`k?m%6^&GC%9 zOq2JNtq=v86R0mdr6{Z}#=%RC3us7yt^m*`F{a7=l}kkfbOnLN02R$Dk0}W-^-y64 zNqbi9XA;yA@K)yzHx{2?n-pg)2Q+tcXR8d;WYhY^B02&-c6+FD1Ohe1 zfXrlSS}N+P#^tNU=cmORYbiUkJSI?2&>!fzo>rjrrIz^;5a~1*jR;doM_I-!ckS7g zu^{hvw(ek(_EP5tI;beXWLimNYqI;in#6n`121(hkPSVpl1!6JyXK4f>j(f%i7=DO z@zjkrmj=2G;whlCQ}0rKptO$$kEbdpkOWDDG0HPd7M-wyEkH*Ah?);iXg@rG5r~<9 Mn0fo*2`ryo0Ps?GmjD0& delta 387 zcmdnCT6o)P;SJyO_IFR8snT>liWR2IRe0@0PX3oTnd$TQ)X|7b!tg;b5~&pNqbi9XA;yE zL>KOCm0_A}TE7@51u+nzBa%^)X);sOQX!yaj&h8(QT7PA&Q_q@rIz^;9;%!`=LF~q z#@i_N6whDwmAf%SAOiL9iNLVV07*e1V2ud{tE5QX? zrCyBj;1 zrI)gOJLD;++@{Vu4wb;B64+!hl=oc6FXhD{<+>h%TKtGsW&cGUc5W{KfvZRBR6V8NJk{6(`n) zc$bo2sQnyv@+VRxD7HLeNyX<@M(wD?e*H|^X;$_d-xSD4U~2T5@ibT~_)L|Q4IPlX zoMM+w0&5)_q0dx=!Cv1P?{6$|R0%~dJ`SL*ih`ulqykDjWqj+dy>T%WRMXVG^5m{s}@eCOWimi z4K!OPq|8JKRS3vSE7VCaHVD&<2`<2k{|I}sGy&ejgbWP?hy@{dL%W#Gt*Jf}{u;$hB7*+whM?0Y6#KkbD45=tT(x!|I znX#{j*WC-fE!d1bWA!N|)k?F62FgMbWj_d4g2TWsfUs8ZoRy+~u4288a*{-=k>V!& v{#*sFshN(}H&H+ikbdU?Tp$S zNsndw>&rkr+TI#G5N$xEOPezm)Fm%zN?+GgxT+&}VSUQt#?-mh31P-!2yNXr8GRW+ zjsj{6)D!a2K3P2;CND+_pm!m`gVojN1QBY`0tVt~pe=dgOzw=* z5OaWJfUY1gm{S~7k;4WQ0J2Oju&4mWW2BiBFv=D+q|UENT-})mjDEO*>y)4d0%Idc zU${EL78nmJ+OoGyDxX;%4Oix;0d*TtnY#)HFw)|zz@)f2m&t`?`$S`)%;Y#GHqHQG($EzIl0XS}mUa_MCLm@8Vi21Z rh}nRc9f&!Am=lP(fS4PId4QM~i1~n+ABY8jSP+PXwwqWAGsyw~5kBYa diff --git a/console/installer/art/version.txt b/console/installer/art/version.txt index 69626fb..9d6c175 100644 --- a/console/installer/art/version.txt +++ b/console/installer/art/version.txt @@ -1 +1 @@ -0.5.11 +0.5.12 diff --git a/console/package.json b/console/package.json index d2b9117..747deaf 100644 --- a/console/package.json +++ b/console/package.json @@ -1,7 +1,7 @@ { "name": "scoreboard-console", "productName": "Scoreboard Console", - "version": "0.5.11", + "version": "0.5.12", "private": true, "author": "AskaEth", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", diff --git a/server/internal/httpapi/event_rename_publish_test.go b/server/internal/httpapi/event_rename_publish_test.go new file mode 100644 index 0000000..61e02ac --- /dev/null +++ b/server/internal/httpapi/event_rename_publish_test.go @@ -0,0 +1,82 @@ +package httpapi_test + +import ( + "fmt" + "net/http" + "reflect" + "testing" +) + +// entryStates 读成绩录入接口,返回「报名项 id → 成绩状态」。 +func entryStates(t *testing.T, env *testEnv, client *http.Client, eventID int64) map[int64]string { + t.Helper() + board := getEntryBoard(t, env, client, eventID) + out := make(map[int64]string, len(board.Entries)) + for _, row := range board.Entries { + entryID, _ := row["id"].(float64) + result, _ := row["result"].(map[string]any) + state, _ := result["state"].(string) + out[int64(entryID)] = state + } + return out +} + +// 改「显示名称」不该影响任何功能:小项 / 组别名改掉之后,录入页读到的成绩状态、 +// 积分流水、榜单必须逐字节不变。 +// +// 这条用例的现场来历:主人把一个小项的显示名称改掉、回录入页,发现「撤销发布」按钮不见了 +// (右侧其实换成了另一个小项——进入录入页的默认选中把它排除了,与改名无关,另有用例守着)。 +// 为了把「改名会不会断链」这件事一次问清,这里把**所有按名字关联**的可能都覆盖上: +// 小项名与组别名都改掉,再对比三份读数。 +func TestRenameEventAndCategoryKeepsEverythingIntact(t *testing.T) { + env, ids, client := recorderWithSeed(t) + publishResults(t, env, client, ids.event, "rename", + map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, + map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"}, + ) + + statesBefore := entryStates(t, env, client, ids.event) + ledgerBefore := ledgerRowsOf(t, env, ids.event, nil) + standingsBefore := rawData(t, env, client, "/api/standings") + if statesBefore[ids.entryA] != "published" || statesBefore[ids.entryB] != "published" { + t.Fatalf("前置:两条成绩都应是 published,得到 %v", statesBefore) + } + + // ① 改小项显示名称(其余字段照原样回写,与基础数据页的整表覆盖语义一致)。 + ev := decodeData[map[string]any](t, func() envelope { + status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d", ids.event), nil, false) + if status != http.StatusOK || !resp.OK { + t.Fatalf("读小项应 200,得到 %d %+v", status, resp.Error) + } + return resp + }()) + ev["name"] = "100米跑(改名后)" + if status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", ids.event), ev, true); status != http.StatusOK { + t.Fatalf("改小项名应 200,得到 %d %+v", status, resp.Error) + } + + // ② 改组别名(就地更新,不删不插)。 + if status, resp := env.do(client, http.MethodPut, + fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category), + map[string]any{"name": "男子甲组", "multiplier": 1, "sortOrder": 0}, true); status != http.StatusOK { + t.Fatalf("改组别名应 200,得到 %d %+v", status, resp.Error) + } + + if got := entryStates(t, env, client, ids.event); got[ids.entryA] != "published" || got[ids.entryB] != "published" { + t.Fatalf("改名后成绩状态不该变:%v", got) + } + if got, want := ledgerRowsOf(t, env, ids.event, nil), ledgerBefore; !reflect.DeepEqual(got, want) { + t.Fatalf("改名后流水不该变:\n%v\n%v", got, want) + } + if got := rawData(t, env, client, "/api/standings"); got != standingsBefore { + t.Fatalf("改名后榜单不该变:\n%s\n%s", got, standingsBefore) + } + // 小项自身的进度状态(左栏「已发布 / 待录入」读的就是它)也不能被改名带偏。 + var status string + if err := env.ev.QueryRow(`SELECT status FROM events WHERE id = ?`, ids.event).Scan(&status); err != nil { + t.Fatalf("读小项状态: %v", err) + } + if status != "published" { + t.Fatalf("改名后小项状态应仍是 published,得到 %q", status) + } +} diff --git a/server/internal/httpapi/handlers_results.go b/server/internal/httpapi/handlers_results.go index 4d285b2..524a555 100644 --- a/server/internal/httpapi/handlers_results.go +++ b/server/internal/httpapi/handlers_results.go @@ -70,6 +70,22 @@ func (s *server) unpublishHandler(w http.ResponseWriter, r *http.Request) (any, }) } +// clearResultsHandler 是「清空当前组 / 批次成绩」(POST /api/results/clear)。 +// +// 与 publish / unpublish 同档权限(recorder 及以上 + CSRF):现场就是同一批人在录成绩。 +// 用 decodeJSONPresence 是为了分清「请求体里没有 heat」(清整个组别)与 +// 「heat 是 null」(只清未分批的行)——两者只差一个键,动作范围差一整组。 +func (s *server) clearResultsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in clearResultsPayload + keys, err := decodeJSONPresence(r, &in) + if err != nil { + return nil, err + } + return nil, s.idempotent(w, r, in.RequestID, func() (any, error) { + return s.clearResults(r.Context(), in, keys.has("heat"), middlewareUserID(r)) + }) +} + // settleResultsHandler 是「立即结算」:延迟结算下的人工兜底,按当前已发布成绩结算一次。 func (s *server) settleResultsHandler(w http.ResponseWriter, r *http.Request) (any, error) { var in publishPayload diff --git a/server/internal/httpapi/results_clear.go b/server/internal/httpapi/results_clear.go new file mode 100644 index 0000000..ee56f9d --- /dev/null +++ b/server/internal/httpapi/results_clear.go @@ -0,0 +1,261 @@ +package httpapi + +import ( + "context" + "database/sql" + "strconv" + "strings" +) + +// 清空「当前组 / 批次」的成绩(POST /api/results/clear)。 +// +// 语义(主人定的):**先撤销发布,再把成绩内容清空**,回到「没录过」的样子—— +// - 已发布的收回(state 退回 draft)、该范围产生的积分流水作废(大屏与榜单跟着回退); +// - 名次 / 成绩值 / 成绩文本 / 单位 / 并列 / 状态 / 备注全部清掉; +// - 整个组别(scope=group)时,连同该组别的「按单位录入的团体名次」一起清掉: +// 它同样是这一组的成绩内容,不清就变成「清空了榜上却还有分」(那批分没有成绩行, +// 重算时会原样再写一遍)。 +// +// 与「撤销发布」的区别必须守住:撤销发布**保留成绩内容**、只把分数收回;清空连内容一起清掉。 +// 所以这是危险操作:必须显式勾选确认(服务端也要求 confirm=true)、全程写审计。 +// +// 批次(heat)只是录入 / 展示分组,不参与算分,所以这里按 heat 过滤的只有「成绩行」: +// 团体名次不分批次,清「本批」时一律不动它。 + +// clearResultsPayload 是 POST /api/results/clear 的请求体。 +// +// heat 用「在不在请求体里」区分三件事,所以必须配 decodeJSONPresence 读: +// 不传 = 整个组别(含未分批的行);传数字 = 只清这一批次;传 null = 只清「未分批」的行。 +// 用 *int64 单看值分不出「不传」与「传 null」——前者是清整组,后者只清未分批,差得很远。 +type clearResultsPayload struct { + publishPayload + Heat *int64 `json:"heat"` + // Confirm 必须为 true:这是「勾选了『我知道这会清掉已发布的分数』」在协议上的落点。 + // 前端禁用确认键只是界面防呆,服务端不能靠它。 + Confirm bool `json:"confirm"` + // Reason 是操作员填的原因(可空);写进流水作废原因与审计,便于事后复盘。 + Reason string `json:"reason"` +} + +// 清空范围的三种取值:与请求体里 heat 的三种写法一一对应。 +const ( + clearScopeGroup = "group" // 整个组别(含未分批的行) + clearScopeBatch = "batch" // 指定批次 + clearScopeUnbatched = "unbatched" // 只清「未分批」的行 +) + +// clearResultsResultDTO 是清空回执:清了多少条、其中多少条原先已发布、作废了多少条流水。 +type clearResultsResultDTO struct { + Scope string `json:"scope"` + Heat *int64 `json:"heat"` + // Cleared 是本次被清空的成绩行数;Published 是其中原先处于已发布状态的条数。 + Cleared int `json:"cleared"` + Published int `json:"published"` + // VoidedLedger 是本次作废的积分流水条数;TeamRanks 是连同清掉的团体手工名次条数。 + VoidedLedger int `json:"voidedLedger"` + TeamRanks int `json:"teamRanks"` +} + +// clearResults 清空指定范围内的成绩。heatPresent 表示请求体里到底有没有 heat 这个键。 +func (s *server) clearResults(ctx context.Context, in clearResultsPayload, heatPresent bool, userID int64) (clearResultsResultDTO, error) { + if in.EventID == 0 { + return clearResultsResultDTO{}, badRequest("eventId 必填", nil) + } + if in.CategoryID == nil || *in.CategoryID <= 0 { + // 故意不提供「清空整个小项」:那条路的爆炸半径是几十条成绩乘上组别数, + // 而界面上每一次操作看到的都是「当前这一组」。要清整项就逐组清。 + return clearResultsResultDTO{}, badRequest("categoryId 必填:清空以「小项 + 组别」为界", nil) + } + if !in.Confirm { + return clearResultsResultDTO{}, badRequest("请先勾选「我知道这会清掉已发布的分数」再提交", nil) + } + scope := clearScopeGroup + if heatPresent { + switch { + case in.Heat == nil: + scope = clearScopeUnbatched + case *in.Heat < 1 || *in.Heat > maxEntryHeat: + return clearResultsResultDTO{}, badRequest("heat 需要是 1-999 的整数,或传 null 只清未分批的行", nil) + default: + scope = clearScopeBatch + } + } + categoryID := *in.CategoryID + + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return clearResultsResultDTO{}, internal(err) + } + defer func() { _ = tx.Rollback() }() + + // 范围里的报名项:整组 = 该组别全部;按批次 = 该组别里 heat 命中的那些。 + entryScope := `SELECT id FROM entries WHERE event_id = ? AND category_id = ?` + args := []any{in.EventID, categoryID} + switch scope { + case clearScopeBatch: + entryScope += ` AND heat = ?` + args = append(args, *in.Heat) + case clearScopeUnbatched: + entryScope += ` AND heat IS NULL` + } + + // 清空前的读数:条数与已发布条数进回执、审计与变更历史, + // 让「清了多少条、其中多少条动过分」有据可查(现场事后只认这句话)。 + var cleared, published int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(*), COALESCE(SUM(state = 'published'), 0) FROM results + WHERE entry_id IN (`+entryScope+`)`, args...).Scan(&cleared, &published); err != nil { + return clearResultsResultDTO{}, internal(err) + } + // 团体名次(只在清整个组别时算数)也是这一组的成绩内容,先数出来: + // 纯团体名次的项目(球类那种)一条 results 行都没有,只有它才是「可清的东西」。 + teamRanks := 0 + if scope == clearScopeGroup { + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM channel_manual_ranks WHERE event_id = ? AND category_id = ?`, + in.EventID, categoryID).Scan(&teamRanks); err != nil { + return clearResultsResultDTO{}, internal(err) + } + } + // 「什么都没有」要明确回答,而不是静默回一句「清空 0 条」——操作员多半看错了批次 / 组别, + // 提示成功会让他以为已经清干净了。判据是这一组此刻还有没有任何成绩痕迹: + // 成绩行、团体名次、以及还没作废的流水(只有它才能证明「榜上还挂着分」)。 + validLedger := s.validLedgerPoints(ctx, tx, in.EventID, categoryID) + if cleared == 0 && teamRanks == 0 && validLedger == 0 { + return clearResultsResultDTO{}, badRequest("该范围没有成绩可清空(这一组或这一批次还没录过)", nil) + } + + // 成绩内容整行归零:回到「没录过」的样子(录入页把「有行但全空」与「没有行」显示成同一件事)。 + // state 必须退回 draft——留在 published 就成了「榜上没分、表格却锁着」的半成品。 + // points_override 一并清掉:它是已停用的历史列,留着只会让这一条之后既发不出去也撤不回来。 + if _, err := tx.ExecContext(ctx, + `UPDATE results + SET state = 'draft', rank = NULL, result_text = NULL, result_value = NULL, unit = NULL, + is_tie = 0, status = 'ok', note = NULL, points_override = NULL, + verified_by = NULL, verified_at = NULL, updated_at = datetime('now') + WHERE entry_id IN (`+entryScope+`)`, args...); err != nil { + return clearResultsResultDTO{}, internal(err) + } + + // 团体名次只在「清整个组别」时动:它按 (小项, 组别, 单位) 存,与批次无关, + // 清一批却把它抹掉会连带清掉别的批次的团体分。 + if scope == clearScopeGroup && teamRanks > 0 { + if _, err := tx.ExecContext(ctx, + `DELETE FROM channel_manual_ranks WHERE event_id = ? AND category_id = ?`, in.EventID, categoryID); err != nil { + return clearResultsResultDTO{}, internal(err) + } + } + + // 作废旧流水并按「剩下的已发布内容」重写:撤销发布走的是同一条路(先算后作废,流水只追加), + // 所以清空之后榜上剩下的分,与「这些成绩从没发布过」逐字节一致。 + voidedBefore := s.voidedLedgerCount(ctx, tx, in.EventID, categoryID) + if _, err := recomputeLedgerTx(ctx, tx, in.EventID, categoryID, userID, clearVoidReason(in.Reason, scope)); err != nil { + return clearResultsResultDTO{}, err + } + voidedAfter := s.voidedLedgerCount(ctx, tx, in.EventID, categoryID) + + // 小项进度与撤销发布对称地退回:这一下把该组已发布的成绩全撤了,小项状态不跟着退, + // 左栏就会写着「已发布」而这一组其实一条已发布成绩都没有——正是现场最容易被误导的那种假象。 + // 与撤销发布同一口径:只在整项此前被标成 published 时降级,pending / ongoing 是人工设的进度,不覆盖。 + if _, err := tx.ExecContext(ctx, + `UPDATE events SET status = 'finished' WHERE id = ? AND status = 'published'`, + in.EventID); err != nil { + return clearResultsResultDTO{}, internal(err) + } + + results, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID) + if err != nil { + return clearResultsResultDTO{}, err + } + // 变更历史:action 只有 publish / unpublish / edit 三种(0008 的 CHECK), + // 清空本质上是「撤销发布 + 清内容」,归到 unpublish 并在 message 里写明清了多少条。 + // 为它新加一个 action 取值要重建整张表,代价与收益不成比例。 + if err := recordRevision(ctx, tx, in.EventID, "unpublish", results, userID, + clearRevisionNote(scope, in.Heat, cleared, published, teamRanks)); err != nil { + return clearResultsResultDTO{}, err + } + if err := recordChanges(ctx, tx, "result", results, "upsert", userID); err != nil { + return clearResultsResultDTO{}, err + } + if err := s.bumpStandings(ctx, tx, userID); err != nil { + return clearResultsResultDTO{}, err + } + if err := tx.Commit(); err != nil { + return clearResultsResultDTO{}, internal(err) + } + + out := clearResultsResultDTO{ + Scope: scope, Heat: in.Heat, Cleared: cleared, Published: published, + VoidedLedger: voidedAfter - voidedBefore, TeamRanks: teamRanks, + } + // 审计:谁在什么时候清空了哪一组 / 哪一批、清掉多少条、作废多少条流水。 + // 这几项都要有——「变更历史里能查到谁在什么时候清空了哪一组」是主人点名的要求。 + s.audit(ctx, "result.clear", "event", in.EventID, nil, map[string]any{ + "eventId": in.EventID, "categoryId": categoryID, "scope": scope, "heat": in.Heat, + "cleared": cleared, "published": published, "voidedLedger": out.VoidedLedger, + "teamRanks": teamRanks, "reason": strings.TrimSpace(in.Reason), + }) + return out, nil +} + +// voidedLedgerCount 数该 (小项, 组别) 范围内已作废的流水条数。 +// +// 重算内部是「先作废、再按当前已发布内容重写」,所以前后两次计数的差值就是本次作废掉的条数。 +// 作废是「改标记」而不是删行,历史累积的作废行不会干扰:差值只反映这一次。 +func (s *server) voidedLedgerCount(ctx context.Context, tx *sql.Tx, eventID, categoryID int64) int { + var n int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM score_ledger + WHERE voided = 1 AND source_type IN ('result', 'record_bonus') AND event_id = ? + AND (entry_id IN (SELECT id FROM entries WHERE category_id = ?) + OR (entry_id IS NULL AND result_id IS NULL AND source_type = 'result'))`, + eventID, categoryID).Scan(&n); err != nil { + return 0 + } + return n +} + +// validLedgerPoints 数该 (小项, 组别) 范围内还没作废的流水分合计。 +// 判据与 writeLedgerTx 的作废范围逐字一致:成绩分 / 破纪录加分,含「无报名项归属」的 +// 团体名次与聚合流水(它们挂在 event 上,靠 category 分不出来,只能整项算)。 +func (s *server) validLedgerPoints(ctx context.Context, tx *sql.Tx, eventID, categoryID int64) float64 { + var points float64 + if err := tx.QueryRowContext(ctx, + `SELECT COALESCE(SUM(points), 0) FROM score_ledger + WHERE voided = 0 AND source_type IN ('result', 'record_bonus') AND event_id = ? + AND (entry_id IN (SELECT id FROM entries WHERE category_id = ?) + OR (entry_id IS NULL AND result_id IS NULL AND source_type = 'result'))`, + eventID, categoryID).Scan(&points); err != nil { + return 0 + } + return points +} + +// clearVoidReason 是流水作废原因;操作员填了原因就带上,便于事后复盘。 +func clearVoidReason(reason, scope string) string { + base := "清空成绩" + if scope == clearScopeBatch || scope == clearScopeUnbatched { + base += "(当前批次)" + } + if trimmed := strings.TrimSpace(reason); trimmed != "" { + return base + ":" + trimmed + } + return base +} + +// clearRevisionNote 是变更历史里那句话:说清范围、条数与「已发布的分被收回」。 +func clearRevisionNote(scope string, heat *int64, cleared, published, teamRanks int) string { + word := "本组" + switch scope { + case clearScopeBatch: + word = "第 " + strconv.FormatInt(*heat, 10) + " 批" + case clearScopeUnbatched: + word = "未分批" + } + note := "清空" + word + "成绩:" + strconv.Itoa(cleared) + " 条(其中已发布 " + + strconv.Itoa(published) + " 条,分数已收回)" + if teamRanks > 0 { + note += ",团体名次 " + strconv.Itoa(teamRanks) + " 条一并清空" + } + return note +} diff --git a/server/internal/httpapi/results_clear_test.go b/server/internal/httpapi/results_clear_test.go new file mode 100644 index 0000000..265278a --- /dev/null +++ b/server/internal/httpapi/results_clear_test.go @@ -0,0 +1,306 @@ +package httpapi_test + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +// clearResults 走真实接口清空指定范围,返回响应体。 +func clearResults(t *testing.T, env *testEnv, client *http.Client, body map[string]any, wantStatus int) map[string]any { + t.Helper() + status, env2 := env.do(client, http.MethodPost, "/api/results/clear", body, true) + if status != wantStatus { + t.Fatalf("清空应 %d,得到 %d %+v", wantStatus, status, env2.Error) + } + if wantStatus != http.StatusOK { + return nil + } + return decodeData[map[string]any](t, env2) +} + +// validLedgerPoints 数该小项当前有效流水的合计分。 +func validLedgerPoints(t *testing.T, env *testEnv, eventID int64) int { + t.Helper() + return countRows(t, env, + `SELECT CAST(COALESCE(SUM(points), 0) AS INTEGER) FROM score_ledger + WHERE event_id = ? AND voided = 0 AND source_type = 'result'`, eventID) +} + +// 清空本组:已发布的分被收回(榜单分数下降)、成绩内容回到「没录过」、写审计与变更历史。 +// +// 这是主人点名的危险操作入口:撤销发布保留成绩内容,清空连内容一起清掉,两者不能混为一谈。 +func TestClearGroupResultsWipesScoresAndContent(t *testing.T) { + env, ids, client := recorderWithSeed(t) + publishResults(t, env, client, ids.event, "clear-group", + map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, + map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"}, + ) + // 前置:榜上确有名次分(前两名 10 + 8 = 18),且两条都是已发布。 + if got := teamPoints(t, env, client, ids.teamA); got != 10 { + t.Fatalf("前置:A 队应有 10 分,得到 %v", got) + } + if got := validLedgerPoints(t, env, ids.event); got != 18 { + t.Fatalf("前置:有效流水应合计 18 分,得到 %d", got) + } + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 2, ids.event) + + body := clearResults(t, env, client, map[string]any{ + "eventId": ids.event, "categoryId": ids.category, "confirm": true, "reason": "现场重赛", + }, http.StatusOK) + // 回执要说清清了几条、其中几条原先已发布、作废了几条流水。 + if body["scope"] != "group" || body["cleared"] != float64(2) || body["published"] != float64(2) { + t.Fatalf("回执不对: %+v", body) + } + if body["voidedLedger"] != float64(2) { + t.Fatalf("应作废 2 条流水(两条名次分各一条),得到 %v", body["voidedLedger"]) + } + + // ① 分数收回:榜单回到 0,有效流水一条不剩,作废行保留(审计痕迹)。 + if got := teamPoints(t, env, client, ids.teamA); got != 0 { + t.Fatalf("清空后 A 队应 0 分,得到 %v", got) + } + if got := teamPoints(t, env, client, ids.teamB); got != 0 { + t.Fatalf("清空后 B 队应 0 分,得到 %v", got) + } + assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 0, ids.event) + assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 1`, 2, ids.event) + + // ② 成绩内容清空(回到「没录过」):名次 / 成绩值 / 文本 / 状态 / 备注全清,state 退回 draft。 + assertCount(t, env, + `SELECT COUNT(*) FROM results + WHERE event_id = ? AND state = 'draft' AND rank IS NULL AND result_value IS NULL + AND result_text IS NULL AND unit IS NULL AND note IS NULL AND status = 'ok'`, 2, ids.event) + // 录名次的那一列也要跟着走:清空后不该还留着旧名次。 + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, 0, ids.event) + + // ③ 录入页读到的这一组不再有「已发布」的行(按钮与锁定都据此判断)。 + board := entryStates(t, env, client, ids.event) + if board[ids.entryA] != "draft" || board[ids.entryB] != "draft" { + t.Fatalf("清空后成绩状态应是 draft,得到 %v", board) + } + + // ④ 小项进度跟着退回:左栏不能一边写着「已发布」、一边一条已发布成绩都没有。 + var status string + if err := env.ev.QueryRow(`SELECT status FROM events WHERE id = ?`, ids.event).Scan(&status); err != nil { + t.Fatalf("读小项状态: %v", err) + } + if status == "published" { + t.Fatalf("清空后小项状态不该还是 published(左栏会显示「已发布」却没有已发布成绩)") + } + + // ⑤ 审计:谁在什么时候清空了哪一组,连带条数。 + var action, payload string + if err := env.ev.QueryRow( + `SELECT action, after_json FROM audit_logs WHERE action = 'result.clear'`).Scan(&action, &payload); err != nil { + t.Fatalf("应写一条 result.clear 审计: %v", err) + } + for _, want := range []string{`"categoryId":`, `"scope":"group"`, `"cleared":2`, `"published":2`, `现场重赛`} { + if !strings.Contains(payload, want) { + t.Fatalf("审计载荷应含 %s,得到 %s", want, payload) + } + } + // ⑥ 变更历史:清空按「撤销发布」记一条,message 里写明清了多少条。 + var message string + if err := env.ev.QueryRow( + `SELECT message FROM result_revisions WHERE event_id = ? AND action = 'unpublish' + ORDER BY id DESC LIMIT 1`, ids.event).Scan(&message); err != nil { + t.Fatalf("应写一条 unpublish 修订: %v", err) + } + if !strings.Contains(message, "清空本组成绩:2 条") { + t.Fatalf("修订文案应写明清空条数,得到 %q", message) + } +} + +// 清空本批:只清当前批次,别的批次的已发布成绩与分数原样保留。 +func TestClearBatchKeepsOtherBatches(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + entries := seedBatch(t, env, ids, 6) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + // 第 1 批:前 3 条;第 2 批:后 3 条。两批都录成绩并发布。 + for i, entryID := range entries { + heat := int64(1) + if i >= 3 { + heat = 2 + } + if status, e := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{ + "entryIds": []int64{entryID}, "heat": heat, + }, true); status != http.StatusOK { + t.Fatalf("设置批次应 200,得到 %d %+v", status, e.Error) + } + } + times := make([]float64, 6) + for i := range times { + times[i] = 12.0 + float64(i)*0.1 + } + if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 6, 0, times), + }, true); status != http.StatusOK { + t.Fatalf("存草稿应 200,得到 %d %+v", status, e.Error) + } + if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, + }, true); status != http.StatusOK { + t.Fatalf("发布应 200,得到 %d %+v", status, e.Error) + } + // 前置:6 条已发布,前六名 10+8+6+3+2+1 = 30 分。 + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 6, ids.event) + if got := validLedgerPoints(t, env, ids.event); got != 30 { + t.Fatalf("前置:有效流水应 30 分,得到 %d", got) + } + + body := clearResults(t, env, client, map[string]any{ + "eventId": ids.event, "categoryId": ids.category, "heat": 1, "confirm": true, + }, http.StatusOK) + if body["scope"] != "batch" || body["cleared"] != float64(3) || body["published"] != float64(3) { + t.Fatalf("回执不对: %+v", body) + } + + // 第 1 批清零,第 2 批原样还在。 + for i := 0; i < 3; i++ { + var state string + if err := env.ev.QueryRow(`SELECT state FROM results WHERE entry_id = ?`, entries[i]).Scan(&state); err != nil { + t.Fatalf("读第 1 批状态: %v", err) + } + if state != "draft" { + t.Fatalf("第 1 批第 %d 条应退回草稿,得到 %s", i+1, state) + } + } + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 3, ids.event) + // 剩下第 2 批三个人:第 1、2、3 名 → 10 + 8 + 6 = 24 分(名次整体往前挪,与「只发布过这三条」一致)。 + if got := validLedgerPoints(t, env, ids.event); got != 24 { + t.Fatalf("清掉一批后有效流水应 24 分,得到 %d", got) + } + // 榜单分数必须真的下降:清空前 30 分,清掉一批后只剩第 2 批那三个人(10+8+6=24)。 + if got := teamPoints(t, env, client, ids.teamA) + teamPoints(t, env, client, ids.teamB); got != 24 { + t.Fatalf("清批后两单位合计应 24 分,得到 %v", got) + } +} + +// 没勾选确认不许提交:服务端也拦(前端禁用确认键只是界面防呆)。 +func TestClearRequiresConfirmCheckbox(t *testing.T) { + env, ids, client := recorderWithSeed(t) + publishResults(t, env, client, ids.event, "clear-confirm", + map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, + ) + + for _, body := range []map[string]any{ + {"eventId": ids.event, "categoryId": ids.category}, + {"eventId": ids.event, "categoryId": ids.category, "confirm": false}, + } { + status, env2 := env.do(client, http.MethodPost, "/api/results/clear", body, true) + if status != http.StatusBadRequest { + t.Fatalf("没勾选确认应 400,得到 %d %+v", status, env2.Error) + } + if env2.Error == nil || !strings.Contains(env2.Error.Message, "勾选") { + t.Fatalf("400 应说清要先勾选,得到 %+v", env2.Error) + } + } + // 一条都不能被动过。 + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 1, ids.event) + if got := validLedgerPoints(t, env, ids.event); got != 10 { + t.Fatalf("被拦下的请求不该动流水,得到 %d", got) + } +} + +// 清空要按「小项 + 组别」为界:缺 categoryId 一律 400(没有「一键清空整项」这条危险路)。 +func TestClearRequiresCategory(t *testing.T) { + env, ids, client := recorderWithSeed(t) + for _, body := range []map[string]any{ + {"eventId": ids.event, "confirm": true}, + {"eventId": ids.event, "categoryId": 0, "confirm": true}, + } { + status, env2 := env.do(client, http.MethodPost, "/api/results/clear", body, true) + if status != http.StatusBadRequest { + t.Fatalf("缺 categoryId 应 400,得到 %d %+v", status, env2.Error) + } + if env2.Error == nil || !strings.Contains(env2.Error.Message, "categoryId") { + t.Fatalf("400 应点名 categoryId,得到 %+v", env2.Error) + } + } +} + +// 空范围给明确回答,而不是静默「清空 0 条」——操作员多半看错了批次 / 组别。 +func TestClearEmptyScopeIsRejected(t *testing.T) { + env, ids, client := recorderWithSeed(t) + status, env2 := env.do(client, http.MethodPost, "/api/results/clear", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, "confirm": true, + }, true) + if status != http.StatusBadRequest { + t.Fatalf("没有成绩时清空应 400,得到 %d %+v", status, env2.Error) + } + if env2.Error == nil || !strings.Contains(env2.Error.Message, "没有成绩") { + t.Fatalf("400 应说清没有成绩可清,得到 %+v", env2.Error) + } +} + +// 权限与录成绩同档:访客 403,recorder 放行。 +func TestClearNeedsRecorder(t *testing.T) { + env, ids, client := recorderWithSeed(t) + publishResults(t, env, client, ids.event, "clear-perm", + map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, + ) + guest, _ := env.client() + status, _ := env.do(guest, http.MethodPost, "/api/results/clear", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, "confirm": true, + }, false) + if status != http.StatusForbidden && status != http.StatusUnauthorized { + t.Fatalf("访客清空应被挡下(401/403),得到 %d", status) + } + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 1, ids.event) +} + +// 清空整个组别时,连带清掉「按单位录入的团体名次」:否则那一批分没有成绩行, +// 重算会原样再写一遍——现场就变成「清空了榜上却还有分」。 +func TestClearGroupAlsoClearsTeamManualRanks(t *testing.T) { + env, ids, client := recorderWithSeed(t) + // 换成「团体 + 手工名次」通道:团体名次走 channel_manual_ranks。 + if _, err := env.ev.Exec( + `DELETE FROM event_score_channels WHERE event_id = ?`, ids.event); err != nil { + t.Fatalf("清通道: %v", err) + } + var profileID int64 + if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&profileID); err != nil { + t.Fatalf("读团体模板: %v", err) + } + env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) + VALUES (?, 'team', 'manual', ?, 0)`, ids.event, profileID) + + // 录两个单位的名次 → 发布 → 榜上每个单位各得名次分。 + if status, e := env.do(client, http.MethodPut, + fmt.Sprintf("/api/events/%d/categories/%d/team-ranks", ids.event, ids.category), + map[string]any{"scope": "team", "basis": "manual", "ranks": []map[string]any{ + {"teamId": ids.teamA, "rank": 1}, {"teamId": ids.teamB, "rank": 2}, + }}, true); status != http.StatusOK { + t.Fatalf("写团体名次应 200,得到 %d %+v", status, e.Error) + } + if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, + }, true); status != http.StatusOK { + t.Fatalf("发布应 200,得到 %d %+v", status, e.Error) + } + // 前置:纯团体名次项目,报名项上没有成绩,分全挂在单位上(15 + 10)。 + if got := teamPoints(t, env, client, ids.teamA); got != 15 { + t.Fatalf("前置:A 队应 15 分,得到 %v", got) + } + + body := clearResults(t, env, client, map[string]any{ + "eventId": ids.event, "categoryId": ids.category, "confirm": true, + }, http.StatusOK) + if body["teamRanks"] != float64(2) { + t.Fatalf("应清掉 2 条团体名次,得到 %v", body["teamRanks"]) + } + assertCount(t, env, `SELECT COUNT(*) FROM channel_manual_ranks WHERE event_id = ?`, 0, ids.event) + // 榜上的分必须真的归零——这正是「清空」与「撤销发布」现场差别最大的地方。 + if got := teamPoints(t, env, client, ids.teamA); got != 0 { + t.Fatalf("清空后 A 队应 0 分,得到 %v", got) + } + if got := teamPoints(t, env, client, ids.teamB); got != 0 { + t.Fatalf("清空后 B 队应 0 分,得到 %v", got) + } +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 0fe8604..2496526 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -290,6 +290,8 @@ func (s *server) routes(mux *http.ServeMux) { mux.HandleFunc("PUT /api/results/draft", s.write(auth.RoleRecorder, s.saveDraftHandler)) mux.HandleFunc("POST /api/results/publish", s.write(auth.RoleRecorder, s.publishHandler)) mux.HandleFunc("POST /api/results/unpublish", s.write(auth.RoleRecorder, s.unpublishHandler)) + // 清空当前组 / 批次成绩:危险操作(连成绩内容一起清),要勾选确认 + 写审计。 + mux.HandleFunc("POST /api/results/clear", s.write(auth.RoleRecorder, s.clearResultsHandler)) // 分批延迟结算:settlement 给录入页查「哪些组还没结算」,settle 是人工兜底(写审计)。 mux.HandleFunc("GET /api/results/settlement", s.read(auth.RoleRecorder, s.settlementStatusHandler)) mux.HandleFunc("POST /api/results/settle", s.write(auth.RoleRecorder, s.settleResultsHandler)) diff --git a/web/src/admin/api/client.ts b/web/src/admin/api/client.ts index f2e3d33..f31ecf1 100644 --- a/web/src/admin/api/client.ts +++ b/web/src/admin/api/client.ts @@ -4,6 +4,8 @@ import type { AthletePurgeResult, DisplayInstance, EntryBoard } from '@/shared/a import type { AthleteStanding, AuditLog, + ClearResultsPayload, + ClearResultsResult, CurrentUser, DisplayCommand, EntryHeatPayload, @@ -237,6 +239,15 @@ export const adminApi = { reopenResult: (id: number, payload: ReopenResultPayload): Promise => callWrite(() => client.results.reopen(id, payload)), + /** + * 清空「当前组 / 批次」的成绩(= 撤销发布 + 清空成绩内容,危险操作)。 + * + * 与发布 / 撤销发布同一档:写操作不降级(示例数据模式下页面先看 data.isMock 拦下), + * 请求体里的 `confirm: true` 是「勾选了『我知道这会清掉已发布的分数』」在协议上的落点。 + */ + clearResults: (payload: ClearResultsPayload): Promise => + callWrite(() => client.results.clearResults(payload)), + standings: (): Promise => call(() => client.standings.teams(), () => mockDb.standings()), diff --git a/web/src/admin/components/ConfirmDialog.vue b/web/src/admin/components/ConfirmDialog.vue index 3c9cd6e..8a33708 100644 --- a/web/src/admin/components/ConfirmDialog.vue +++ b/web/src/admin/components/ConfirmDialog.vue @@ -9,6 +9,10 @@ import MxIcon from './MxIcon.vue' * `reasonRequired` 打开时,弹窗里多出一个必填原因输入框(作废成绩 / 退回草稿 / 作废破纪录登记), * 原因随 confirm 事件一起 emit 给调用方,由调用方写进请求体(服务端再写审计)。 * 原因留空时**不 emit confirm**:这类操作都改榜单,宁可多拦一次,也不能带着空原因落库。 + * + * `ackRequired` 是另一道更轻的门:勾选一句话(例如「我知道这会清掉已发布的分数」)才允许确认。 + * 用于**后果需要被读到**的操作(清空成绩):光有「确认 / 取消」两颗按钮, + * 现场很容易连点两下把一整组成绩清掉;勾选强制多读一句话。 */ const props = withDefaults( defineProps<{ @@ -24,6 +28,10 @@ const props = withDefaults( reasonRequired?: boolean reasonLabel?: string reasonPlaceholder?: string + /** true 时要求勾选下面的确认句才允许点确认键。 */ + ackRequired?: boolean + /** 勾选框旁的说明;缺省时给一句中性的,具体语义由调用方写清。 */ + ackLabel?: string }>(), { details: () => [], @@ -34,6 +42,8 @@ const props = withDefaults( reasonRequired: false, reasonLabel: '原因', reasonPlaceholder: '必填,会写入审计日志', + ackRequired: false, + ackLabel: '我已确认这次操作', }, ) @@ -43,8 +53,12 @@ const emit = defineEmits<{ confirm: [reason: string]; cancel: [] }>() const reason = ref('') /** 只有点过确认键(或按键提交)之后才亮红字,避免刚打开就一片报错。 */ const reasonTouched = ref(false) +/** 勾选状态;同样在每次弹窗打开时复位——上一次的勾选不能替这一次背书。 */ +const acked = ref(false) const reasonMissing = computed(() => props.reasonRequired && reason.value.trim() === '') +/** 勾选框没勾上:确认键据此禁用(原因必填与它互相独立)。 */ +const ackMissing = computed(() => props.ackRequired && !acked.value) watch( () => props.open, @@ -52,6 +66,7 @@ watch( if (!open) return reason.value = '' reasonTouched.value = false + acked.value = false }, ) @@ -67,13 +82,14 @@ function requestCancel(): void { emit('cancel') } -/** 确认键:先过原因必填,再 emit。 */ +/** 确认键:先过原因必填与勾选,再 emit。 */ function requestConfirm(): void { if (props.pending) return if (reasonMissing.value) { reasonTouched.value = true return } + if (ackMissing.value) return emit('confirm', reason.value.trim()) } @@ -118,6 +134,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown)) > 原因必填 + +
+ + +
@@ -3436,6 +3589,24 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i + + + row.resultState === 'published').length, + } +} + +export interface ClearDetailsInput { + eventName: string + categoryName: string + scope: ClearScope + heat: number | null + cleared: number + published: number + /** 该组别已录入的团体手工名次条数(0 = 这一项没有记团体名次,或清的是批次)。 */ + teamRanks: number +} + +/** + * 确认框明细:谁都能照着念——哪个小项哪个组 / 批、清多少条、其中多少条已发布、 + * 已发布的分会被收回。 + */ +export function clearConfirmDetails(input: ClearDetailsInput): string[] { + const scope = clearScopeWord(input.scope, input.heat) + const details = [ + `小项「${input.eventName}」· 组别「${input.categoryName}」· 范围:${scope}`, + `将清空 ${input.cleared} 条成绩(其中已发布 ${input.published} 条)`, + ] + if (input.published > 0) { + details.push(`这 ${input.published} 条已发布成绩产生的分会一并收回,积分榜与大屏跟着回退`) + } else { + details.push('当前没有已发布的成绩,这一步不涉及积分榜变化') + } + if (input.scope === 'group' && input.teamRanks > 0) { + details.push(`该组按单位录入的团体名次(${input.teamRanks} 条)会一并清空`) + } + details.push('清空后这一组回到「没录过」:名次、成绩值、备注都清掉') + details.push(keepPublishedNote()) + return details +} + +/** 与「撤销发布」的区别:两者只差一步,界面上必须说清。 */ +export function keepPublishedNote(): string { + return '只想收回分数、保留成绩内容,请改用「撤销发布」' +} + +/** 没勾选确认不许提交(确认键据此禁用)。 */ +export function canSubmitClear(acked: boolean): boolean { + return acked +} diff --git a/web/src/admin/pages/entryInitialEvent.ts b/web/src/admin/pages/entryInitialEvent.ts new file mode 100644 index 0000000..a7776c6 --- /dev/null +++ b/web/src/admin/pages/entryInitialEvent.ts @@ -0,0 +1,95 @@ +/** + * 进入成绩录入页时选哪个小项。 + * + * 抽出来的原因:`.vue` 组件跑不进 `node --test`,而这条选择规则踩过一次真坑—— + * 页面**不记得上次录的是哪个小项**,兜底挑「第一个待录入(pending)的小项」, + * 而刚发布完的小项状态已经是 published,恰好被这条兜底排除掉:去基础数据改个名称 + * 再回录入页,右侧换成了别的小项,「撤销发布」按钮自然不在(左栏那个小项还写着已发布)。 + * + * 记忆按 **id** 存:改小项显示名称不该影响任何功能,名字从头到尾不参与这里的判断。 + * 记忆只是「优先」,不是「唯一」——小项被删掉、存储读不出来(隐私模式)时一律退回 + * 原来的兜底,绝不让一条坏记忆把人挡在录入页外面。 + */ + +/** 上次录入的小项在 localStorage 里的键。带前缀,免得和同域下别的页面撞。 */ +export const LAST_ENTRY_EVENT_KEY = 'scoreboard.entry.lastEventId' + +/** 只用到这两件事;注入假实现后,node 里也能把存取与脏数据这两条路走一遍。 */ +export interface EntryMemoryStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +/** 浏览器的 localStorage;隐私模式 / 禁用存储时**读属性本身就抛**,所以整段包在 try 里。 */ +export function entryMemoryStorage(): EntryMemoryStorage | null { + try { + return (globalThis as { localStorage?: EntryMemoryStorage }).localStorage ?? null + } catch { + return null + } +} + +/** 记住上次成功加载过的小项(null = 清掉记忆)。存不进去不影响录入。 */ +export function rememberEntryEventId(storage: EntryMemoryStorage | null, id: number | null): void { + if (storage === null) return + try { + storage.setItem(LAST_ENTRY_EVENT_KEY, id === null ? '' : String(id)) + } catch { + /* 存储写不进去(配额满 / 隐私模式)时静默放弃:录入不靠这条记忆 */ + } +} + +/** + * 读出记忆的小项 id。 + * + * 只认正整数:空串、负数、小数、非数字一律当成「没有记忆」。 + * 把脏值原样喂给 `events.find(id === ...)` 本身无害,但「有没有记忆」这件事会在 + * 日志与调试里被反复读,宁可在这里就把它收敛成 null / 正整数两种可能。 + */ +export function readLastEntryEventId(storage: EntryMemoryStorage | null): number | null { + if (storage === null) return null + let raw: string | null + try { + raw = storage.getItem(LAST_ENTRY_EVENT_KEY) + } catch { + return null + } + if (raw === null) return null + const text = raw.trim() + if (!/^[1-9]\d*$/.test(text)) return null + const value = Number(text) + return Number.isSafeInteger(value) ? value : null +} + +/** 选择时只需要 id 与进度状态;名字不参与(改名不该影响任何功能)。 */ +export interface InitialEventCandidate { + id: number + status: string +} + +export interface InitialEventInput { + /** URL 里的 ?eventId=;非数字 / 缺省时为 null。 */ + fromQuery: number | null + /** 上次录入的小项 id。 */ + lastId: number | null + events: readonly InitialEventCandidate[] +} + +/** + * 选哪个小项进页面,按优先级: + * 1. URL 带 eventId(从仪表盘「继续录入」这类深链进来,对方的意图最明确); + * 2. 上次录入过的那个小项(**不排除已发布的小项**——发布完回来接着看的正是它); + * 3. 第一个待录入(pending)的小项(改动前的兜底,保留); + * 4. 列表第一个。 + * + * 前两条都要在小项列表里真的存在才算数:小项被删掉之后,记忆里那个 id 不该把页面锁死。 + */ +export function pickInitialEventId(input: InitialEventInput): number | null { + const exists = (id: number | null): id is number => + id !== null && input.events.some((event) => event.id === id) + if (exists(input.fromQuery)) return input.fromQuery + if (exists(input.lastId)) return input.lastId + const pending = input.events.find((event) => event.status === 'pending') + if (pending !== undefined) return pending.id + return input.events[0]?.id ?? null +} diff --git a/web/src/admin/tests/entry-clear.test.ts b/web/src/admin/tests/entry-clear.test.ts new file mode 100644 index 0000000..380e994 --- /dev/null +++ b/web/src/admin/tests/entry-clear.test.ts @@ -0,0 +1,156 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { + CLEAR_ACK_LABEL, + CLEAR_BATCH_LABEL, + CLEAR_GROUP_LABEL, + CLEAR_RESULT_TITLE, + clearConfirmDetails, + clearPayload, + clearScopeOf, + clearScopeWord, + clearSummary, + canSubmitClear, + keepPublishedNote, +} from '../pages/entryClear' + +/** + * 「清空当前组 / 批次成绩」的判定与文案(危险操作)。 + * + * 语义(主人定的):「清空」= **先撤销发布**(已发布的收回、该组产生的积分流水作废) + * **+ 把成绩内容清空**(名次 / 成绩值 / 备注都清,回到「没录过」)。 + * 与「撤销发布」必须分得清:撤销发布**保留成绩内容**、只把分数收回;清空连内容一起清掉。 + * + * 所以这里钉三件事: + * 1. 作用范围与请求体:整组**不带** heat 键,批次带数字,未分批带 null(三者在服务端是三条路); + * 2. 确认框要写明「清多少条、哪个小项哪个组 / 批、已发布的分会被收回」; + * 3. 没勾选「我知道这会清掉已发布的分数」不许提交。 + */ + +const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8') +const DIALOG_SOURCE = readFileSync(new URL('../components/ConfirmDialog.vue', import.meta.url), 'utf8') + +const row = (heat: number | null, state: string | null) => ({ heat, resultState: state }) + +test('作用范围:全部 = 整组,数字 = 该批次,未分批 = 只清未分批的行', () => { + assert.equal(clearScopeOf('all'), 'group') + assert.equal(clearScopeOf(2), 'batch') + assert.equal(clearScopeOf('none'), 'unbatched') +}) + +test('请求体:整组不带 heat 键,批次带数字,未分批带 null', () => { + const group = clearPayload({ eventId: 4, categoryId: 3, filter: 'all', reason: '' }) + assert.equal('heat' in group, false, '整组不能带 heat:服务端把「有 heat 键」当批次处理') + assert.equal(group.eventId, 4) + assert.equal(group.categoryId, 3) + + const batch = clearPayload({ eventId: 4, categoryId: 3, filter: 2, reason: '现场重赛' }) + assert.equal(batch.heat, 2) + assert.equal(batch.reason, '现场重赛') + + const unbatched = clearPayload({ eventId: 4, categoryId: 3, filter: 'none', reason: '' }) + assert.equal('heat' in unbatched, true) + assert.equal(unbatched.heat, null) +}) + +test('请求体一律带 confirm:true —— 服务端也要求勾选,界面禁用只是第一道', () => { + const payload = clearPayload({ eventId: 1, categoryId: 2, filter: 'all', reason: '' }) + assert.equal(payload.confirm, true) +}) + +test('条数统计:按当前可见行算,已发布条数单独数出来', () => { + const rows = [row(1, 'published'), row(1, 'draft'), row(2, 'published')] + assert.deepEqual(clearSummary(rows), { cleared: 3, published: 2 }) + assert.deepEqual(clearSummary([]), { cleared: 0, published: 0 }) +}) + +test('称呼:整组说「本组」,批次说「第 N 批」,未分批说「未分批」', () => { + assert.equal(clearScopeWord('group', null), '本组') + assert.equal(clearScopeWord('batch', 2), '第 2 批') + assert.equal(clearScopeWord('unbatched', null), '未分批') +}) + +test('按钮文案逐字:两个粒度各一个入口,且点明是「清空」不是「撤销发布」', () => { + assert.equal(CLEAR_GROUP_LABEL, '清空本组') + assert.equal(CLEAR_BATCH_LABEL, '清空本批') + assert.equal(CLEAR_RESULT_TITLE, '清空该组成绩') + assert.notEqual(CLEAR_GROUP_LABEL, '撤销发布') +}) + +test('确认框明细:点名小项与组别、清多少条、其中多少条已发布、分数被收回', () => { + const details = clearConfirmDetails({ + eventName: '100米跑', + categoryName: '男子', + scope: 'batch', + heat: 2, + cleared: 8, + published: 8, + teamRanks: 0, + }) + const text = details.join('\n') + assert.match(text, /100米跑/) + assert.match(text, /男子/) + assert.match(text, /第 2 批/) + assert.match(text, /8 条/) + assert.match(text, /已发布 8 条/) + assert.match(text, /收回/) + // 与「撤销发布」的区别必须写在框里,不能让人以为两者一样。 + assert.match(text, /撤销发布/) +}) + +test('清整组时会点名「团体名次一并清空」,清批次时不会', () => { + const group = clearConfirmDetails({ + eventName: '篮球', categoryName: '男子', scope: 'group', heat: null, + cleared: 4, published: 4, teamRanks: 6, + }).join('\n') + assert.match(group, /团体名次/) + const batch = clearConfirmDetails({ + eventName: '篮球', categoryName: '男子', scope: 'batch', heat: 1, + cleared: 4, published: 4, teamRanks: 0, + }).join('\n') + assert.doesNotMatch(batch, /团体名次/) +}) + +test('一条已发布都没有时如实说「不涉及榜单变化」,不吓唬人也不谎报', () => { + const details = clearConfirmDetails({ + eventName: '100米跑', categoryName: '男子', scope: 'group', heat: null, + cleared: 3, published: 0, teamRanks: 0, + }).join('\n') + assert.match(details, /没有已发布的成绩/) + // 「本批没有已发布的分」时不该出现「会一并收回」这种后果描述(那句话在别的分支里)。 + assert.doesNotMatch(details, /会一并收回/) + assert.match(keepPublishedNote(), /撤销发布/) +}) + +test('没勾选确认时不允许提交', () => { + assert.equal(canSubmitClear(false), false) + assert.equal(canSubmitClear(true), true) + assert.equal(CLEAR_ACK_LABEL, '我知道这会清掉已发布的分数') +}) + +test('录入页接线:两个入口 + 勾选门禁 + 审计(清空走 /results/clear)', () => { + // 两个粒度的入口都在工具栏(发布按钮附近),清空本批只在选了批次时出现。 + assert.match(ENTRY_SOURCE, /v-if="heatScoped"[\s\S]{0,400}openClear\('batch'\)/) + assert.match(ENTRY_SOURCE, /\{\{ CLEAR_BATCH_LABEL \}\}/) + assert.match(ENTRY_SOURCE, /openClear\('group'\)/) + assert.match(ENTRY_SOURCE, /\{\{ CLEAR_GROUP_LABEL \}\}/) + // 勾选门禁与确认框接线。 + assert.match(ENTRY_SOURCE, /:ack-required="true"/) + assert.match(ENTRY_SOURCE, /adminApi\.clearResults\(/) +}) + +test('权限与录成绩同档:访客只读与示例数据模式都置灰,并在 title 里说明原因', () => { + // 两颗按钮都跟着 canWrite / isMock 走(与「撤销发布」「存草稿」同一把尺子)。 + const groupBtn = ENTRY_SOURCE.slice(ENTRY_SOURCE.indexOf('openClear(\'group\')') - 900) + assert.match(groupBtn, /:disabled="!canWrite \|\| data\.isMock"/) + assert.match(groupBtn, /示例数据模式下不能清空成绩/) + assert.match(groupBtn, /需要登录后才能清空成绩/) + // 访客只读时的入口:置灰 + 说明,而不是点了才被告知。 + assert.match(ENTRY_SOURCE, /toast\.error\('当前不可清空成绩', auth\.readOnly \? '请先登录' : '服务端未连接'\)/) +}) + +test('确认框支持「必须勾选」:勾选框未勾上时确认键不可点', () => { + assert.match(DIALOG_SOURCE, /ackRequired/) + assert.match(DIALOG_SOURCE, /:disabled="pending \|\| ackMissing"/) +}) diff --git a/web/src/admin/tests/entry-initial-event.test.ts b/web/src/admin/tests/entry-initial-event.test.ts new file mode 100644 index 0000000..064d65e --- /dev/null +++ b/web/src/admin/tests/entry-initial-event.test.ts @@ -0,0 +1,126 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { + LAST_ENTRY_EVENT_KEY, + entryMemoryStorage, + pickInitialEventId, + readLastEntryEventId, + rememberEntryEventId, +} from '../pages/entryInitialEvent' + +/** + * 进入录入页时选哪个小项。 + * + * 现场踩到的坑:在录入页录完成绩、发布、去基础数据改了显示名称,回来时「撤销发布」按钮 + * 不见了——右侧表里根本不是那个小项。原因是进入页面时**不记得上次录的是哪个小项**, + * 兜底挑「第一个待录入(pending)的小项」,而刚发布完的小项状态已经是 published, + * 恰好被这条兜底排除掉。 + * + * 所以这里钉死两件事: + * 1. 上次录入的小项要按 **id** 记住并优先选回来(改名字不影响,名字根本不参与判断); + * 2. 记忆失效(小项被删、存储读不出来)时,行为退回改动前那套兜底,不因为一条坏记忆卡住录入。 + * + * 项目没有组件测试工具,判定逻辑抽在 entryInitialEvent 里直接测,界面接线读源码断言。 + */ + +const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8') + +/** 内存版 localStorage:node 里没有浏览器存储,存取两条路都要能测。 */ +function memoryStorage(initial: Record = {}) { + const map = new Map(Object.entries(initial)) + return { + getItem: (key: string) => map.get(key) ?? null, + setItem: (key: string, value: string) => void map.set(key, value), + dump: () => Object.fromEntries(map), + } +} + +test('上次录入的小项即使已经发布,回到录入页也要选回它', () => { + // 4 号是刚发布完的那个(status=published),3 号是另一个待录入的小项。 + const events = [ + { id: 3, status: 'pending' }, + { id: 4, status: 'published' }, + ] + assert.equal(pickInitialEventId({ fromQuery: null, lastId: 4, events }), 4) +}) + +test('按 id 认小项:名字改了、同名了都照样选回原来那一个', () => { + // 两个同名小项(现场确实会有「预赛 / 决赛」这类重名),只认 id。 + const events = [ + { id: 7, status: 'pending' }, + { id: 9, status: 'pending' }, + ] + assert.equal(pickInitialEventId({ fromQuery: null, lastId: 9, events }), 9) +}) + +test('URL 带 eventId 时优先于记忆', () => { + const events = [ + { id: 3, status: 'pending' }, + { id: 4, status: 'published' }, + ] + assert.equal(pickInitialEventId({ fromQuery: 3, lastId: 4, events }), 3) +}) + +test('记忆里的小项已经被删掉时,退回原来的兜底:第一个待录入,其次第一个', () => { + const events = [ + { id: 3, status: 'finished' }, + { id: 4, status: 'pending' }, + ] + assert.equal(pickInitialEventId({ fromQuery: null, lastId: 99, events }), 4) + assert.equal( + pickInitialEventId({ fromQuery: null, lastId: 99, events: [{ id: 3, status: 'finished' }] }), + 3, + ) + assert.equal(pickInitialEventId({ fromQuery: null, lastId: 99, events: [] }), null) +}) + +test('没有记忆、URL 也没带时,行为与改动前一致(第一个 pending)', () => { + const events = [ + { id: 3, status: 'ongoing' }, + { id: 4, status: 'pending' }, + ] + assert.equal(pickInitialEventId({ fromQuery: null, lastId: null, events }), 4) + assert.equal(pickInitialEventId({ fromQuery: null, lastId: null, events: [{ id: 3, status: 'ongoing' }] }), 3) +}) + +test('记忆的读写:只存数字 id,脏数据一律当成没有记忆', () => { + const storage = memoryStorage() + assert.equal(readLastEntryEventId(storage), null) + + rememberEntryEventId(storage, 12) + assert.equal(storage.dump()[LAST_ENTRY_EVENT_KEY], '12') + assert.equal(readLastEntryEventId(storage), 12) + + // 非数字 / 负数 / 空白 → 不认(会被当成「没有记忆」,走兜底) + for (const bad of ['abc', '', ' ', '-1', '0', '3.5', 'null']) { + assert.equal(readLastEntryEventId(memoryStorage({ [LAST_ENTRY_EVENT_KEY]: bad })), null, bad) + } + // 清空(null)时把键写空串,读回来同样是「没有记忆」 + rememberEntryEventId(storage, null) + assert.equal(readLastEntryEventId(storage), null) +}) + +test('存储不可用(隐私模式 / 禁用存储)时不抛错,只是没有记忆', () => { + const throwing = { + getItem: () => { + throw new Error('禁用了存储') + }, + setItem: () => { + throw new Error('禁用了存储') + }, + } + assert.equal(readLastEntryEventId(throwing), null) + assert.doesNotThrow(() => rememberEntryEventId(throwing, 5)) + assert.equal(readLastEntryEventId(null), null) + assert.doesNotThrow(() => rememberEntryEventId(null, 5)) + // node 里没有 window.localStorage:取存储这一步本身也不能抛。 + assert.equal(entryMemoryStorage(), null) +}) + +test('录入页接线:进入时用记忆,成功加载后写回记忆(按 id)', () => { + assert.match(ENTRY_SOURCE, /readLastEntryEventId\(entryMemoryStorage\(\)\)/) + assert.match(ENTRY_SOURCE, /rememberEntryEventId\(entryMemoryStorage\(\), id\)/) + // 兜底仍是「第一个 pending」,但不再是进入页面时的第一顺位。 + assert.match(ENTRY_SOURCE, /lastId:/) +}) diff --git a/web/src/admin/tests/entry-sample-data-guard.test.ts b/web/src/admin/tests/entry-sample-data-guard.test.ts index e1084c2..ba01952 100644 --- a/web/src/admin/tests/entry-sample-data-guard.test.ts +++ b/web/src/admin/tests/entry-sample-data-guard.test.ts @@ -50,12 +50,16 @@ test('两个按钮在示例数据模式下直接禁用,避免点了才被告 assert.match(ENTRY_PAGE_SOURCE, /:disabled="!canWrite \|\| data\.isMock"/) }) -test('「继续录入」的注释按实现写:第一个 pending,没有持久化的「上次编辑」', () => { +test('「继续录入」优先选上次录入的小项(按 id),兜底才是第一个 pending', () => { const pickInitial = section(ENTRY_PAGE_SOURCE, '进入录入页时默认选中小项', 'onMounted(') - assert.doesNotMatch(pickInitial, /默认聚焦上次编辑/, '实现里没有「上次编辑」的记忆,注释不能那么写') + // 上次录入的小项按 id 记在本地:刚发布完的那个小项状态已是 published, + // 只按「第一个 pending」兜底会把它排除掉,回录入页就换成了别的小项(撤销发布按钮跟着消失)。 + assert.match(pickInitial, /lastId: readLastEntryEventId\(entryMemoryStorage\(\)\)/) + assert.match(pickInitial, /events: data\.events/) + // 兜底行为没变:没有记忆时仍是「第一个未开始(pending)的项目,都没有就取列表第一个」。 assert.match(pickInitial, /第一个未开始(pending)的项目/) - assert.match(pickInitial, /event\.status === 'pending'/) + assert.match(pickInitial, /pickInitialEventId/) }) test('整份 EntryPage 里不再残留「默认聚焦上次编辑」这类与实现不符的说法', () => { diff --git a/web/src/shared/api.ts b/web/src/shared/api.ts index 05c86e0..4396b73 100644 --- a/web/src/shared/api.ts +++ b/web/src/shared/api.ts @@ -12,6 +12,8 @@ import type { AuditLogQuery, BackupRestoreInput, BackupRestoreResult, + ClearResultsPayload, + ClearResultsResult, CurrentUser, DisplayCommand, Entry, @@ -354,6 +356,13 @@ export interface ResultsApi { saveDraft(payload: SaveDraftPayload): Promise publish(payload: PublishPayload): Promise unpublish(payload: PublishPayload): Promise + /** + * 清空「当前组 / 批次」的成绩(`POST /api/results/clear`,危险操作)。 + * + * = 撤销发布(收回分数、作废该范围的积分流水)+ 清空成绩内容(名次 / 成绩值 / 备注)。 + * 请求体必须带 `confirm: true`(对应界面上那个必须勾选的框),服务端也据此兜底。 + */ + clearResults(payload: ClearResultsPayload): Promise void(id: number, payload: VoidResultPayload): Promise /** * 把一条已作废的成绩退回草稿(`POST /api/results/{id}/reopen`)。 @@ -657,6 +666,8 @@ function createResultsApi(http: Http): ResultsApi { publish: (payload) => http.request('/results/publish', { method: 'POST', body: payload }), unpublish: (payload) => http.request('/results/unpublish', { method: 'POST', body: payload }), + clearResults: (payload) => + http.request('/results/clear', { method: 'POST', body: payload }), void: (id, payload) => http.request(`/results/${id}/void`, { method: 'POST', body: payload }), reopen: (id, payload) => http.request(`/results/${id}/reopen`, { method: 'POST', body: payload }), diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index aa9f17e..fdcac46 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -455,6 +455,37 @@ export interface PublishPayload { rankOnly?: boolean } +/** + * POST /api/results/clear 的请求体:清空「当前组 / 批次」的成绩(危险操作)。 + * + * 语义:先撤销发布(已发布的收回、该范围的积分流水作废),再把成绩内容清空 + * (名次 / 成绩值 / 备注都清,回到「没录过」)。与「撤销发布」的区别是后者保留成绩内容。 + */ +export interface ClearResultsPayload { + eventId: number + /** 必填:清空以「小项 + 组别」为界,没有「一键清空整项」这条路。 */ + categoryId?: number + /** + * 作用范围:**不传** = 整个组别(含未分批的行);数字 = 只清这一批次; + * 显式传 null = 只清「未分批」的行。三种写法在服务端是三条路,所以「不传」与 null 必须分开。 + */ + heat?: number | null + /** 必须为 true = 勾选了「我知道这会清掉已发布的分数」;服务端也拿它兜底。 */ + confirm: boolean + reason?: string + requestId?: string +} + +/** POST /api/results/clear 的回执:清了多少条、其中多少条原先已发布、作废了多少条流水。 */ +export interface ClearResultsResult { + scope: 'group' | 'batch' | 'unbatched' + heat: number | null + cleared: number + published: number + voidedLedger: number + teamRanks: number +} + export interface VoidResultPayload { reason: string requestId?: string