372 lines
12 KiB
Vue
372 lines
12 KiB
Vue
<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>
|