Files
Scoreboard/web/src/admin/pages/DisplayPage.vue
T

372 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { formatDateTime } from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import { adminApi } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
/** 大屏页面表里的规范值;写错会被大屏静默丢弃,所以用字面量联合锁死。 */
type DisplayPage = 'standings' | 'team-medals' | 'athlete-standings' | 'event-results'
interface ScreenNode {
key: string
name: string
/** null = 未知;后端没有实例上报接口,占位节点恒为未知。 */
online: boolean | null
/** 本机最后一次下发指令的时间,不是大屏心跳。 */
lastCommandAt: string | null
}
const PAGES: Array<{ key: DisplayPage; label: string }> = [
{ key: 'standings', label: '总积分榜' },
{ key: 'team-medals', label: '团体奖牌榜' },
{ key: 'athlete-standings', label: '个人积分榜' },
{ key: 'event-results', label: '最新成绩' },
]
const data = useEventStore()
const auth = useAuthStore()
const toast = useToastStore()
const currentPage = ref<DisplayPage>('standings')
const locked = ref(false)
const announceText = ref('')
const announceSeconds = ref(15)
const pending = ref<string | null>(null)
/**
* 在线看板(占位)。
*
* devdocs/05 §7 未定义「大屏在线列表」接口,管理端拿不到每块屏的心跳,
* 所以这里只登记本地已知的屏,在线状态恒为「未知」——指令送达不等于大屏在线。
* 后端补上实例上报接口后,把 screens 换成接口数据、hasInstanceData 才可能为真。
*/
const screens = ref<ScreenNode[]>([{ key: 'main', name: '主屏(本地登记)', online: null, lastCommandAt: null }])
const canWrite = computed(() => !auth.readOnly && data.canWrite)
/** 只有真实实例上报(online 非 null)才算有在线数据,占位节点一律不谎报在线。 */
const hasInstanceData = computed(() => screens.value.some((screen) => screen.online !== null))
const onlineCount = computed(() => screens.value.filter((screen) => screen.online === true).length)
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> {
if (!canWrite.value) {
toast.error('当前不可下发指令', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
if (data.isMock) {
// 示例数据模式下指令不会真正下发,不能提示「已切换」。
toast.warning('示例数据模式未真正下发', '后端未就绪,指令不会到达大屏')
return
}
pending.value = action
try {
if (action === 'goto') {
// 页面值不在大屏别名表内会被静默丢弃,这里先拦下并明确报错,避免谎报成功。
if (!PAGES.some((page) => page.key === currentPage.value)) {
toast.error('大屏不认识该页面', `页面值「${currentPage.value}」不在大屏页面表内`)
return
}
await adminApi.displayCommand({ action, payload: { page: currentPage.value } })
toast.success(`已切换大屏到「${PAGES.find((page) => page.key === currentPage.value)?.label ?? currentPage.value}」`)
} else if (action === 'lock' || action === 'unlock') {
await adminApi.displayCommand({ action, payload: {} })
locked.value = action === 'lock'
toast.success(action === 'lock' ? '大屏已锁定,停止自动轮播' : '大屏已解锁,恢复轮播')
} else if (action === 'reload') {
await adminApi.displayCommand({ action, payload: {} })
toast.success('已请求所有大屏重新拉取快照')
} else {
const text = announceText.value.trim()
if (text === '') {
toast.error('公告内容不能为空')
return
}
await adminApi.displayCommand({ action, payload: { text, seconds: announceSeconds.value } })
announceText.value = ''
toast.success('公告已推送')
}
recordDispatch()
} catch (caught) {
toast.error('指令下发失败', caught instanceof Error ? caught.message : null)
} finally {
pending.value = null
}
}
</script>
<template>
<div>
<PageHeader title="大屏控制" subtitle="切页 / 锁定 / 刷新 / 公告 / 在线看板">
<template #actions>
<span class="mx-badge" :class="locked ? 'mx-badge-warning' : 'mx-badge-success'">
{{ locked ? '已锁定' : '轮播中' }}
</span>
</template>
</PageHeader>
<div class="display-grid">
<div class="col">
<section class="mx-card">
<div class="mx-card-head"><span class="mx-card-title">切页</span></div>
<div class="mx-card-body">
<div class="page-grid">
<button
v-for="page in PAGES"
:key="page.key"
class="page-btn"
:class="{ active: currentPage === page.key }"
type="button"
@click="currentPage = page.key"
>
<MxIcon name="display" :size="16" />
{{ page.label }}
</button>
</div>
<button
class="mx-btn mx-btn-primary full"
type="button"
:disabled="!canWrite || pending !== null"
@click="send('goto')"
>
<MxIcon name="push" :size="15" />
{{ pending === 'goto' ? '下发中…' : '立即跳转' }}
</button>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head"><span class="mx-card-title">锁定与刷新</span></div>
<div class="mx-card-body">
<div class="action-row">
<button
class="mx-btn mx-btn-secondary"
type="button"
:disabled="!canWrite || pending !== null || locked"
@click="send('lock')"
>
<MxIcon name="lock" :size="15" /> 锁定
</button>
<button
class="mx-btn mx-btn-secondary"
type="button"
:disabled="!canWrite || pending !== null || !locked"
@click="send('unlock')"
>
<MxIcon name="unlock" :size="15" /> 解锁
</button>
<button
class="mx-btn mx-btn-secondary"
type="button"
:disabled="!canWrite || pending !== null"
@click="send('reload')"
>
<MxIcon name="refresh" :size="15" /> 强制刷新
</button>
</div>
<p class="hint">锁定后停止自动轮播并停在当前页;强制刷新让所有大屏重新拉取快照,用于排障。</p>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head"><span class="mx-card-title">公告</span></div>
<div class="mx-card-body">
<div class="mx-input-wrap textarea-wrap">
<textarea
v-model="announceText"
class="mx-input announce"
rows="3"
placeholder="输入要在大屏底部滚动显示的公告"
></textarea>
</div>
<div class="announce-foot">
<label class="seconds">
滚动时长
<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>
</div>
</section>
</div>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">在线看板</span>
<span class="mx-badge" :class="hasInstanceData ? (onlineCount > 0 ? 'mx-badge-success' : 'mx-badge-error') : 'mx-badge-neutral'">
{{ hasInstanceData ? `${onlineCount} / ${screens.length} 在线` : '在线状态未知(占位)' }}
</span>
</div>
<div class="mx-card-body">
<div class="board-note">
<MxIcon name="warning" :size="14" />
<span>后端未提供大屏实例上报接口(devdocs/05 §7 未定义),下表是本地占位节点:「在线」无法判定,也不按指令送达推断;「最近下发」只是本机下发时间,不代表大屏在线。</span>
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>大屏</th>
<th>状态</th>
<th>最近下发</th>
</tr>
</thead>
<tbody>
<tr v-for="screen in screens" :key="screen.key">
<td>{{ screen.name }}</td>
<td>
<span
class="mx-badge"
:class="screen.online === true ? 'mx-badge-success' : screen.online === false ? 'mx-badge-error' : 'mx-badge-neutral'"
>
{{ screen.online === true ? '在线' : screen.online === false ? '掉线' : '未知' }}
</span>
</td>
<td class="mono muted">{{ screen.lastCommandAt === null ? '—' : formatDateTime(screen.lastCommandAt) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="board-hint">
<p>当前状态:{{ locked ? '已锁定' : '自动轮播' }} · 页面 {{ PAGES.find((page) => page.key === currentPage)?.label ?? currentPage }}</p>
<p v-if="data.isMock" class="mock">示例数据模式下指令不会真正下发到大屏。</p>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.display-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.col {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 12px;
}
.page-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
border: 1px solid transparent;
color: var(--mx-t2);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all var(--mx-d2) var(--mx-ease);
}
.page-btn:hover {
color: var(--mx-text);
border-color: var(--mx-border);
}
.page-btn.active {
background: var(--mx-primary-glow);
border-color: var(--mx-primary);
color: var(--mx-primary);
}
.full {
width: 100%;
}
.action-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.hint,
.board-note,
.board-hint {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
}
.hint {
margin-top: 12px;
}
.textarea-wrap {
height: auto;
align-items: flex-start;
padding: 10px 14px;
}
.announce {
resize: vertical;
line-height: 1.6;
padding: 4px 0;
}
.announce-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
.seconds {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--mx-t3);
}
.seconds-input {
width: 68px;
text-align: center;
}
.board-note {
display: flex;
gap: 8px;
padding: 10px 12px;
border-radius: var(--mx-r-sm);
background: rgba(251, 191, 36, 0.08);
color: var(--mx-amber);
margin-bottom: 12px;
}
.muted {
color: var(--mx-t3);
font-size: 12px;
}
.board-hint {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 4px;
}
.board-hint .mock {
color: var(--mx-amber);
}
@media (max-width: 1023px) {
.display-grid {
grid-template-columns: 1fr;
}
}
</style>