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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,23 @@ const announceText = ref('')
|
||||
const announceSeconds = ref(15)
|
||||
const pending = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* 大屏当前公告(服务端随设置持久化的那条,全屏放完仍留在底栏)。
|
||||
*
|
||||
* 先取启动时加载到的设置,再向 GET /api/settings 核对一次:公告是别的管理员推的、
|
||||
* 或者本页面开着时被推过,本地这份设置可能是旧的。
|
||||
*/
|
||||
const currentAnnounce = ref<string | null>(data.settings?.display?.announce ?? null)
|
||||
|
||||
async function refreshAnnounce(): Promise<void> {
|
||||
try {
|
||||
const settings = await adminApi.client.settings.get()
|
||||
currentAnnounce.value = settings.display?.announce ?? null
|
||||
} catch {
|
||||
// 示例数据 / 断网时读不到设置,保留本地这一份,不能把「当前公告」清成未知。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示器菜单:只有「打开服务端管理面板」那个 Electron 窗口里的 shell-preload 会注入
|
||||
* window.desktopShell.displays,浏览器直接打开面板时没有这一组能力。
|
||||
@@ -144,7 +161,10 @@ async function relayoutDisplays(): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadDisplays)
|
||||
onMounted(() => {
|
||||
void loadDisplays()
|
||||
void refreshAnnounce()
|
||||
})
|
||||
|
||||
/**
|
||||
* 在线看板(占位)。
|
||||
@@ -164,7 +184,9 @@ function recordDispatch(): void {
|
||||
screens.value = screens.value.map((screen) => ({ ...screen, lastCommandAt: new Date().toISOString() }))
|
||||
}
|
||||
|
||||
async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'): Promise<void> {
|
||||
async function send(
|
||||
action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce' | 'announce_clear',
|
||||
): Promise<void> {
|
||||
if (!canWrite.value) {
|
||||
toast.error('当前不可下发指令', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||
return
|
||||
@@ -191,6 +213,11 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
|
||||
} else if (action === 'reload') {
|
||||
await adminApi.displayCommand({ action, payload: {} })
|
||||
toast.success('已请求所有大屏重新拉取快照')
|
||||
} else if (action === 'announce_clear') {
|
||||
await adminApi.displayCommand({ action, payload: {} })
|
||||
currentAnnounce.value = null
|
||||
toast.success('已清除大屏公告')
|
||||
await refreshAnnounce()
|
||||
} else {
|
||||
const text = announceText.value.trim()
|
||||
if (text === '') {
|
||||
@@ -199,7 +226,9 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
|
||||
}
|
||||
await adminApi.displayCommand({ action, payload: { text, seconds: announceSeconds.value } })
|
||||
announceText.value = ''
|
||||
currentAnnounce.value = text
|
||||
toast.success('公告已推送')
|
||||
await refreshAnnounce()
|
||||
}
|
||||
recordDispatch()
|
||||
} catch (caught) {
|
||||
@@ -284,8 +313,16 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
|
||||
</section>
|
||||
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head"><span class="mx-card-title">公告</span></div>
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">公告</span>
|
||||
<span class="mx-badge" :class="currentAnnounce === null ? 'mx-badge-neutral' : 'mx-badge-success'">
|
||||
{{ currentAnnounce === null ? '当前公告:无' : '当前公告:有' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<p class="announce-current">
|
||||
{{ currentAnnounce ?? '暂无公告(大屏底栏显示占位)' }}
|
||||
</p>
|
||||
<div class="mx-input-wrap textarea-wrap">
|
||||
<textarea
|
||||
v-model="announceText"
|
||||
@@ -300,16 +337,28 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
|
||||
<input v-model.number="announceSeconds" class="mx-cell-input seconds-input" type="number" min="5" max="120" />
|
||||
秒
|
||||
</label>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary"
|
||||
type="button"
|
||||
:disabled="!canWrite || pending !== null"
|
||||
@click="send('announce')"
|
||||
>
|
||||
<MxIcon name="bell" :size="15" />
|
||||
{{ pending === 'announce' ? '推送中…' : '推送公告' }}
|
||||
</button>
|
||||
<div class="announce-actions">
|
||||
<button
|
||||
class="mx-btn mx-btn-primary"
|
||||
type="button"
|
||||
:disabled="!canWrite || pending !== null"
|
||||
@click="send('announce')"
|
||||
>
|
||||
<MxIcon name="bell" :size="15" />
|
||||
{{ pending === 'announce' ? '推送中…' : '推送公告' }}
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-secondary"
|
||||
type="button"
|
||||
:disabled="!canWrite || pending !== null"
|
||||
@click="send('announce_clear')"
|
||||
>
|
||||
<MxIcon name="close" :size="15" />
|
||||
{{ pending === 'announce_clear' ? '清除中…' : '清除公告' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">公告先全屏显示,到时间后自动退回原页面,并常驻大屏底栏「公告」槽位,直到被新公告替换或点「清除公告」。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -514,6 +563,16 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
|
||||
line-height: 1.6;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.announce-current {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s3);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--mx-t2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.announce-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -522,6 +581,11 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.announce-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.seconds {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -15,6 +15,7 @@ import PageHeader from '../components/PageHeader.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import { adminApi } from '../api/client'
|
||||
import { computeEventScore, type ScorableRow } from '../api/scoring'
|
||||
import { entryRowLabel } from '../components/baseData/entrySelection'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useEventStore } from '../stores/event'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
@@ -233,8 +234,9 @@ const issues = computed<Issue[]>(() => {
|
||||
const errors = computed(() => issues.value.filter((issue) => issue.level === 'error'))
|
||||
const warnings = computed(() => issues.value.filter((issue) => issue.level === 'warn'))
|
||||
|
||||
/** 团体 / 接力行没有 athleteName(服务端 omitempty 不下发),回落到队员名单首人 / 单位。 */
|
||||
function rowLabel(row: EditableRow): string {
|
||||
return row.athleteName ?? row.memberNames?.[0] ?? row.teamShort
|
||||
return entryRowLabel(row)
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import type { EventSettings } from '@/shared'
|
||||
|
||||
/**
|
||||
* 榜单口径(standings.individual_to_team_weight)的保存契约。
|
||||
*
|
||||
* adminApi 的 http 实例在模块初始化时就绑定了 `globalThis.fetch`,所以这里先替换 fetch,
|
||||
* 再用动态 import 拿到 store 与控制器,才能观察到真实请求的 URL / 方法与载荷。
|
||||
*/
|
||||
|
||||
const SETTINGS: EventSettings = {
|
||||
event: { name: '测试赛事', subtitle: null, host: null, logoPath: null, primaryColor: null },
|
||||
display: { background: null, logo: null, title: '测试赛事', subtitle: null, primaryColor: null, rotateSeconds: 15 },
|
||||
standings: {
|
||||
individualToTeamWeight: 1,
|
||||
rankingMode: 'points',
|
||||
showIndividualBoard: true,
|
||||
showIndividualMedals: true,
|
||||
},
|
||||
defaultProfileId: null,
|
||||
requireApproval: false,
|
||||
}
|
||||
|
||||
interface Captured {
|
||||
url: string
|
||||
method: string
|
||||
body: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
function stubFetch(weight: number): Captured {
|
||||
const captured: Captured = { url: '', method: '', body: null }
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
captured.url = String(input)
|
||||
captured.method = init?.method ?? 'GET'
|
||||
captured.body = init?.body === undefined ? null : (JSON.parse(String(init.body)) as Record<string, unknown>)
|
||||
const data: EventSettings = { ...SETTINGS, standings: { ...SETTINGS.standings, individualToTeamWeight: weight } }
|
||||
return new Response(JSON.stringify({ ok: true, data }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}) as typeof fetch
|
||||
return captured
|
||||
}
|
||||
|
||||
test('保存榜单口径 PUT /api/settings,提交完整 standings 并回写 event store', async () => {
|
||||
const captured = stubFetch(0.5)
|
||||
const { useEventStore } = await import('../stores/event')
|
||||
const { useBaseData } = await import('../components/baseData/useBaseData')
|
||||
|
||||
setActivePinia(createPinia())
|
||||
const event = useEventStore()
|
||||
event.settings = { ...SETTINGS }
|
||||
const data = useBaseData()
|
||||
|
||||
const saved = await data.saveIndividualToTeamWeight(0.5)
|
||||
|
||||
assert.equal(captured.url, '/api/settings')
|
||||
assert.equal(captured.method, 'PUT')
|
||||
const standings = captured.body?.standings as Record<string, unknown>
|
||||
assert.equal(standings.individualToTeamWeight, 0.5)
|
||||
// 只提交一个字段会把其余口径留在服务端旧值上,所以整个 standings 子对象原样回传。
|
||||
assert.equal(standings.rankingMode, 'points')
|
||||
assert.equal(standings.showIndividualBoard, true)
|
||||
assert.equal(standings.showIndividualMedals, true)
|
||||
|
||||
assert.equal(saved.standings.individualToTeamWeight, 0.5)
|
||||
// 成绩录入页与积分预览直接读 event store 的设置,不回写就等于「保存成功但预览没变」。
|
||||
assert.equal(event.settings?.standings.individualToTeamWeight, 0.5)
|
||||
assert.equal(data.settings.value?.standings.individualToTeamWeight, 0.5)
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import type { CurrentUser } from '@/shared'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useEventStore } from '../stores/event'
|
||||
import { useBaseData } from '../components/baseData/useBaseData'
|
||||
import { useBaseData, parseIndividualToTeamWeight } from '../components/baseData/useBaseData'
|
||||
|
||||
// 写权限只看角色:实时连接断开(服务端重启、网络瞬断)不得让整页变只读。
|
||||
// 曾经 canWrite 依赖 realtimeStatus === 'online',WS 一断连「保存全部」都是灰的。
|
||||
@@ -25,3 +25,30 @@ test('实时连接离线时仍可写,只读访客不可写', () => {
|
||||
auth.user = null
|
||||
assert.equal(data.canWrite.value, false, '退出后回到只读访客')
|
||||
})
|
||||
|
||||
// 榜单口径输入框是 type="number",清空时 v-model 给空串;Number('') 是 0,
|
||||
// 不拦住就会把「清空」当成「系数 0」提交给服务端。
|
||||
test('榜单系数解析:空值 / 越界 / 非数字一律 null,0–100 原样保留', () => {
|
||||
assert.equal(parseIndividualToTeamWeight(''), null)
|
||||
assert.equal(parseIndividualToTeamWeight(' '), null)
|
||||
assert.equal(parseIndividualToTeamWeight(undefined), null)
|
||||
assert.equal(parseIndividualToTeamWeight(null), null)
|
||||
assert.equal(parseIndividualToTeamWeight('abc'), null)
|
||||
assert.equal(parseIndividualToTeamWeight(Number.NaN), null)
|
||||
assert.equal(parseIndividualToTeamWeight(-1), null)
|
||||
assert.equal(parseIndividualToTeamWeight(100.5), null)
|
||||
assert.equal(parseIndividualToTeamWeight(0), 0)
|
||||
assert.equal(parseIndividualToTeamWeight('0.5'), 0.5)
|
||||
assert.equal(parseIndividualToTeamWeight(1), 1)
|
||||
assert.equal(parseIndividualToTeamWeight('100'), 100)
|
||||
})
|
||||
|
||||
test('赛事设置未加载时保存榜单口径直接报错,不发请求', async () => {
|
||||
setActivePinia(createPinia())
|
||||
const event = useEventStore()
|
||||
const data = useBaseData()
|
||||
|
||||
assert.equal(data.settings.value, null, '尚未 bootstrap 时设置应为 null')
|
||||
await assert.rejects(() => data.saveIndividualToTeamWeight(2), /赛事设置未加载/)
|
||||
assert.equal(event.settings, null, '失败不应写入 store')
|
||||
})
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
OPTIONAL_COLUMNS,
|
||||
REQUIRED_COLUMNS,
|
||||
buildSelectionRows,
|
||||
entryRowLabel,
|
||||
selectionAllowsSubmit,
|
||||
toCsv,
|
||||
} from '../components/baseData/entrySelection'
|
||||
|
||||
@@ -91,3 +93,33 @@ test('buildSelectionRows 个人项目:证件号与号码布都为空时两列
|
||||
})
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', 'registered', ''])
|
||||
})
|
||||
|
||||
// 团体项目「只报单位」:一个人都没勾也要能提交,member_names 留空(服务端 athlete_id 为 NULL 的报名合法)。
|
||||
test('buildSelectionRows 团体项目:0 名队员时 member_names 留空', () => {
|
||||
const rows = buildSelectionRows({
|
||||
eventCode: 'BB',
|
||||
categoryName: '混合组',
|
||||
teamCode: 'F2',
|
||||
mode: 'team',
|
||||
athletes: [],
|
||||
})
|
||||
assert.equal(rows.length, 2, '表头 + 1 条只挂单位的报名')
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'registered', ''])
|
||||
})
|
||||
|
||||
test('团体 / 对抗项目 0 勾选可提交,个人项目必须勾人', () => {
|
||||
assert.equal(selectionAllowsSubmit('team', 0), true, '团体项目允许只报单位')
|
||||
assert.equal(selectionAllowsSubmit('team', 3), true)
|
||||
assert.equal(selectionAllowsSubmit('individual', 0), false, '个人项目必须至少勾一人')
|
||||
assert.equal(selectionAllowsSubmit('individual', 1), true)
|
||||
})
|
||||
|
||||
// 团体 / 接力行服务端不下发 athleteName(omitempty),回落到队员 / 单位名,
|
||||
// 否则防呆校验的文案会变成「:缺少成绩」。
|
||||
test('entryRowLabel 团体行回落到队员首人 / 单位', () => {
|
||||
assert.equal(entryRowLabel({ athleteName: '张伟', memberNames: null, teamShort: '一队' }), '张伟')
|
||||
assert.equal(entryRowLabel({ athleteName: '', memberNames: ['王芳', '刘洋'], teamShort: '一队' }), '王芳')
|
||||
assert.equal(entryRowLabel({ athleteName: null, memberNames: [], teamShort: '一队' }), '一队')
|
||||
assert.equal(entryRowLabel({ athleteName: null, memberNames: null, teamShort: '', teamName: '第一分公司' }), '第一分公司')
|
||||
assert.equal(entryRowLabel({}), '')
|
||||
})
|
||||
@@ -228,3 +228,45 @@ test('项目与组别权重相乘', () => {
|
||||
)
|
||||
close(result.rowPoints.get(1) ?? 0, 60)
|
||||
})
|
||||
|
||||
// 团体行的 athleteId 是 null(服务端 entries.athlete_id 为空,0 队员的「只报单位」报名也是这种行),
|
||||
// 团体通道的排名主体是单位,不能因为没有运动员就把行丢掉。
|
||||
test('团体行(athleteId 为 null)按单位通道正常拿名次与积分', () => {
|
||||
const profile = makeProfile({
|
||||
rules: [
|
||||
{ rank: 1, points: 9 },
|
||||
{ rank: 2, points: 6 },
|
||||
],
|
||||
})
|
||||
const rows: ScorableRow[] = [
|
||||
{ entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 1, status: 'ok', isTie: false },
|
||||
{ entryId: 2, teamId: 101, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false },
|
||||
]
|
||||
const result = computeEventScore(
|
||||
rows,
|
||||
makeContext({
|
||||
event: makeEvent({ eventType: 'team', resultMode: 'manual', compare: 'none', resultType: 'none' }),
|
||||
channels: [makeChannel({ scope: 'team', basis: 'manual' })],
|
||||
profiles: [profile],
|
||||
}),
|
||||
)
|
||||
assert.equal(result.ranks.get(1), 1)
|
||||
assert.equal(result.ranks.get(2), 2)
|
||||
close(result.rowPoints.get(1) ?? 0, 9)
|
||||
close(result.rowTeamPoints.get(1) ?? 0, 9)
|
||||
close(result.teamPoints.get(100) ?? 0, 9)
|
||||
close(result.teamPoints.get(101) ?? 0, 6)
|
||||
})
|
||||
|
||||
test('团体行只配了录入名次(无计分通道)时也能算出名次', () => {
|
||||
const rows: ScorableRow[] = [
|
||||
{ entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false },
|
||||
{ entryId: 2, teamId: 101, athleteId: null, resultValue: null, rank: 1, status: 'ok', isTie: false },
|
||||
]
|
||||
const result = computeEventScore(
|
||||
rows,
|
||||
makeContext({ event: makeEvent({ eventType: 'team', resultMode: 'manual', compare: 'none', resultType: 'none' }) }),
|
||||
)
|
||||
assert.equal(result.ranks.get(1), 2)
|
||||
assert.equal(result.ranks.get(2), 1)
|
||||
})
|
||||
Reference in new issue
Block a user