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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user