feat: 公告持久化(底栏常驻)+ 团体可单独报名 + 榜单系数开关
公告: - 服务端把当前公告落库到 settings(display.announce_text / announce_seconds), 作为 display.announce 随 snapshot 与 /api/settings 下发(非 admin 快照也保留) - announce 指令除转发外写库;新增 announce_clear 清空并广播;两者写审计 - realtime 协议登记 announce_clear(补上,使清除能即时推到大屏) - 大屏把「全屏推送」与「底栏常驻」拆开:推送 seconds 秒后只退出全屏态,公告留在底栏, 直到被替换或清除;新增公告状态机(可测)与底栏取值优先级 推送中 > 服务端持久值 > 「暂无公告」;管理端加「清除公告」与当前公告状态 团体报名: - EntryPicker 团体/对抗模式允许不勾队员直接报名该单位(此前必须勾人,导致 「只想给单位报个团体项目」做不到、单位下没有运动员时完全报不了) - 核实录入页团体行:显示/填名次/保存/发布/算分闭环无缺陷(用真实服务端跑过), 抽出共享的 entryRowLabel 供两处复用 榜单口径: - 基础数据 → 积分模板页新增「榜单口径(全局)」卡片:individualToTeamWeight 数字输入(默认 1、0–100),说明「个人项目得分按该系数折算计入单位总分」, 保存走 PUT /api/settings;因该值在服务端是全局键、模板表里没有此字段, 故做成独立卡片并标注全局,不塞进模板表单 自检:服务端 11 包全绿;前端 vue-tsc + build、node --test 99/99。
This commit is contained in:
1 parent
cded54e440
commit
b5f40642ff
24 files changed
+1073
-67
No files matched your search
@@ -4,10 +4,12 @@ import type { RecordBonusMode, ScoringProfile, ScoringProfileInput, TieMode } fr
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
import FormField from './FormField.vue'
|
||||
import { useBaseData } from './useBaseData'
|
||||
import { parseIndividualToTeamWeight, useBaseData } from './useBaseData'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
|
||||
const data = useBaseData()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const CODE_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
|
||||
@@ -54,6 +56,52 @@ const removedRules = ref<string[]>([])
|
||||
const selectedProfile = computed(() => data.profileById(selectedId.value) ?? null)
|
||||
const isEditing = computed(() => creating.value || selectedProfile.value !== null)
|
||||
|
||||
// ── 榜单口径:全局设置,不随某张模板保存 ──────────────────────────────────
|
||||
const isAdmin = computed(() => auth.user?.role === 'admin')
|
||||
const weightInput = ref<number | string>('')
|
||||
const weightSaving = ref(false)
|
||||
const weightError = ref<string | null>(null)
|
||||
|
||||
/** 库里现有的系数;settings 仅管理员可见,非管理员拿到 null。 */
|
||||
const storedWeight = computed<number | null>(() => data.settings.value?.standings.individualToTeamWeight ?? null)
|
||||
const parsedWeight = computed(() => parseIndividualToTeamWeight(weightInput.value))
|
||||
const weightDirty = computed(
|
||||
() => parsedWeight.value !== null && storedWeight.value !== null && parsedWeight.value !== storedWeight.value,
|
||||
)
|
||||
|
||||
// 服务端设置变化(规则包导入、别处保存)时回填;本地已改动的输入不被覆盖。
|
||||
watch(
|
||||
() => data.settings.value?.standings.individualToTeamWeight,
|
||||
(value) => {
|
||||
if (value === undefined) return
|
||||
if (!weightDirty.value) weightInput.value = value
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function saveWeight(): Promise<void> {
|
||||
const value = parsedWeight.value
|
||||
if (value === null) {
|
||||
weightError.value = '折算系数需为 0–100 的数字,例如 1 或 0.5'
|
||||
return
|
||||
}
|
||||
// 服务端对 ≤0 直接 400(0 与负数会被拒绝),本地先拦下,别让用户以为能「按 1 处理」。
|
||||
if (value <= 0) {
|
||||
weightError.value = '折算系数必须大于 0,服务端会拒绝 0 与负数'
|
||||
return
|
||||
}
|
||||
weightSaving.value = true
|
||||
weightError.value = null
|
||||
try {
|
||||
await data.saveIndividualToTeamWeight(value)
|
||||
toast.success('榜单口径已保存', '个人项目得分已按新系数折算计入单位总分')
|
||||
} catch (caught) {
|
||||
weightError.value = caught instanceof Error ? caught.message : '保存失败'
|
||||
} finally {
|
||||
weightSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => data.profiles.value,
|
||||
(list) => {
|
||||
@@ -363,6 +411,50 @@ const totalTop = computed(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-card global-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">榜单口径<span class="mx-badge mx-badge-neutral global-badge">全局设置</span></span>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!isAdmin || !weightDirty || weightSaving"
|
||||
@click="saveWeight"
|
||||
>
|
||||
{{ weightSaving ? '保存中…' : '保存榜单口径' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<div class="bd-form-grid">
|
||||
<FormField
|
||||
label="个人项目得分折算系数"
|
||||
:error="weightError"
|
||||
hint="个人项目得分按该系数折算计入单位总分(全局设置,对所有模板生效)。默认 1,步进 0.1;服务端要求大于 0、不超过 100。"
|
||||
>
|
||||
<input
|
||||
v-model="weightInput"
|
||||
class="mx-cell-input"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
:disabled="!isAdmin || weightSaving"
|
||||
placeholder="1"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<p class="bd-note">
|
||||
这是<strong>全局</strong>设置(standings.individual_to_team_weight),不随某一张积分模板保存,所有模板与所有计分通道共用:
|
||||
单位总分 = Σ 只记单位的流水 + 系数 × Σ 记到个人的流水。导入规则包时,规则包里的同名配置会覆盖这里。
|
||||
</p>
|
||||
<p v-if="!isAdmin" class="bd-note weight-readonly">
|
||||
只有管理员能读写赛事设置(GET / PUT /api/settings 仅管理员可用),当前账号为只读:读不到具体系数,榜单按库里已有取值折算(未配置时为 1)。
|
||||
</p>
|
||||
<p v-else-if="storedWeight === null" class="bd-note">
|
||||
赛事设置尚未加载(未登录或服务端未连接),暂时读不到当前系数。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="confirmOpen"
|
||||
tone="warning"
|
||||
@@ -381,6 +473,17 @@ const totalTop = computed(() => {
|
||||
.danger-text {
|
||||
color: var(--mx-red);
|
||||
}
|
||||
/* 榜单口径是全局设置,横跨列表与明细两栏,避免被当成某张模板的一部分。 */
|
||||
.global-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.global-badge {
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.weight-readonly {
|
||||
color: var(--mx-amber);
|
||||
}
|
||||
.form-error {
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
|
||||
Reference in new issue
Block a user