feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项
## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修) - 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同** (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致 - 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到 - 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢); 另加守卫「compare=none 的小项不得配 basis=result/aggregate」 - 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0 (7 人三人并列第 5 时第 5、6 名的分也一起没了) - 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分 ## 现场反馈(主人实测报的) - 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组), 与「作废」语义分开;取消路径不产生任何副作用 - 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」: 管理端、大屏覆盖层、xlsx 备注列三处同源 - 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦 - 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响) ## 新功能 - 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切) ## 安装器 - 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、 安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540 - 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败 自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。 端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
This commit is contained in:
1 parent
78b14efaa9
commit
877ff15823
181 files changed
+8747
-371
No files matched your search
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
eventInputOf,
|
||||
@@ -55,7 +55,10 @@ import {
|
||||
RESULT_ROW_ACTION_TITLE,
|
||||
RESULT_ROW_ACTION_WORD,
|
||||
resultActionTarget,
|
||||
resultActionTargets,
|
||||
resultCellAction,
|
||||
type ResultActionTarget,
|
||||
type ResultRowAction,
|
||||
} from './entryResultActions'
|
||||
import {
|
||||
DEFAULT_HEAT_SIZE,
|
||||
@@ -125,6 +128,17 @@ const STATUS_OPTIONS: Array<{ value: ResultStatus; label: string }> = [
|
||||
{ value: 'withdrawn', label: '退赛' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 成绩状态文案(下拉选项的反查表)。
|
||||
*
|
||||
* 已发布的行不再渲染下拉(disabled 的表单控件收不到点击,正是「点不动」的来源),
|
||||
* 改用只读按钮显示当前状态,文案必须与下拉选项同源,免得两处各写一份慢慢说岔。
|
||||
*/
|
||||
const STATUS_LABEL = Object.fromEntries(STATUS_OPTIONS.map((option) => [option.value, option.label])) as Record<
|
||||
ResultStatus,
|
||||
string
|
||||
>
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const data = useEventStore()
|
||||
@@ -459,37 +473,49 @@ onBeforeRouteLeave((to) => {
|
||||
return false
|
||||
})
|
||||
|
||||
/** 已发布的行由服务端锁定:只能先撤销发布,不能就地改草稿。 */
|
||||
/**
|
||||
* 已发布的行由服务端锁定:直接改会让「榜单上是旧数、表格里是新数」。
|
||||
* 退回草稿(reopen)后 state 变回 draft,这里随即放行——「修改本条」正是靠这一步解锁。
|
||||
*/
|
||||
function rowLocked(row: EditableRow): boolean {
|
||||
return row.resultState === 'published'
|
||||
}
|
||||
|
||||
/**
|
||||
* 每行的行操作(作废本条 / 重新录入):判定在 entryResultActions 的纯函数里。
|
||||
* 已发布(含已作废)行的锁定提示:这些格子改不了时,悬停要直接指出出路。
|
||||
*
|
||||
* `clickable` 指**这一格自己点得动**(成绩格 / 状态格:点一下弹退回草稿的确认框)——
|
||||
* 名次 / 备注格仍不参与点击,只能说「点成绩格或右侧『修改』」,
|
||||
* 否则提示就变成一句点不出来的空头承诺。
|
||||
*
|
||||
* 已发布行的出路有两条——退回这一条草稿,或整组「撤销发布」;已作废行的出路只有一条——「重新录入」。
|
||||
*/
|
||||
function lockedHint(row: EditableRow, clickable = false): string {
|
||||
if (!rowLocked(row)) return ''
|
||||
if (row.status === 'withdrawn') {
|
||||
return '本条已作废:不计入名次与积分,用「重新录入」退回草稿后才能修改'
|
||||
}
|
||||
return clickable
|
||||
? '已发布:点击这一格可退回草稿修改,改完需重新发布本组;也可以点右侧「修改」或整组「撤销发布」'
|
||||
: '已发布:点成绩格或右侧「修改」退回草稿后可改,改完需重新发布本组'
|
||||
}
|
||||
|
||||
/**
|
||||
* 每行的行操作(修改本条 / 作废本条 / 重新录入):判定在 entryResultActions 的纯函数里。
|
||||
*
|
||||
* 与 judgements 同口径——按当前可见行算,筛选后看不见的行不会残留按钮。
|
||||
*/
|
||||
const rowActions = computed<Map<number, ResultActionTarget>>(() => {
|
||||
const map = new Map<number, ResultActionTarget>()
|
||||
const rowActions = computed<Map<number, ResultActionTarget[]>>(() => {
|
||||
const map = new Map<number, ResultActionTarget[]>()
|
||||
for (const row of visibleRows.value) {
|
||||
const target = resultActionTarget(row)
|
||||
if (target !== null) map.set(row.entryId, target)
|
||||
const targets = resultActionTargets(row)
|
||||
if (targets.length > 0) map.set(row.entryId, targets)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function rowAction(row: EditableRow): ResultActionTarget | null {
|
||||
return rowActions.value.get(row.entryId) ?? null
|
||||
}
|
||||
|
||||
function rowActionLabel(row: EditableRow): string {
|
||||
const target = rowAction(row)
|
||||
return target === null ? '' : RESULT_ROW_ACTION_LABEL[target.action]
|
||||
}
|
||||
|
||||
function rowActionTitle(row: EditableRow): string {
|
||||
const target = rowAction(row)
|
||||
return target === null ? '' : RESULT_ROW_ACTION_TITLE[target.action]
|
||||
function rowActionsOf(row: EditableRow): ResultActionTarget[] {
|
||||
return rowActions.value.get(row.entryId) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -545,10 +571,9 @@ const focusedHint = computed<ResultCellHint | null>(() => {
|
||||
/** 失焦后由既有的红框 + title 承载原因;合法成绩不写 title,避免无意义悬浮。 */
|
||||
function resultTitle(row: EditableRow): string {
|
||||
if (rowLocked(row)) {
|
||||
// 已作废的行同样是「锁定」的,但原因是作废而不是发布:提示要指向真正的出路。
|
||||
return row.status === 'withdrawn'
|
||||
? '本条已作废:不计入名次与积分,用「重新录入」退回草稿后才能修改'
|
||||
: '已发布,请先撤销发布'
|
||||
// 已发布 / 已作废的行都锁着,但出路不同:统一由 lockedHint 说清该点哪个按钮。
|
||||
// 成绩格自己点得动,用 clickable 那一份文案(「点击这一格」)。
|
||||
return lockedHint(row, true)
|
||||
}
|
||||
const hint = resultHintOf(row)
|
||||
return hint !== null && hint.level !== 'ok' ? hint.message : ''
|
||||
@@ -1395,24 +1420,27 @@ const BATCH_CONFIRM_TEXT: Record<EntryBatchAction, string> = {
|
||||
remove: '确认移出',
|
||||
}
|
||||
|
||||
// ── 单条作废 / 重新录入(A16:外科手术式改一条,不必整组撤销发布) ──────────────
|
||||
// ── 单条修改 / 作废 / 重新录入(A16:外科手术式改一条,不必整组撤销发布) ──────────
|
||||
|
||||
/** 待确认的行操作;非 null 时确认框打开。 */
|
||||
const resultAction = ref<ResultActionTarget | null>(null)
|
||||
const resultActionBusy = ref(false)
|
||||
|
||||
/** 确认框文案随动作变(作废 / 退回草稿的后果不同)。 */
|
||||
/** 确认框文案随动作变(修改 / 作废 / 退回草稿的后果不同)。 */
|
||||
const resultActionCopy = computed(() => RESULT_ACTION_CONFIRM[resultAction.value?.action ?? 'void'])
|
||||
|
||||
/**
|
||||
* 点行操作按钮:先做前置检查(示例数据 / 只读 / 未保存修改),再弹确认框。
|
||||
*
|
||||
* 一个按钮一次只带一个动作(同一行可能并排着「修改本条」与「作废本条」),
|
||||
* 判定仍由纯函数给出:传进来的动作必须在该行成立,否则当没点。
|
||||
*
|
||||
* 与批量操作同一口径——它会重算并刷新整表,未保存的编辑必须先落草稿,
|
||||
* 否则刚填的名次会被服务端返回的口径覆盖掉。
|
||||
*/
|
||||
function requestResultAction(row: EditableRow): void {
|
||||
const target = rowAction(row)
|
||||
if (target === null) return
|
||||
function requestResultAction(target: ResultActionTarget): void {
|
||||
const current = rowActions.value.get(target.entryId)
|
||||
if (current === undefined || !current.some((item) => item.action === target.action)) return
|
||||
if (data.isMock) {
|
||||
// 示例数据模式下写接口没有后端可落库,提示成功等于谎报(与发布 / 批量操作同口径)。
|
||||
const notice = sampleDataBlockedNotice(RESULT_ROW_ACTION_WORD[target.action])
|
||||
@@ -1424,12 +1452,41 @@ function requestResultAction(row: EditableRow): void {
|
||||
return
|
||||
}
|
||||
if (dirty.value) {
|
||||
toast.error('有未保存的修改', '作废 / 退回会重算并刷新整表,请先「存草稿」/「保存团体名次」再操作')
|
||||
toast.error('有未保存的修改', '修改 / 作废 / 退回会重算并刷新整表,请先「存草稿」/「保存团体名次」再操作')
|
||||
return
|
||||
}
|
||||
resultAction.value = target
|
||||
}
|
||||
|
||||
/** 已发布行的格子点一下走哪个动作;草稿行 / 已作废行返回 null(照常直接编辑 / 保持禁用)。 */
|
||||
function cellAction(row: EditableRow): ResultRowAction | null {
|
||||
return resultCellAction(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一格是否保持原来的 disabled 锁定(收不到点击)。
|
||||
*
|
||||
* 已作废的行走这里:它的出路是按钮上的「重新录入」,格子不该装成点得动。
|
||||
* 可点的已发布格子改用 readonly——disabled 的控件连 click 事件都不会派发,弹不出确认框。
|
||||
*/
|
||||
function cellDisabled(row: EditableRow): boolean {
|
||||
return rowLocked(row) && cellAction(row) === null
|
||||
}
|
||||
|
||||
/**
|
||||
* 点已发布行的成绩格 / 状态格 = 点「修改本条」。
|
||||
*
|
||||
* 必须复用 requestResultAction 这一条路:确认框(含原因必填)、示例数据 / 只读 / 未保存修改
|
||||
* 三道拦截、确认后整表重拉都在里面。格子这里绝不能自己调接口——否则就成了
|
||||
* 「点一下成绩就悄悄从榜上退下来」。用户取消时也什么都回不去(见 cancelResultAction)。
|
||||
*/
|
||||
function requestCellAction(row: EditableRow): void {
|
||||
const action = cellAction(row)
|
||||
if (action === null) return
|
||||
const target = resultActionTarget(row, action)
|
||||
if (target !== null) requestResultAction(target)
|
||||
}
|
||||
|
||||
/** 确认框回传的原因;空原因由 ConfirmDialog 拦下,这里拿到的一定是填好的。 */
|
||||
async function confirmResultAction(reason: string): Promise<void> {
|
||||
const target = resultAction.value
|
||||
@@ -1439,25 +1496,48 @@ async function confirmResultAction(reason: string): Promise<void> {
|
||||
if (target.action === 'void') {
|
||||
await adminApi.voidResult(target.resultId, { reason })
|
||||
} else {
|
||||
// 「修改本条」与「重新录入」是同一个接口:两者都把这一条退回草稿,只是入口语境不同。
|
||||
await adminApi.reopenResult(target.resultId, { reason })
|
||||
}
|
||||
resultAction.value = null
|
||||
// 名次 / 积分 / 大屏都由服务端重算:整表 + 榜单一起重拉,不在本地行上打补丁。
|
||||
await reloadAfterSettlementChange()
|
||||
// 退回草稿后把焦点送回那一格,操作员不必再拿鼠标找一遍就能接着改;
|
||||
// 作废没有可改的格子(它的出路是「重新录入」),不聚焦。
|
||||
if (target.action !== 'void') await focusResultCell(target.entryId)
|
||||
const success = RESULT_ACTION_SUCCESS[target.action]
|
||||
toast.success(success.title, success.detail)
|
||||
} catch (caught) {
|
||||
toast.error(target.action === 'void' ? '作废失败' : '退回草稿失败', caught instanceof Error ? caught.message : null)
|
||||
toast.error(`${RESULT_ROW_ACTION_WORD[target.action]}失败`, caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
resultActionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消确认框:什么都不做。
|
||||
*
|
||||
* 这里只能清掉待办的动作——退回草稿的接口一次都不能提前发出去,
|
||||
* 否则「点一下格子又点了取消」就会把一条已发布成绩悄悄从榜上退下来。
|
||||
*/
|
||||
function cancelResultAction(): void {
|
||||
if (resultActionBusy.value) return
|
||||
resultAction.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 把焦点落到某一行的成绩输入格(退回草稿成功后调用)。
|
||||
*
|
||||
* 必须等 nextTick:整表刚被 reload 换过一轮,DOM 还没更新完,这时候查不到新那一格的节点。
|
||||
*/
|
||||
async function focusResultCell(entryId: number): Promise<void> {
|
||||
await nextTick()
|
||||
const input = tableRef.value?.querySelector<HTMLInputElement>(`[data-result-cell="${entryId}"]`)
|
||||
if (input === null || input === undefined) return
|
||||
input.focus()
|
||||
input.select()
|
||||
}
|
||||
|
||||
/** 一键均分:先亮明「动哪些人、怎么切、编到第几批」,确认后才提交。 */
|
||||
function requestSplit(): void {
|
||||
const plan = splitPlan.value
|
||||
@@ -1529,6 +1609,10 @@ function onBeforeUnload(event: BeforeUnloadEvent): void {
|
||||
function onPaste(event: ClipboardEvent, startEntryId: number): void {
|
||||
const text = event.clipboardData?.getData('text/plain') ?? ''
|
||||
if (text.trim() === '') return
|
||||
// 已发布的行现在是 readonly:浏览器照样会派发 paste 事件(只是不插入文本),
|
||||
// 放过去的话这一条成绩会被本地悄悄改掉、连确认框都不弹,必须在这里挡住。
|
||||
const start = visibleRows.value.find((row) => row.entryId === startEntryId)
|
||||
if (start === undefined || rowLocked(start)) return
|
||||
event.preventDefault()
|
||||
|
||||
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '')
|
||||
@@ -1891,6 +1975,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
<MxIcon name="lock" :size="15" />
|
||||
{{ scopeWord }}已有 {{ publishedCount }} 条成绩发布并锁定上屏(共 {{ visibleRows.length }} 条):未发布的行可继续补录,
|
||||
录完再点一次「发布本批」即可,名次与积分会按已发布成绩整体重算。
|
||||
要改某一条已发布的成绩,直接点那一格(或右侧「修改本条」)退回草稿,改完再重新发布本组。
|
||||
</div>
|
||||
|
||||
<div class="entry-body">
|
||||
@@ -1972,14 +2057,22 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
<!-- 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。 -->
|
||||
<td>{{ athleteCellName(row) }} <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span></td>
|
||||
<td class="result-cell">
|
||||
<!--
|
||||
已发布的行不再用 :disabled 锁(disabled 的表单控件收不到点击,
|
||||
「点不动」正是现场反馈的观感),改成 readonly + 点一下弹确认框:
|
||||
确认后才退回草稿,取消则什么都不会发生。
|
||||
-->
|
||||
<input
|
||||
v-model="row.resultText"
|
||||
data-cell
|
||||
:data-result-cell="row.entryId"
|
||||
class="mx-cell-input mono result-input"
|
||||
:class="{ 'is-invalid': issueFor(row.entryId)?.level === 'error' }"
|
||||
:disabled="!canWrite || rowLocked(row)"
|
||||
:class="{ 'is-invalid': issueFor(row.entryId)?.level === 'error', 'is-locked-cell': cellAction(row) !== null }"
|
||||
:disabled="!canWrite || cellDisabled(row)"
|
||||
:readonly="cellAction(row) !== null"
|
||||
:title="resultTitle(row)"
|
||||
:placeholder="resultPlaceholder"
|
||||
@click="requestCellAction(row)"
|
||||
@focus="onResultFocus(row, $event)"
|
||||
@blur="onResultBlur"
|
||||
@input="onResultInput"
|
||||
@@ -2007,7 +2100,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
min="1"
|
||||
class="mx-cell-input rank-input"
|
||||
:disabled="!canWrite || rowLocked(row)"
|
||||
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
|
||||
:title="lockedHint(row)"
|
||||
@input="row.rank = rankInputValue($event)"
|
||||
@focus="focusedEntryId = row.entryId"
|
||||
@blur="focusedEntryId = null"
|
||||
@@ -2022,12 +2115,30 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
:class="row.entryStatus === 'scratched' ? 'mx-badge-warning' : 'mx-badge-error'"
|
||||
:title="entryStatusTitle(row.entryStatus)"
|
||||
>{{ ENTRY_STATUS_LABEL[row.entryStatus] }}</span>
|
||||
<!--
|
||||
已发布(未作废)的行不渲染下拉:下拉在锁定态只能 disabled,而 disabled 控件
|
||||
收不到点击。换成一个可点的只读按钮,点一下与「修改本条」同一条路
|
||||
(确认后才退回草稿);已作废的行仍走下面那个 disabled 下拉,保持现状。
|
||||
-->
|
||||
<button
|
||||
v-if="cellAction(row) !== null"
|
||||
type="button"
|
||||
data-cell
|
||||
class="mx-cell-input status-select is-locked-cell"
|
||||
:disabled="!canWrite"
|
||||
:title="lockedHint(row, true)"
|
||||
@click="requestCellAction(row)"
|
||||
@focus="focusedEntryId = row.entryId"
|
||||
@blur="focusedEntryId = null"
|
||||
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
|
||||
>{{ STATUS_LABEL[row.status] }}</button>
|
||||
<select
|
||||
v-else
|
||||
v-model="row.status"
|
||||
data-cell
|
||||
class="mx-cell-input status-select"
|
||||
:disabled="!canWrite || rowLocked(row)"
|
||||
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
|
||||
:title="lockedHint(row)"
|
||||
@focus="focusedEntryId = row.entryId"
|
||||
@blur="focusedEntryId = null"
|
||||
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
|
||||
@@ -2048,7 +2159,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
data-cell
|
||||
class="mx-cell-input"
|
||||
:disabled="!canWrite || rowLocked(row)"
|
||||
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
|
||||
:title="lockedHint(row)"
|
||||
placeholder="可留空"
|
||||
@focus="focusedEntryId = row.entryId"
|
||||
@blur="focusedEntryId = null"
|
||||
@@ -2057,21 +2168,24 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
</td>
|
||||
<!--
|
||||
行操作区:只对已发布的行出现(草稿行本来就能直接改)。
|
||||
guest / 断线只读时按钮保留但置灰,让访客看得到「这里能作废」而不是一片空白。
|
||||
正常已发布的行并排两个按钮——「修改本条」(退回草稿改一改,改完重新发布)与
|
||||
「作废本条」(这一条不算了);已作废的行只有一个「重新录入」。
|
||||
guest / 断线只读时按钮保留但置灰,让访客看得到「这里能改 / 能作废」而不是一片空白。
|
||||
-->
|
||||
<td class="center action-cell">
|
||||
<button
|
||||
v-if="rowAction(row) !== null"
|
||||
class="mx-btn mx-btn-sm"
|
||||
:class="rowAction(row)?.action === 'reopen' ? 'mx-btn-secondary' : 'mx-btn-danger'"
|
||||
v-for="target in rowActionsOf(row)"
|
||||
:key="target.action"
|
||||
class="mx-btn mx-btn-sm action-btn"
|
||||
:class="target.action === 'void' ? 'mx-btn-danger' : 'mx-btn-secondary'"
|
||||
type="button"
|
||||
:disabled="!canWrite || resultActionBusy"
|
||||
:title="rowActionTitle(row)"
|
||||
@click="requestResultAction(row)"
|
||||
:title="RESULT_ROW_ACTION_TITLE[target.action]"
|
||||
@click="requestResultAction(target)"
|
||||
>
|
||||
{{ rowActionLabel(row) }}
|
||||
{{ RESULT_ROW_ACTION_LABEL[target.action] }}
|
||||
</button>
|
||||
<span v-else class="muted">—</span>
|
||||
<span v-if="rowActionsOf(row).length === 0" class="muted">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && visibleRows.length === 0">
|
||||
@@ -2584,14 +2698,21 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
.team-cell {
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 行操作区:窄列、不换行,按钮别把整张表撑宽。 */
|
||||
/*
|
||||
* 行操作区:已发布的行要并排放「修改本条」+「作废本条」两个按钮,
|
||||
* 列宽按两者之和留(4 字 ×12px + 内边距 + 单元格 padding),否则按钮会换行把这一行撑高一倍。
|
||||
* 按钮内边距收窄(12 → 10px)是为了把列宽压住,不与上面的列宽估算脱节。
|
||||
*/
|
||||
.action-col,
|
||||
.action-cell {
|
||||
width: 96px;
|
||||
width: 180px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.action-cell .mx-btn {
|
||||
width: 100%;
|
||||
.action-cell .action-btn {
|
||||
padding: 0 10px;
|
||||
}
|
||||
.action-cell .action-btn + .action-btn {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.action-cell .muted {
|
||||
color: var(--mx-t3);
|
||||
@@ -2732,6 +2853,25 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
.status-select {
|
||||
width: 110px;
|
||||
}
|
||||
/*
|
||||
* 已发布行的可点格子(成绩格 / 状态格):从「彻底禁用」改成「点一下退回草稿改」。
|
||||
* 底色与草稿格(--mx-s3)不同 + 常态描边 + cursor: pointer,一眼看出这里点得动;
|
||||
* 点下去只是弹与「修改本条」同一个确认框,不确认则什么都不会发生。
|
||||
* 已作废行的格子 cellAction 为 null:拿不到这个类,也仍走 disabled(保持原来的禁用观感)。
|
||||
*/
|
||||
.is-locked-cell {
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
background: var(--mx-s2);
|
||||
border-color: var(--mx-border);
|
||||
}
|
||||
.is-locked-cell:hover:not(:disabled) {
|
||||
border-color: var(--mx-primary);
|
||||
box-shadow: 0 0 0 3px var(--mx-primary-glow);
|
||||
}
|
||||
.is-locked-cell:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.rank-auto {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatDateTime, formatPoints, formatRankChange, type TeamBreakdown } from '@/shared'
|
||||
import {
|
||||
breakdownAthleteName,
|
||||
breakdownCategoryName,
|
||||
breakdownRevisionBadge,
|
||||
breakdownRevisionTitle,
|
||||
breakdownSourceLabel,
|
||||
formatDateTime,
|
||||
formatPoints,
|
||||
formatRankChange,
|
||||
medalBoardRows,
|
||||
type TeamBreakdown,
|
||||
} from '@/shared'
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
@@ -42,6 +53,9 @@ const filteredStandings = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// 单位奖牌榜有自己的一套名次与顺序(金→银→铜→单位编码,服务端 medalRank),不再跟着总分榜走。
|
||||
const medalBoard = computed(() => medalBoardRows(filteredStandings.value))
|
||||
|
||||
const filteredAthletes = computed(() => {
|
||||
const keyword = search.value.trim()
|
||||
if (keyword === '') return data.athleteStandings
|
||||
@@ -289,8 +303,8 @@ async function refresh(): Promise<void> {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in filteredStandings" :key="row.teamId">
|
||||
<td><span class="rank-badge" :class="{ top: row.rank <= 3 }">{{ row.rank }}</span></td>
|
||||
<tr v-for="row in medalBoard" :key="row.teamId">
|
||||
<td><span class="rank-badge" :class="{ top: row.medalRank <= 3 }">{{ row.medalRank }}</span></td>
|
||||
<td>{{ row.teamName }}</td>
|
||||
<td class="center mono gold">{{ row.gold }}</td>
|
||||
<td class="center mono silver">{{ row.silver }}</td>
|
||||
@@ -377,15 +391,40 @@ async function refresh(): Promise<void> {
|
||||
<span>合计</span>
|
||||
<strong class="mono">{{ formatPoints(activeBreakdown.totalPoints) }}</strong>
|
||||
</div>
|
||||
<!-- 列头与「推送到大屏」的覆盖层同一套列:项目/组别 · 运动员 · 来源 · 成绩 · 名次 · 积分 -->
|
||||
<div v-if="activeBreakdown.items.length > 0" class="detail-item detail-head">
|
||||
<span>项目 / 组别</span>
|
||||
<span>运动员</span>
|
||||
<span>来源</span>
|
||||
<span>成绩</span>
|
||||
<span>名次</span>
|
||||
<span>积分</span>
|
||||
</div>
|
||||
<ul class="detail-list">
|
||||
<li v-for="item in activeBreakdown.items" :key="item.ledgerId" class="detail-item">
|
||||
<span class="detail-main">
|
||||
<strong>{{ item.eventName ?? '—' }}</strong>
|
||||
<small>{{ item.categoryName ?? '' }} · {{ item.athleteName ?? '团体' }}</small>
|
||||
<strong :title="item.eventName ?? ''">{{ item.eventName ?? '—' }}</strong>
|
||||
<small>{{ breakdownCategoryName(item) }}</small>
|
||||
</span>
|
||||
<span class="detail-athlete" :title="breakdownAthleteName(item, activeBreakdown.teamName)">
|
||||
{{ breakdownAthleteName(item, activeBreakdown.teamName) }}
|
||||
</span>
|
||||
<!-- 来源与导出的「来源」列同一套词(成绩 / 破纪录加分 / 人工调整),见 breakdownSourceLabel -->
|
||||
<span class="detail-source">{{ breakdownSourceLabel(item) }}</span>
|
||||
<span class="detail-result mono">{{ item.resultText ?? '—' }}</span>
|
||||
<span class="detail-rank mono">第 {{ item.rank ?? '—' }} 名</span>
|
||||
<span class="detail-points mono">+{{ formatPoints(item.points) }}</span>
|
||||
<!--
|
||||
行尾角标:只有真被改过的成绩才有(没改过的行连元素都没有)。
|
||||
屏幕放不下的时间 / 原因 / 操作人全在悬停里,见 breakdownRevisionTitle。
|
||||
-->
|
||||
<span
|
||||
v-if="breakdownRevisionBadge(item)"
|
||||
class="detail-revision"
|
||||
:title="breakdownRevisionTitle(item)"
|
||||
>
|
||||
{{ breakdownRevisionBadge(item) }}
|
||||
</span>
|
||||
</li>
|
||||
<li v-if="activeBreakdown.items.length === 0" class="detail-empty">暂无已发布的得分记录</li>
|
||||
</ul>
|
||||
@@ -421,7 +460,8 @@ async function refresh(): Promise<void> {
|
||||
}
|
||||
.standings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
/* 明细卡多了一列「运动员」,360px 下五列会挤成一团,放宽到 400px。 */
|
||||
grid-template-columns: minmax(0, 1fr) 400px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@@ -511,14 +551,37 @@ tr.expanded {
|
||||
}
|
||||
.detail-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
/* 七条轨道:项目/组别 · 运动员 · 来源 · 成绩 · 名次 · 积分 · 修订角标。
|
||||
前两条可伸缩,其余按内容宽度 —— 没被改过的行不渲染最后一条,轨道空着不影响对齐。 */
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr) auto auto auto auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--mx-r-xs);
|
||||
background: var(--mx-s2);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* 列头与明细行共用同一套栅格,保证标题落在各自列上方;列头本身不要行底色。 */
|
||||
.detail-head {
|
||||
background: none;
|
||||
padding: 2px 10px 6px;
|
||||
color: var(--mx-t3);
|
||||
font-size: 11px;
|
||||
}
|
||||
/* 姓名可能是「单位名」(团体项目回退),长了就省略,不把后面几列挤歪。 */
|
||||
.detail-athlete {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 来源(成绩 / 破纪录加分 / 人工调整)与导出的「来源」列同一套词,保持窄列、不换行。 */
|
||||
.detail-source {
|
||||
color: var(--mx-t3);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.detail-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -543,6 +606,16 @@ tr.expanded {
|
||||
color: var(--mx-green);
|
||||
font-weight: 800;
|
||||
}
|
||||
/* 修订角标:淡色小药丸,比同行的数字更轻,一眼看出「这几分被改过」但抢不了成绩的位置。 */
|
||||
.detail-revision {
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--mx-s3);
|
||||
color: var(--mx-t3);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
cursor: help;
|
||||
}
|
||||
.detail-empty {
|
||||
color: var(--mx-t3);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -10,8 +10,14 @@ import type { ResultState, ResultStatus } from '@/shared'
|
||||
* 不是报名项 id。两者在页面上挨着,拿错就会把别人的成绩作废掉,所以这里单独强调。
|
||||
*/
|
||||
|
||||
/** 行操作区能对一条已发布成绩做的事。 */
|
||||
export type ResultRowAction = 'void' | 'reopen'
|
||||
/**
|
||||
* 行操作区能对一条成绩做的事。
|
||||
*
|
||||
* `modify` 与 `reopen` 打的是同一个接口(`POST /api/results/{id}/reopen`),差别只在语义与文案:
|
||||
* `modify` 是「已发布的行填错了,退回草稿改一改」,`reopen` 是「已作废的行要重新录入」。
|
||||
* 拆成两个动作是因为按钮文案与确认框后果必须一眼看出不同——「填错了要改」不是「作废」。
|
||||
*/
|
||||
export type ResultRowAction = 'modify' | 'void' | 'reopen'
|
||||
|
||||
export interface ResultRowState {
|
||||
resultState: ResultState | null
|
||||
@@ -19,14 +25,32 @@ export interface ResultRowState {
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一行此刻该显示哪个操作;null 表示不显示(草稿行本来就能直接改,不需要作废入口)。
|
||||
* 这一行此刻该显示哪些操作(按显示顺序);空数组表示不显示(草稿行本来就能直接改)。
|
||||
*
|
||||
* 口径来自服务端 void 的实际行为:只把 `results.status` 改成 withdrawn,`state` 仍是 published,
|
||||
* 所以「已发布 + withdrawn」= 已作废(显示「重新录入」),「已发布 + 其它状态」= 正常已发布(显示「作废本条」)。
|
||||
* 口径来自服务端 void 的实际行为:只把 `results.status` 改成 withdrawn,`state` 仍是 published。
|
||||
* 所以「已发布 + withdrawn」= 已作废(只给「重新录入」),「已发布 + 其它状态」= 正常已发布
|
||||
* (给「修改本条」+「作废本条」)。已发布的行不再只有作废一条出路:改错成绩的直接入口是
|
||||
* 「修改本条」,退回草稿改完再重新发布本组,不必先作废、更不必撤销整组发布。
|
||||
*/
|
||||
export function resultRowAction(row: ResultRowState): ResultRowAction | null {
|
||||
export function resultRowActions(row: ResultRowState): ResultRowAction[] {
|
||||
if (row.resultState !== 'published') return []
|
||||
return row.status === 'withdrawn' ? ['reopen'] : ['modify', 'void']
|
||||
}
|
||||
|
||||
/**
|
||||
* 已发布行的**格子**(成绩输入格 / 状态格)点一下该走哪个动作;null = 这一格点不动。
|
||||
*
|
||||
* 现场裁判长的直觉是「去点格子」而不是去右边找按钮,所以已发布(未作废)的格子给出 `modify`:
|
||||
* 与「修改本条」按钮同一个动作、同一个确认框——点一下只是把确认框弹出来,
|
||||
* 用户不确认就什么都不会发生(绝不能「点一下成绩就悄悄从榜上退下去」)。
|
||||
*
|
||||
* 已作废的行(published + withdrawn)返回 null:它的出路只有按钮上的「重新录入」,
|
||||
* 格子保持禁用,免得「改错字」与「这条不算了」两种语义在同一个格子上混掉。
|
||||
* 草稿行也返回 null——它本来就能直接改,不需要弹窗。
|
||||
*/
|
||||
export function resultCellAction(row: ResultRowState): ResultRowAction | null {
|
||||
if (row.resultState !== 'published') return null
|
||||
return row.status === 'withdrawn' ? 'reopen' : 'void'
|
||||
return row.status === 'withdrawn' ? null : 'modify'
|
||||
}
|
||||
|
||||
export interface ResultActionRow extends ResultRowState {
|
||||
@@ -44,31 +68,47 @@ export interface ResultActionTarget {
|
||||
resultText: string
|
||||
}
|
||||
|
||||
/** 把一行收窄成一次可提交的操作;判定不成立(草稿行 / 没有成绩 id)时为 null。 */
|
||||
export function resultActionTarget(row: ResultActionRow): ResultActionTarget | null {
|
||||
const action = resultRowAction(row)
|
||||
if (action === null || row.resultId === null) return null
|
||||
/** 把一行收窄成一次可提交的操作;该动作在这一行不成立(草稿行 / 没有成绩 id)时为 null。 */
|
||||
export function resultActionTarget(row: ResultActionRow, action: ResultRowAction): ResultActionTarget | null {
|
||||
if (!resultRowActions(row).includes(action)) return null
|
||||
if (row.resultId === null) return null
|
||||
return { action, resultId: row.resultId, entryId: row.entryId, resultText: row.resultText }
|
||||
}
|
||||
|
||||
/** 一行此刻所有可提交的操作,顺序与按钮渲染顺序一致。 */
|
||||
export function resultActionTargets(row: ResultActionRow): ResultActionTarget[] {
|
||||
return resultRowActions(row)
|
||||
.map((action) => resultActionTarget(row, action))
|
||||
.filter((target): target is ResultActionTarget => target !== null)
|
||||
}
|
||||
|
||||
export const RESULT_ROW_ACTION_LABEL: Record<ResultRowAction, string> = {
|
||||
modify: '修改本条',
|
||||
void: '作废本条',
|
||||
reopen: '重新录入',
|
||||
}
|
||||
|
||||
export const RESULT_ROW_ACTION_TITLE: Record<ResultRowAction, string> = {
|
||||
modify: '退回草稿后这一条可直接改,改完再「发布本批」重新上屏;不是作废,原来的成绩还在格里',
|
||||
void: '作废后该成绩不再计入名次与积分,同组其它人重新排名,大屏立即更新',
|
||||
reopen: '退回草稿后可继续修改,改完再发布本组',
|
||||
}
|
||||
|
||||
/** 示例数据模式下拦下写操作时的动作名(拼进「示例数据模式不能X」)。 */
|
||||
export const RESULT_ROW_ACTION_WORD: Record<ResultRowAction, string> = {
|
||||
modify: '修改成绩',
|
||||
void: '作废成绩',
|
||||
reopen: '退回草稿',
|
||||
}
|
||||
|
||||
/** 确认框文案:后果必须写在弹窗里,不能只让操作员凭经验猜(07 §8)。 */
|
||||
export const RESULT_ACTION_CONFIRM: Record<ResultRowAction, { title: string; message: string; confirmText: string; reasonLabel: string }> = {
|
||||
modify: {
|
||||
title: '确认修改本条已发布的成绩?',
|
||||
message: '这一条将退回草稿(成绩还在格里,可直接改):积分榜与大屏先把它退下来,名次与积分按「没有这条成绩」重算;改完请再点「发布本批」重新发布本组,让它重新上屏。只是填错了要改用这里;这一条确实不算了,请改用「作废本条」。',
|
||||
confirmText: '退回草稿修改',
|
||||
reasonLabel: '修改原因',
|
||||
},
|
||||
void: {
|
||||
title: '确认作废本条成绩?',
|
||||
message: '作废后该成绩不再计入名次与积分,同组其它人重新排名,大屏立即更新。',
|
||||
@@ -83,8 +123,12 @@ export const RESULT_ACTION_CONFIRM: Record<ResultRowAction, { title: string; mes
|
||||
},
|
||||
}
|
||||
|
||||
/** 成功提示:作废与退回的后续动作不同,提示要各说各的。 */
|
||||
/** 成功提示:修改 / 作废 / 退回的后续动作不同,提示要各说各的。 */
|
||||
export const RESULT_ACTION_SUCCESS: Record<ResultRowAction, { title: string; detail: string }> = {
|
||||
modify: {
|
||||
title: '已退回草稿,这一行可以改了',
|
||||
detail: '名次与积分暂时按「没有这条成绩」重算;改完点「发布本批」把它重新上屏',
|
||||
},
|
||||
void: {
|
||||
title: '已作废该条成绩,名次与积分已重算',
|
||||
detail: '大屏已更新;需要改回来时用同一行的「重新录入」退回草稿',
|
||||
|
||||
Reference in new issue
Block a user