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

+76 -12
View File
@@ -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;
+3 -1
View File
@@ -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 {