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
@@ -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<SettlementStatus | null>(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<void> {
|
||||
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<void> {
|
||||
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
|
||||
<PageHeader title="成绩录入" :subtitle="selectedEvent?.name ?? '选择左侧项目开始录入'">
|
||||
<template #actions>
|
||||
<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
|
||||
class="mx-btn mx-btn-secondary"
|
||||
type="button"
|
||||
@@ -2336,7 +2421,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
class="mx-btn mx-btn-primary"
|
||||
type="button"
|
||||
:disabled="!canWrite || publishing || data.isMock || pendingRows.length === 0"
|
||||
:title="pendingRows.length === 0 ? '没有可发布的成绩:未录行会被自动跳过' : ''"
|
||||
:title="pendingRows.length === 0 ? '没有可发布的成绩:未录行会被自动跳过' : publishModeHint(finalPublish)"
|
||||
@click="requestPublish"
|
||||
>
|
||||
<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">
|
||||
<MxIcon name="lock" :size="15" />
|
||||
{{ scopeWord }}已有 {{ publishedCount }} 条成绩发布并锁定上屏(共 {{ visibleRows.length }} 条):未发布的行可继续补录,
|
||||
录完再点一次「发布本批」即可,名次与积分会按已发布成绩整体重算。
|
||||
录完再点一次「发布本批」即可;勾选「以决赛成绩发布」时名次与积分按已发布成绩整体重算。
|
||||
要改某一条已发布的成绩,直接点那一格(或右侧「修改本条」)退回草稿,改完再重新发布本组。
|
||||
</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="mx-card table-card">
|
||||
<div ref="tableRef" class="mx-table-wrap">
|
||||
@@ -2917,6 +3016,13 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
<span>{{ scoreConfigError.message }}</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!--
|
||||
预览永远按「计分发布」算分:不勾「以决赛成绩发布」时这些分差这次不会发生,
|
||||
卡片里必须说破,否则操作员会以为按了发布就涨了这些分。
|
||||
-->
|
||||
<p v-if="!finalPublish" class="preview-hint preview-rank-only-hint">
|
||||
{{ PREVIEW_RANK_ONLY_HINT }}
|
||||
</p>
|
||||
<p class="preview-hint">
|
||||
若现在发布,各单位积分变化(最大分差 <strong>{{ formatPoints(maxDelta) }}</strong> 分)
|
||||
</p>
|
||||
@@ -3064,7 +3170,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
<ConfirmDialog
|
||||
:open="publishDialog"
|
||||
tone="warning"
|
||||
title="确认发布该组成绩?"
|
||||
:title="finalPublish ? '确认按决赛成绩发布该组?' : '确认发布该组(只更新名次)?'"
|
||||
:message="publishMessage"
|
||||
:details="warnings.map((issue) => issue.message)"
|
||||
confirm-text="确认发布"
|
||||
@@ -3682,6 +3788,46 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
color: var(--mx-green);
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
@@ -4048,6 +4194,10 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
.preview-rank-dirty {
|
||||
color: var(--mx-amber);
|
||||
}
|
||||
/* 只更新名次时预览里的分差这次不会发生,用警示色标出来。 */
|
||||
.preview-rank-only-hint {
|
||||
color: var(--mx-amber);
|
||||
}
|
||||
.preview-card {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
@@ -25,6 +25,14 @@ import { adminApi } from '../api/client'
|
||||
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
|
||||
import { liveOnPublishOf } from '../components/system/systemApi'
|
||||
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 { useEventStore } from '../stores/event'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
@@ -63,6 +71,14 @@ interface DraftGroup {
|
||||
deltas: TeamDelta[]
|
||||
affectedTeams: number
|
||||
maxDelta: number
|
||||
/**
|
||||
* 本组**已经发布**的成绩在榜上的分(服务端给出的流水合计),不是本地算出来的预览值。
|
||||
*
|
||||
* 发布确认框靠它把「只更新名次」的后果说清楚:本组若已按决赛发布过,那份分与奖牌不会被
|
||||
* 这次发布改动(名次会变、分还是旧的)。本地引擎算不出这件事——上一次可能只更新了名次,
|
||||
* 那种成绩在本地照样算得出分,但榜上其实一分没有。
|
||||
*/
|
||||
pointsOnBoard: number
|
||||
}
|
||||
|
||||
interface TeamDelta {
|
||||
@@ -85,6 +101,14 @@ const compareMode = ref<'group' | 'row'>('group')
|
||||
const publishTarget = ref<DraftGroup | null>(null)
|
||||
const publishing = ref(false)
|
||||
const returnTarget = ref<DraftGroup | null>(null)
|
||||
/**
|
||||
* 「以决赛成绩发布」勾选状态,**默认不勾**(不勾 = 本次只更新名次)。
|
||||
*
|
||||
* 与录入页同一口径、同一个模块:两个发布入口都能表达「这一下是预赛还是决赛」,
|
||||
* 否则从审核页发布就成了绕开这条规则的暗门(预赛成绩会被悄悄算分)。
|
||||
* 发布成功后复位成不勾——勾选是一次发布的口径,不是页面设置。
|
||||
*/
|
||||
const finalPublish = ref(false)
|
||||
/** 退回没有服务端接口(05 §7 未定义),只能本地标记,界面必须说清楚这一点。 */
|
||||
const returnedKeys = ref<string[]>([])
|
||||
|
||||
@@ -320,6 +344,10 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
|
||||
deltas,
|
||||
affectedTeams: deltas.length,
|
||||
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
|
||||
}
|
||||
|
||||
/** 发布确认框正文:延迟结算下不能说「发布即重算积分榜」。 */
|
||||
/** 发布确认框正文:延迟结算下不能说「发布即重算积分榜」;口径与录入页同源。 */
|
||||
const publishMessage = computed(() => {
|
||||
const group = publishTarget.value
|
||||
if (group === null) return ''
|
||||
@@ -411,22 +439,53 @@ const publishMessage = computed(() => {
|
||||
skippedCount(group) > 0
|
||||
? `本组还有 ${skippedCount(group)} 条没有成绩,本次将发布已填的 ${publishableCount(group)} 条;其余保持未发布,补录后再发布即可。`
|
||||
: `本次将发布该组全部 ${publishableCount(group)} 条。`
|
||||
const tail = deferredSettlement.value
|
||||
? '发布后成绩立即上屏;延迟结算已开启,名次与榜单等该组全部录完后才更新。'
|
||||
: '发布后立即上屏并重算积分榜。'
|
||||
return `${scope}本次影响 ${group.affectedTeams} 个单位,最大分差 ${formatPoints(group.maxDelta)} 分。${tail}`
|
||||
// 「影响几个单位、最大分差多少」是一次计分发布的效果:勾上时才报(不勾时报出来是假话)。
|
||||
const scored = finalPublish.value
|
||||
? `本次影响 ${group.affectedTeams} 个单位,最大分差 ${formatPoints(group.maxDelta)} 分。`
|
||||
: ''
|
||||
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> {
|
||||
const group = publishTarget.value
|
||||
if (group === null) return
|
||||
publishing.value = true
|
||||
// 口径在按下确认时定下来:请求字段与提示都用它。
|
||||
const scoring = finalPublish.value
|
||||
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
|
||||
// 勾选不留在页面上:发完复位成「只更新名次」,免得下一组被当成决赛算分。
|
||||
finalPublish.value = false
|
||||
await load()
|
||||
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} 全组录完后才更新名次与榜单`)
|
||||
} else {
|
||||
toast.success('发布成功,积分榜已更新', `${group.eventName} · ${group.categoryName}`)
|
||||
@@ -576,6 +635,22 @@ watch(
|
||||
>
|
||||
<MxIcon name="logout" :size="14" /> 退回
|
||||
</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
|
||||
class="mx-btn mx-btn-primary"
|
||||
type="button"
|
||||
@@ -714,9 +789,9 @@ watch(
|
||||
<ConfirmDialog
|
||||
:open="publishTarget !== null"
|
||||
tone="warning"
|
||||
title="确认发布该组成绩?"
|
||||
:title="finalPublish ? '确认按决赛成绩发布该组?' : '确认发布该组(只更新名次)?'"
|
||||
:message="publishMessage"
|
||||
:details="publishTarget === null ? [] : publishTarget.deltas.slice(0, 8).map((item) => `${item.name}:${item.delta > 0 ? '+' : ''}${formatPoints(item.delta)} 分`)"
|
||||
:details="publishImpactDetails"
|
||||
confirm-text="确认发布"
|
||||
:pending="publishing"
|
||||
@confirm="confirmPublish"
|
||||
@@ -825,8 +900,19 @@ watch(
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
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 {
|
||||
display: flex;
|
||||
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
|
||||
categoryId?: number
|
||||
requestId?: string
|
||||
/**
|
||||
* 只更新名次:发布成绩并写回名次,但不计分、不发奖牌、不动榜单分数。
|
||||
*
|
||||
* 缺省 / false = 计分发布(与改动前逐字节一致)。撤销发布 / 立即结算不吃这个字段
|
||||
* (它们的请求体是同一形状,服务端只从发布接口读它)。
|
||||
*/
|
||||
rankOnly?: boolean
|
||||
}
|
||||
|
||||
export interface VoidResultPayload {
|
||||
|
||||
Reference in new issue
Block a user