From c5ce1878fcc1efbcac925d98153cffca974655fc Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 14 Sep 2026 22:57:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(publish):=20=E3=80=8C=E4=BB=A5=E5=86=B3?= =?UTF-8?q?=E8=B5=9B=E6=88=90=E7=BB=A9=E5=8F=91=E5=B8=83=E3=80=8D=E5=BC=80?= =?UTF-8?q?=E5=85=B3=20=E2=80=94=E2=80=94=20=E9=9D=9E=E5=86=B3=E8=B5=9B?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=8F=AA=E6=9B=B4=E6=96=B0=E5=90=8D=E6=AC=A1?= =?UTF-8?q?=E3=80=81=E4=B8=8D=E8=AE=A1=E5=88=86=EF=BC=9B=E7=89=88=E6=9C=AC?= =?UTF-8?q?=20dev=5F0.5.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 需求(主人原话) > 只有在最后一轮(决赛)的成绩才算分,你给发布按钮边上带一个选项,勾选之后**当前小项/组以决赛成绩发布**, > 不然的话就**只更新名次(非决赛) 不加分**。 **设计好处**:不引入「轮次」模型,而是**在已有的「发布」动作上加一个口径** —— 分轮次比赛时,同一批人各轮成绩各发各的, 榜单只认决赛那一轮的分。引擎本来就有一条「只排名不计分」的通路,这次是**把它在发布入口露出来**,没有另造算分分支。 ## 契约 `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` 两张)。 --- 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 +- docs/升级说明.md | 41 +- server/internal/domain/scoring.go | 16 +- server/internal/httpapi/handlers_results.go | 2 +- server/internal/httpapi/payload.go | 14 + .../httpapi/publish_rank_only_test.go | 379 ++++++++++++++++++ .../internal/httpapi/rank_only_heal_test.go | 44 ++ server/internal/httpapi/recompute_heal.go | 14 +- server/internal/httpapi/scoring.go | 56 ++- web/src/admin/pages/EntryPage.vue | 166 +++++++- web/src/admin/pages/ReviewPage.vue | 104 ++++- web/src/admin/pages/entryPublish.ts | 83 ++++ web/src/admin/tests/publish-final.test.ts | 143 +++++++ web/src/shared/types.ts | 7 + 18 files changed, 1039 insertions(+), 36 deletions(-) create mode 100644 server/internal/httpapi/publish_rank_only_test.go create mode 100644 server/internal/httpapi/rank_only_heal_test.go create mode 100644 web/src/admin/pages/entryPublish.ts create mode 100644 web/src/admin/tests/publish-final.test.ts diff --git a/VERSION b/VERSION index 3dd72cf..1ac8a0d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.5.7 +dev_0.5.8 diff --git a/console/installer/art/1x/brand.bmp b/console/installer/art/1x/brand.bmp index a0977370954a72c1854304e0cd1994c8ed153cb4..a05560d7bb85a2894dc1e5ddb375d7ede4a63a10 100644 GIT binary patch delta 457 zcmdnCT6o)P;SCZwoPOE@K;*9@u-TE(kZE#I-eevi-%FjxO_{?>oqO}d5 z0*1--(x}EbXCRsDr4Kg+XjM_y1b(1Nz~BRV3K)Dbma>}}Yk}eVzmaXSYwrY5he8Z*mdd^CAC zJA%yXubd$5sm|l4E#Rfj6JaU^lnc@q@zvt<)8+@F&8wQ!nI3lr{@?0}arxpDYR#1sdS0#NHV1QXOgQrpyj>+-7%1ZKlagO})}SV0}PEo@(4t z<}yIt{yIRDKvn?F-^wHbQ2^8vpey8|!dV~VSQq2qp~|^=c@xlxMeSXRema8QntVX% zo=m?fMd1_kLxAMGn#3?;aiCZHbp$rAZBk{LT+}rIH~$sliuS@o2`MN-TZV?`_oB`K+FWh%-f$%V!7x90ApKq>i_@% diff --git a/console/installer/art/2x/brand.bmp b/console/installer/art/2x/brand.bmp index bc3d6de8b68e1fc87e796e62ac508780b2009799..96819a7fee7cc757023e7ae3a54d04b0eabfd389 100644 GIT binary patch delta 1066 zcmYLISx*yT6wQnSZCTm^R+J?yg|@VymVHr-EM;p8QVK1lKm}POP|Ic!Wd{{hR&5Ny zNCcNiR4nlQ0iIFglTW_*q%Ufu@zFD#Mw0I&{igTKIp^Lxdt17_E#24-mAfCRSBY`7 zNkSZ*N>rIFc75Q{5@Oq@`urmCmrf}vNf4CFgQ`+OFP#q4Dutzq0&P+-L0J<+^aqrb zO5k3B(%!0!NB!pKQ=KoSudWR@k6H_isVB{6RCKHU!qPS4`-Oq^(Uyjs=n6#;OEJ*~hRl6|guFr#T$&hwvfX9plud)HM#|Sn z1sW;Op-%&G*-;ByoQmozRFqJGhiBt$u#Nyw#L=rn@Z%aVzrJg?WJmdgEUeJMH!7A& zK_ifJW1{_Gp9v~l3lI%l5aIdFJx9~ISYId$8_Mw=MAqTsLCgARD@WlnmdOIpfsOID z&58CJwM3^BlD{6x%#e_uRJ7M3y6dP+M=HBkT^!GYJc1e2l0~}#567$pWikPJ;iGLI zq8V{WGuxqid%w4>P*HtW;xy(y9cjUJu2YA6tjM8`0Ub?**qa@QUrSBf}VG!NrXL_sBw`T2*$}}PhqdOSl@~0!HM1(>2 z#~KUwR)%I;%ebW|j`zRQR)K0$W(3K` zEZOqox910j>hekw_(#b;g?MQyF)Et#r7(msL)1GnG05huNNCNplp=tS*1(YdaQI_K zjh@tJMWF%_OQd{mVzAg=l;mC<95$anQUmRHG}PoM0&US=gU0uMne9C|JMoZ;1BX*HWkzDXuz71Ynq2+ZryfjTz>vi@?g~R{6i|ryUyYp_G-Siu zilcVYrm^Kq@oqi&$fIox`ISOTsgqnS762=O11lwod{lYANVkoyV#s0|3b93)F?(Rd xcYNQiyAk_%J3WU6jsfg%KfoX00enCJ1OS0R5D*Lq0TCbuLV!?`o)bn#{{!L1JDLCh delta 972 zcmY*XOH30{6wP}Mr4(8yZG%7{Kub#tg%BYM@)0CZYFio*DWtrmAtr(*xbVyjP9(YKrkS31?z!i^^(FWEl6z{E zRBrH7?^KF-PhpDNmh6stsXiK@3f8khhT?R>C$UZys2%w#beR$Bp>B?Owuq6WOB_fv zn-fRcjy)dh>TfP~m{neWdz0v60fG7LBQcZAmLe_4U^W?*H-imtA5Dy(t!*?Zy>@N1 zg&H90;8&0{WMR1Rm1n%T z)BhwCXemnZ6!MWjL|iN_tOujF z&pF=A5BD`#`4WoNMJdl7v5G7qB2&T@haF2#_)j?UrKJV2Hg4@FH51`xTvknMiT>Gi z-(ol@<|m5GDk$=kM*Tl(Ov$O{k;lhFrd?aA$Sc zSB>r~7w%9vwqT{9xLml$D?CspWz)ZM*)*|s3SW^U$UYnD2 0)`, - ).Scan(&rankRows, &publishedRankedEvents); err != nil { + WHERE state = 'published' AND rank IS NOT NULL AND rank > 0), + (SELECT COUNT(*) FROM score_ledger WHERE source_type IN ('result', 'record_bonus'))`, + ).Scan(&rankRows, &publishedRankedEvents, &scoredLedgerRows); err != nil { return 0, false, fmt.Errorf("检查通道名次自愈条件: %w", err) } - return int(publishedRankedEvents), rankRows == 0 && publishedRankedEvents > 0, nil + return int(publishedRankedEvents), rankRows == 0 && publishedRankedEvents > 0 && scoredLedgerRows > 0, nil } diff --git a/server/internal/httpapi/scoring.go b/server/internal/httpapi/scoring.go index 1337bc3..db5c2fb 100644 --- a/server/internal/httpapi/scoring.go +++ b/server/internal/httpapi/scoring.go @@ -344,8 +344,19 @@ func loadRecordBreaksQ(ctx context.Context, q queryer, eventID int64) ([]domain. // ---------- 计分 ---------- -// computeEventScore 计算某小项(或某组别)的流水与名次明细。 +// computeEventScore 是缺省口径(计分发布)的计分,供发布之外的各条重算路径使用。 +// +// ⚠️ 口径只有**发布这一次动作**带得上(见 publishResults 的 RankOnly):本函数是「按此刻已发布 +// 的成绩重算 = 计分」,被 立即结算 / 作废成绩 / 批量退赛 / 撤销发布 / 改队员与份额 / 改积分模板 / +// 规则包导入 共用。也就是说:一批「只更新名次」发布的成绩,在这些动作里照样会被算分——它们算的 +// 本来就是「此刻 results 里已发布的成绩」,与改动前一致(改动前这些路径也一样会算)。 +// 现场若要把某一组彻底排除在计分外,正确做法是把该组退回草稿(撤销发布),而不是只靠勾选。 func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64) (domain.EventScore, error) { + return computeEventScoreOpts(ctx, q, eventID, categoryID, domain.ScoreOptions{}) +} + +// computeEventScoreOpts 计算某小项(或某组别)的流水与名次明细,并带上引擎口径。 +func computeEventScoreOpts(ctx context.Context, q queryer, eventID, categoryID int64, opts domain.ScoreOptions) (domain.EventScore, error) { ev, err := loadEventQ(ctx, q, eventID) if err != nil { return domain.EventScore{}, err @@ -403,6 +414,7 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64 RecordBreaks: breaks, // 每个组别只拿自己组别的团体名次:名次在组别内有效,串组会算出别人的分。 TeamRanks: teamRanks[cat.ID], + Options: opts, }) if err != nil { return domain.EventScore{}, mapDomainError(err) @@ -805,6 +817,21 @@ func skipNote(skipped int) string { return fmt.Sprintf("跳过 %d 条只填了成绩文本、没有成绩值的草稿(本次未上屏,补上成绩值后重新发布)", skipped) } +// publishNote 是本条发布写进修订说明的文案:只更新名次时必须留一句——榜单一点没动, +// 事后复盘(变更历史)得能看出这一批是预赛口径,而不是「发布失败」或「没算分」。 +// 计分发布照旧只带跳过提示,一个字符都不加(老行为不许多出文案)。 +func publishNote(rankOnly bool, skipped int) string { + skip := skipNote(skipped) + if !rankOnly { + return skip + } + const rankOnlyNote = "本次按「只更新名次」发布:名次已更新,未计分、未发奖牌,榜单分数与已有积分不变" + if skip == "" { + return rankOnlyNote + } + return rankOnlyNote + ";" + skip +} + // liveOnPublishQ 读「发布后是否立即结算」:缺键 / 脏值一律按开启处理,保证历史行为。 func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) { var raw string @@ -815,7 +842,7 @@ func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) { return boolDefault(raw, true), nil } -func (s *server) publishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) { +func (s *server) publishResults(ctx context.Context, in publishResultsPayload, userID int64) ([]resultDTO, error) { if in.EventID == 0 { return nil, badRequest("eventId 必填", nil) } @@ -883,14 +910,29 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i // 名次也要等结算才写:名次(results.rank 与 channel_ranks)只在这里落, // 分批写名次会让奖牌列跟着每批发布跳动,而总分来自流水、不写流水就不动, // 同一张榜上「有金牌但 0 分」自相矛盾。所以延迟期间只落成绩,名次一并等结算。 - score, err := computeEventScore(ctx, tx, in.EventID, catID) + score, err := computeEventScoreOpts(ctx, tx, in.EventID, catID, domain.ScoreOptions{RankOnly: in.RankOnly}) if err != nil { return nil, err } if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { return nil, err } - if err := writeLedgerTx(ctx, tx, score, in.EventID, catID, userID, "发布重算"); err != nil { + if in.RankOnly { + // 只更新名次:名次已写回 results.rank(上面那一步),到此为止——流水与通道名次一个 + // 字节都不动。 + // + // 为什么不顺手重建通道名次:channel_ranks 只存名次,奖牌是「rank 在前三 + 小项结算 + // 奖牌 + 通道发牌」算出来的,**不看分数**。预赛名次写进去 = 给预赛发奖牌;反过来, + // 本组上次若已按决赛计分发布过,重建会把那批名次(连同决赛的奖牌)抹掉,而决赛那份 + // 分还留在流水里 —— 榜上就会「分还在、牌没了」。 + // + // 为什么不顺手写流水:writeLedgerTx 会先把范围内的旧流水整批作废再写新的, + // 而决赛那份积分是有效的,不该被一次预赛发布抹掉。 + // + // 于是本次发布只动 results.rank:录入页的名次列、大屏最新成绩、分项成绩表读的都是它。 + // 名单/明细里「名次」关联不到 channel_ranks 时本来就回落到 results.rank(见 + // breakdownRankExpr / athleteDetailRankExpr),所以能看到的都跟着更新。 + } else if err := writeLedgerTx(ctx, tx, score, in.EventID, catID, userID, "发布重算"); err != nil { return nil, err } } @@ -917,14 +959,16 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i if err != nil { return nil, err } - if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, skipNote(skipped)); err != nil { + if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, publishNote(in.RankOnly, skipped)); err != nil { return nil, err } if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil { return nil, err } // 没结算就不推榜单:延迟期间榜单保持上一次结算的状态,大屏不该因为一批成绩而跳动。 - if settle { + // 只更新名次时榜单同样不推:分数与奖牌一个数字都没变,推一版「没变的榜单」只会让现场 + // 以为榜单跟着这批成绩动了。成绩本身的变更由上面 recordChanges 推出去,大屏据此重拉名次。 + if settle && !in.RankOnly { if err := s.bumpStandings(ctx, tx, userID); err != nil { return nil, err } diff --git a/web/src/admin/pages/EntryPage.vue b/web/src/admin/pages/EntryPage.vue index c84afcc..786eabf 100644 --- a/web/src/admin/pages/EntryPage.vue +++ b/web/src/admin/pages/EntryPage.vue @@ -119,6 +119,17 @@ import { type HeatFilter, } from './entryHeat' import { adminApi, sampleDataBlockedNotice } from '../api/client' +import { + FINAL_PUBLISH_LABEL, + FINAL_PUBLISH_TITLE, + PREVIEW_RANK_ONLY_HINT, + boardModeNote, + publishModeBadge, + publishModeConfirm, + publishModeHint, + publishedPointsOnBoard, + rankOnlyOf, +} from './entryPublish' import { computeEventScore, type ScorableRow } from '../api/scoring' import EntryPicker from '../components/baseData/EntryPicker.vue' import { entryRowLabel } from '../components/baseData/entrySelection' @@ -168,6 +179,13 @@ interface EditableRow { isTie: boolean note: string resultState: ResultState | null + /** + * 服务端给出的这一条成绩在榜上的分(= 该成绩的流水合计;草稿与没结算过的都是 null)。 + * + * 录入页只拿它判断「本组在榜上是不是已计分的成绩」(见 boardModeNote): + * 名次与分对不上时(名次变了、分还是旧的)必须让操作员看得见。 + */ + points: number | null original: string } @@ -220,6 +238,13 @@ const publishing = ref(false) const unpublishing = ref(false) const publishDialog = ref(false) const unpublishDialog = ref(false) +/** + * 「以决赛成绩发布」勾选状态,**默认不勾**(不勾 = 本次只更新名次)。 + * + * 每次发布成功后就复位成不勾:勾选是一次发布的口径,不是这个页面的设置。留着勾选状态, + * 换到别的小项再点发布就会把一批预赛成绩当成决赛算分——那是这个功能最贵的错法。 + */ +const finalPublish = ref(false) /** 分批结算状态:延迟结算开启时,当前组可能「已发布但还没结算」。 */ const settlement = ref(null) const settling = ref(false) @@ -535,6 +560,20 @@ const allPublished = computed( ) const publishedCount = computed(() => visibleRows.value.filter((row) => row.resultState === 'published').length) +/** 整个组别已发布的条数(在榜上的口径是「这一组」的事,不随批次筛选变小)。 */ +const categoryPublishedCount = computed( + () => categoryRows.value.filter((row) => row.resultState === 'published').length, +) +/** + * 本组在榜上的分(已发布成绩的流水分合计)。 + * + * 按**整个组别**(categoryRows)数,而不是当前批次筛选后的可见行:筛了某个批次去看时, + * 在榜上的口径不该跟着变小(否则会出现「本组只有名次」这种假话)。 + */ +const pointsOnBoard = computed(() => publishedPointsOnBoard(categoryRows.value)) +/** 本组在榜上的口径(预赛 / 决赛)那句话;一条都没发布过时为 null。 */ +const boardNote = computed(() => boardModeNote(categoryPublishedCount.value, pointsOnBoard.value)) + /** * 当前 (小项, 组别) 的未结算状态:非 null 才提示。 * @@ -844,12 +883,22 @@ const publishMessage = computed(() => { const scope = heatScoped.value ? '注意:发布按「小项 + 组别」整体生效,其它批次已保存的草稿也会一起发布。' : '' - // 延迟结算下「发布即重算积分榜」不再成立,确认框不能再说反话。 - const tail = - settlement.value?.liveOnPublish === false + // 「影响几个单位、最大分差多少」说的是一次计分发布的效果:勾上时照旧报这两个数, + // 不勾时报出来就是假话(这次根本不计分),换成口径说明(见 publishModeConfirm)。 + const scored = finalPublish.value + ? `本次影响 ${affectedTeams.value} 个单位,最大分差变化 ${formatPoints(maxDelta.value)} 分。` + : '' + const mode = publishModeConfirm(finalPublish.value, pointsOnBoard.value) + // 延迟结算下「发布即重算积分榜」不再成立;只更新名次时这句同样要说对。 + const deferred = settlement.value?.liveOnPublish === false + const tail = finalPublish.value + ? deferred ? '发布后成绩立即上屏;延迟结算已开启,名次与榜单等该组全部录完后再更新。' : '发布后立即上屏并重算积分榜。' - return `${head}${scope}本次影响 ${affectedTeams.value} 个单位,最大分差变化 ${formatPoints(maxDelta.value)} 分。${tail}` + : deferred + ? '发布后成绩立即上屏;延迟结算已开启,名次等该组全部录完后再更新,榜单分数不变。' + : '发布后成绩立即上屏;本次不改动榜单。' + return `${head}${scope}${scored}${mode}${tail}` }) /** 团体 / 接力行没有 athleteName(服务端 omitempty 不下发),回落到队员名单首人 / 单位。 */ @@ -933,6 +982,8 @@ function toRow(entry: EntryWithResult): EditableRow { isTie: result?.isTie ?? false, note: result?.note ?? '', resultState: result?.state ?? null, + // 老服务端不下发 points(见 attachResultPoints)时按 null = 榜上没分。 + points: result?.points ?? null, original: '', } row.original = snapshotOf(entry) @@ -1714,6 +1765,8 @@ async function confirmPublish(): Promise { publishing.value = true const published = pendingRows.value.length const missing = unrecordedRows.value.length + // 这一下的口径在按下确认时就定下来:请求字段与提示都用它,避免中途被复位带偏。 + const scoring = finalPublish.value try { // 发布作用于服务端已保存的草稿,所以必须先整组落草稿再发布(07 第 3 节)。 // 只提交未发布的行,已发布行由服务端锁定。 @@ -1725,14 +1778,24 @@ async function confirmPublish(): Promise { await adminApi.publish({ eventId: selectedEventId.value, categoryId: selectedCategoryId.value ?? undefined, + rankOnly: rankOnlyOf(finalPublish.value), }) publishDialog.value = false + // 勾选是一次发布的口径,不是页面设置:发完就复位成「只更新名次」, + // 免得换个小项再点发布时把预赛成绩当成决赛算分。 + finalPublish.value = false await reloadAfterSettlementChange() // 延迟结算下这次发布可能只落成绩:提示要如实说清楚,别让现场以为榜单已经跟着变了。 const deferred = settlement.value?.liveOnPublish === false const leftover = missing > 0 ? `本次发布 ${published} 人;其余 ${missing} 人保持未发布,补录后再次发布即可` : null - if (deferred && pendingSettlement.value !== null) { + if (!scoring) { + // 只更新名次:这句话是现场唯一的回执,不能套用「积分榜已更新」。 + toast.success( + '已发布本批成绩(只更新名次)', + `名次已更新;本次不计分、不发奖牌,榜单分数不变${leftover === null ? '' : `。${leftover}`}`, + ) + } else if (deferred && pendingSettlement.value !== null) { toast.success('已发布本批成绩', `延迟结算:名次与榜单等该组全部录完后再更新${leftover === null ? '' : `;${leftover}`}`) } else { toast.success('发布成功,积分榜已更新', leftover) @@ -2323,6 +2386,28 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i