wallace-screen-app/pages/index/index.vue

191 lines
5.1 KiB
Vue

<template>
<view class="terminal-page">
<ScreenPlayer
v-if="terminalState === 'playing' && playlist.items.length"
:items="playlist.items"
@cycle-end="refreshPlaylist"
/>
<view v-else class="status-page">
<view class="brand">华莱士</view>
<template v-if="terminalState === 'binding'">
<view class="status-title">绑定门店后即可播放</view>
<image class="qr-code" :src="qrImageUrl" mode="aspectFit" />
<view class="status-tip">请使用华莱士小程序扫码绑定本门店电视</view>
<view class="device-code">设备编号:{{ deviceSn }}</view>
<button v-if="isMock" class="mock-action" @tap="completeMockBinding">
模拟完成绑定
</button>
</template>
<template v-else-if="terminalState === 'loading'">
<view class="status-title">广告终端启动中</view>
<view class="status-tip">正在读取终端状态</view>
</template>
<template v-else>
<view class="status-title">暂时无法播放广告</view>
<view class="status-tip">{{ errorMessage || '请检查网络连接后重试' }}</view>
<button class="retry-action" @tap="initialize">重新连接</button>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import ScreenPlayer from '@/components/ScreenPlayer/ScreenPlayer.vue'
import { TERMINAL_CONFIG } from '@/config/terminal'
import { bindMockTerminal, fetchTerminalBinding, fetchTerminalPlaylist } from '@/services/terminal'
import type { Playlist } from '@/types/terminal'
import { getOrCreateDeviceSn } from '@/utils/device'
type TerminalState = 'loading' | 'binding' | 'playing' | 'error'
const terminalState = ref<TerminalState>('loading')
const deviceSn = ref('')
const errorMessage = ref('')
const playlist = reactive<Playlist>({ revision: '', items: [] })
const isMock = TERMINAL_CONFIG.useMock
let bindingTimer: ReturnType<typeof setInterval> | undefined
let mockAutoBindTimer: ReturnType<typeof setTimeout> | undefined
const bindingUrl = computed(() => {
const separator = TERMINAL_CONFIG.bindPageUrl.includes('?') ? '&' : '?'
return `${TERMINAL_CONFIG.bindPageUrl}${separator}sn=${encodeURIComponent(deviceSn.value)}`
})
/** Mock 阶段由公共二维码服务将真实绑定链接编码为可扫码图片。正式版替换为本地 QR 组件。 */
const qrImageUrl = computed(() =>
`https://api.qrserver.com/v1/create-qr-code/?size=420x420&margin=12&data=${encodeURIComponent(bindingUrl.value)}`
)
const clearTimers = () => {
if (bindingTimer) clearInterval(bindingTimer)
if (mockAutoBindTimer) clearTimeout(mockAutoBindTimer)
bindingTimer = undefined
mockAutoBindTimer = undefined
}
const refreshPlaylist = async () => {
try {
const nextPlaylist = await fetchTerminalPlaylist(deviceSn.value)
playlist.revision = nextPlaylist.revision
playlist.items = nextPlaylist.items
} catch (error: any) {
errorMessage.value = error?.message || '广告节目单加载失败'
}
}
const checkBinding = async () => {
try {
const binding = await fetchTerminalBinding(deviceSn.value)
if (!binding.bound) {
terminalState.value = 'binding'
return
}
clearTimers()
await refreshPlaylist()
terminalState.value = playlist.items.length ? 'playing' : 'error'
if (!playlist.items.length) errorMessage.value = '门店暂未配置广告节目单'
} catch (error: any) {
terminalState.value = 'error'
errorMessage.value = error?.message || '终端状态读取失败'
}
}
const completeMockBinding = async () => {
await bindMockTerminal()
await checkBinding()
}
const initialize = async () => {
clearTimers()
terminalState.value = 'loading'
errorMessage.value = ''
deviceSn.value = getOrCreateDeviceSn()
await checkBinding()
if (terminalState.value !== 'binding') return
bindingTimer = setInterval(checkBinding, TERMINAL_CONFIG.bindingPollIntervalMs)
if (isMock) {
mockAutoBindTimer = setTimeout(completeMockBinding, TERMINAL_CONFIG.mockAutoBindAfterMs)
}
}
onMounted(initialize)
onBeforeUnmount(clearTimers)
</script>
<style lang="scss" scoped>
.terminal-page,
.status-page {
width: 100vw;
height: 100vh;
}
.status-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: radial-gradient(circle at 50% 44%, #333333 0%, #171717 46%, #0d0d0d 100%);
color: #ffffff;
}
.brand {
margin-bottom: 42rpx;
font-size: 68rpx;
font-weight: 700;
letter-spacing: 14rpx;
}
.status-title {
font-size: 40rpx;
font-weight: 600;
letter-spacing: 2rpx;
}
.status-tip {
margin-top: 16rpx;
color: rgba(255, 255, 255, 0.62);
font-size: 24rpx;
}
.qr-code {
width: 360rpx;
height: 360rpx;
margin-top: 34rpx;
border-radius: 12rpx;
background: #ffffff;
}
.device-code {
margin-top: 20rpx;
color: rgba(255, 255, 255, 0.42);
font-family: monospace;
font-size: 20rpx;
letter-spacing: 1rpx;
}
.mock-action,
.retry-action {
margin-top: 32rpx;
padding: 0 34rpx;
border: 1rpx solid rgba(255, 255, 255, 0.42);
border-radius: 6rpx;
background: transparent;
color: rgba(255, 255, 255, 0.85);
font-size: 24rpx;
line-height: 66rpx;
}
.mock-action::after,
.retry-action::after {
border: none;
}
</style>