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:
AskaEth committed 2026-09-10 11:06:58 +08:00
1 parent cded54e440
commit b5f40642ff
24 files changed
+1073 -67

No files matched your search

@@ -5,7 +5,7 @@ import MxIcon from '../MxIcon.vue'
import { adminApi } from '../../api/client'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
import { buildSelectionRows, toCsv, type SelectionMode } from './entrySelection'
import { buildSelectionRows, selectionAllowsSubmit, toCsv, type SelectionMode } from './entrySelection'
/**
* 从已有运动员名单里勾选报名。
@@ -75,7 +75,8 @@ const canSubmit = computed(
currentEvent.value !== undefined &&
currentCategory.value !== undefined &&
currentTeam.value !== undefined &&
checkedAthletes.value.length > 0 &&
// 团体 / 对抗项目允许只报单位(0 勾选也放行),个人项目必须勾人。
selectionAllowsSubmit(mode.value, checkedAthletes.value.length) &&
!submitting.value,
)
@@ -130,7 +131,8 @@ async function submit(): Promise<void> {
const category = currentCategory.value
const team = currentTeam.value
const chosen = checkedAthletes.value
if (event === undefined || category === undefined || team === undefined || chosen.length === 0) return
if (event === undefined || category === undefined || team === undefined) return
if (!selectionAllowsSubmit(mode.value, chosen.length)) return
submitting.value = true
error.value = null
@@ -214,10 +216,16 @@ async function submit(): Promise<void> {
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
<input v-model="search" class="mx-input" placeholder="搜索姓名 / 工号" />
</div>
<span class="bd-note">已勾选 {{ checkedAthletes.length }} 人</span>
<span class="bd-note">
已勾选 {{ checkedAthletes.length }} 人{{ mode === 'team' && checkedAthletes.length === 0 ? '(团体项目可留空)' : '' }}
</span>
</div>
</div>
<p v-if="mode === 'team'" class="bd-note">
团体 / 对抗项目按单位报名:队员名单可选,一名队员都不勾也能把本单位的报名建出来,名单可以之后再来补。
</p>
<div class="picker-list">
<label
v-for="athlete in filteredAthletes"
@@ -243,7 +251,7 @@ async function submit(): Promise<void> {
<p class="bd-note">
<template v-if="mode === 'individual'">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</template>
<template v-else>团体 / 对抗项目:勾选的人合成一条报名,队员名单按列表顺序写入;已报名时再次提交会更新队员名单。</template>
<template v-else>团体 / 对抗项目:勾选的人合成一条报名(一个人也不勾则只报单位),队员名单按列表顺序写入;已报名时再次提交会更新队员名单。</template>
</p>
<p class="bd-note">报名会自动带上该运动员的证件号(优先)或号码布;种子等字段仍通过下方报名表导入填写。</p>
<p v-if="mode === 'individual'" class="bd-note">证件号与号码布都没有的运动员不能报名个人项目,请先在「运动员」分区补齐其中一项。</p>
@@ -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;
@@ -1,5 +1,5 @@
/**
* 「从已有名单勾选报名」的纯函数部分,以及报名导入冲突的差异 / 决策计算。
* 报名相关的纯函数:勾选转 CSV、报名行文案,以及报名导入冲突的差异 / 决策计算。
*
* 之所以复用导入通道的表头与 CSV 格式:服务端只认 /entries/import 这一个报名入口,
* 勾选结果转成同格式 CSV 后走同一个接口,字段校验、按 (组别, 单位, 运动员) upsert
@@ -39,6 +39,26 @@ export const MANDATORY_COLUMNS: readonly string[] = REQUIRED_COLUMNS.filter(
export type SelectionMode = 'individual' | 'team'
/**
* 「加入报名」是否放行。
*
* 个人项目一人一条报名,必须勾人;团体 / 对抗项目一条报名只挂单位(服务端 athlete_id 为 NULL
* 的报名合法),队员名单可以留空后补,所以 0 勾选也要放行。
*/
export function selectionAllowsSubmit(mode: SelectionMode, chosenCount: number): boolean {
return mode === 'team' || chosenCount > 0
}
/** 报名行的展示文案:运动员姓名 → 团体队员首人 → 单位简称 / 全称。 */
export function entryRowLabel(row: {
athleteName?: string | null
memberNames?: string[] | null
teamShort?: string | null
teamName?: string | null
}): string {
return row.athleteName || row.memberNames?.[0] || row.teamShort || row.teamName || ''
}
export interface SelectionInput {
eventCode: string
categoryName: string
@@ -54,7 +74,7 @@ export interface SelectionInput {
/**
* 生成与导入表头一致的二维数组。
*
* 个人项目一人一行;团体 / 对抗项目整体一行,队员姓名进 member_names。
* 个人项目一人一行;团体 / 对抗项目整体一行,队员姓名进 member_names(可以为空,即只报单位)。
* 服务端按 (组别, 单位, 运动员) upsert,团体行运动员为空,因此同组别 + 单位只留一条报名。
*/
export function buildSelectionRows(input: SelectionInput): string[][] {
@@ -8,6 +8,7 @@ import type {
EventCategory,
EventCategoryInput,
EventInput,
EventSettings,
ImportConflictDecision,
ScoringProfile,
ScoringProfileInput,
@@ -18,7 +19,7 @@ import type {
Team,
TeamInput,
} from '@/shared'
import { mockMode, mockReason } from '../../api/client'
import { adminApi, mockMode, mockReason } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
import { useEventStore } from '../../stores/event'
import { baseDataApi, baseDataFallback } from './baseDataApi'
@@ -240,6 +241,38 @@ function resolveImportConflicts(file: File, decisions: ImportConflictDecision[])
return baseDataApi.entries.resolveImportConflicts(file, decisions)
}
// ── 榜单口径(全局赛事设置) ────────────────────────────────────────────────
/**
* 解析榜单系数的输入值。`type="number"` 的 v-model 会给出 number、清空时是空串,
* 所以先 String() 再判断;0–100 之外或非数字一律返回 null,由调用方给出提示。
*/
export function parseIndividualToTeamWeight(raw: unknown): number | null {
const text = String(raw ?? '').trim()
if (text === '') return null
const value = Number(text)
if (!Number.isFinite(value) || value < 0 || value > 100) return null
return value
}
/**
* 保存榜单口径(PUT /api/settings)。
*
* 只提交完整的 standings 子对象,其余设置不动;写操作不降级,失败原样抛给页面提示。
* 保存后回写 event store:成绩录入页与积分预览都直接读 store 里的设置,不回写就会
* 「提示保存成功但预览没变」。
*/
async function saveIndividualToTeamWeight(weight: number): Promise<EventSettings> {
const eventStore = useEventStore()
const current = eventStore.settings
if (current === null) throw new Error('赛事设置未加载,无法保存榜单口径')
const saved = await adminApi.client.settings.update({
standings: { ...current.standings, individualToTeamWeight: weight },
})
eventStore.settings = saved
return saved
}
export interface BaseDataController {
teams: Ref<Team[]>
sports: Ref<Sport[]>
@@ -253,6 +286,8 @@ export interface BaseDataController {
isMock: ComputedRef<boolean>
mockLabel: ComputedRef<string | null>
canWrite: ComputedRef<boolean>
/** 赛事设置(含榜单口径);GET /api/settings 仅管理员可见,只读访客拿不到时为 null。 */
settings: ComputedRef<EventSettings | null>
load: (force?: boolean) => Promise<void>
loadEventDetail: (id: number, force?: boolean) => Promise<Event | null>
selectEvent: (id: number) => Promise<void>
@@ -274,10 +309,14 @@ export interface BaseDataController {
removeAthlete: (id: number) => Promise<void>
importEntries: (file: File) => Promise<EntryImportResult>
resolveImportConflicts: (file: File, decisions: ImportConflictDecision[]) => Promise<EntryImportResult>
/** 只改榜单口径的全局系数,其余设置原样回传;保存后同步 event store,页面立即生效。 */
saveIndividualToTeamWeight: (weight: number) => Promise<EventSettings>
}
export function useBaseData(): BaseDataController {
const auth = useAuthStore()
// 榜单口径是全局设置,唯一真值在 event store(bootstrap / 实时快照都写它)。
const eventStore = useEventStore()
return {
teams,
@@ -295,6 +334,7 @@ export function useBaseData(): BaseDataController {
),
// 写权限只看角色:写操作走 HTTP、服务端会做完整校验,实时连接断线不该让整页变只读。
canWrite: computed(() => !auth.readOnly),
settings: computed(() => eventStore.settings),
load,
loadEventDetail,
selectEvent,
@@ -316,5 +356,6 @@ export function useBaseData(): BaseDataController {
removeAthlete,
importEntries,
resolveImportConflicts,
saveIndividualToTeamWeight,
}
}