Files
evening_detective_frontend/src/components/ScreenPage.vue
T
2026-09-10 12:07:58 +07:00

292 lines
8.5 KiB
Vue

<script setup lang="ts">
import { NButton, NCard, NQrCode, NSelect, useMessage } from 'naive-ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getAuthClient } from '@/api/auth_client'
import type { Game, ScreenState } from '@/api/generated/crabs/evening_detective_server'
import HeaderMenu from '@/components/HeaderMenu.vue'
import { applicationFileURL } from '@/utils/application'
import { formatGameDateTime } from '@/utils/datetime'
const sessionKey = 'evening-detective-screen-session'
const client = getAuthClient()
const message = useMessage()
const route = useRoute()
const screenId = ref('')
const accessToken = ref('')
const state = ref<ScreenState>()
const games = ref<Game[]>([])
const selectedGameId = ref<number | null>(null)
const prepared = ref(false)
const loading = ref(true)
const binding = ref(false)
const bindSuccess = ref(false)
let pollTimer: number | null = null
const bindingFlow = computed(() => Boolean(route.query.screenId && route.query.accessToken))
const qrValue = computed(() => {
if (!screenId.value || !accessToken.value) return ''
const url = new URL('/screen', window.location.origin)
url.searchParams.set('screenId', screenId.value)
url.searchParams.set('accessToken', accessToken.value)
return url.toString()
})
const gameOptions = computed(() =>
games.value.map((game) => ({
label: `${formatGameDateTime(game.startAt).split(',')[0] || 'дата не указана'} — ${game.name || `Игра ${game.id}`}`,
value: game.id!,
})),
)
const screenMode = computed(() => state.value?.mode || 'SCREEN_MODE_COVER')
const introAudio = computed(() => applicationFileURL(state.value?.introAudio))
const conclusionVideo = computed(() => applicationFileURL(state.value?.conclusionVideo))
function saveSession() {
sessionStorage.setItem(sessionKey, JSON.stringify({ screenId: screenId.value, accessToken: accessToken.value }))
}
function readSession() {
try {
const saved = JSON.parse(sessionStorage.getItem(sessionKey) || '{}') as {
screenId?: string
accessToken?: string
}
screenId.value = saved.screenId || ''
accessToken.value = saved.accessToken || ''
} catch {
sessionStorage.removeItem(sessionKey)
}
}
async function createSession() {
const res = await client.CreateScreenSession({})
if (res.error || !res.screenId || !res.accessToken) {
throw new Error(res.error || 'Не удалось создать сессию экрана')
}
screenId.value = res.screenId
accessToken.value = res.accessToken
saveSession()
}
async function loadState() {
if (!screenId.value || !accessToken.value) return
try {
const res = await client.GetScreenState({ screenId: screenId.value, accessToken: accessToken.value })
if (res.error) throw new Error(res.error)
state.value = res.state
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось получить состояние экрана')
}
}
async function loadGames() {
try {
const res = await client.GetGames({})
if (res.error) throw new Error(res.error)
games.value = res.games || []
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось загрузить игры')
}
}
async function bind() {
if (!selectedGameId.value || !route.query.screenId || !route.query.accessToken) return
binding.value = true
try {
const res = await client.BindScreen({
screenId: String(route.query.screenId),
accessToken: String(route.query.accessToken),
gameId: selectedGameId.value,
})
if (res.error) throw new Error(res.error)
bindSuccess.value = true
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось привязать экран')
} finally {
binding.value = false
}
}
async function prepareScreen() {
try {
await document.documentElement.requestFullscreen?.()
} catch {
message.warning('Полноэкранный режим недоступен в этом браузере')
}
prepared.value = true
}
onMounted(async () => {
if (bindingFlow.value) {
await loadGames()
loading.value = false
return
}
readSession()
try {
if (!screenId.value || !accessToken.value) await createSession()
await loadState()
pollTimer = window.setInterval(loadState, 1000)
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось подготовить экран')
} finally {
loading.value = false
}
})
onUnmounted(() => {
if (pollTimer != null) window.clearInterval(pollTimer)
})
</script>
<template>
<div v-if="bindingFlow" class="screen-center">
<n-card class="binding-card" title="Привязка экрана">
<template v-if="bindSuccess">
<p>Экран привязан к игре.</p>
<p>Можно закрыть эту страницу.</p>
</template>
<template v-else>
<p>Выберите игру, к которой привязать экран.</p>
<n-select v-model:value="selectedGameId" :options="gameOptions" placeholder="Игра" />
<n-button type="primary" :loading="binding" :disabled="selectedGameId == null" @click="bind">
Привязать
</n-button>
</template>
</n-card>
</div>
<div v-else class="screen-page">
<div v-if="loading" class="screen-center">Подготовка экрана…</div>
<template v-else>
<div v-if="!state?.gameId" class="screen-center">
<n-card class="qr-card" title="Подключение экрана">
<div v-if="qrValue" class="screen-qr">
<n-qr-code :value="qrValue" :size="260" :padding="0" />
</div>
<p>Отсканируйте QR телефоном организатора и выберите игру.</p>
<n-button v-if="!prepared" type="primary" @click="prepareScreen">Подготовить экран</n-button>
</n-card>
</div>
<div v-else class="screen-content">
<div v-if="screenMode === 'SCREEN_MODE_INTRO' && state.introAudio" class="screen-media">
<p class="media-title">Введение</p>
<audio :src="introAudio" :muted="!state.soundEnabled" autoplay controls />
</div>
<div v-else-if="screenMode === 'SCREEN_MODE_CONCLUSION' && state.conclusionVideo" class="screen-media">
<video :src="conclusionVideo" :muted="!state.soundEnabled" autoplay controls playsinline />
</div>
<div v-else-if="screenMode === 'SCREEN_MODE_INTRO_DONE'" class="screen-message">Успехов</div>
<div v-else-if="screenMode === 'SCREEN_MODE_CONCLUSION_DONE'" class="screen-message">Спасибо за игру!</div>
<div v-else class="screen-cover">
<img v-if="state.coverImage" :src="state.coverImage" class="cover-image" alt="Обложка дела" />
<p class="cover-label">{{ state.gameName || `Игра №${state.gameId}` }}</p>
<p>Обложка дела</p>
<n-button v-if="!prepared" type="primary" @click="prepareScreen">Подготовить экран</n-button>
</div>
</div>
</template>
</div>
<HeaderMenu v-if="bindingFlow" active="games" />
</template>
<style scoped>
.binding-card,
.qr-card,
.ready-card {
max-width: min(94vw, 520px);
text-align: center;
width: 100%;
}
.binding-card p,
.qr-card p,
.ready-card p {
margin: 0 0 16px;
}
.binding-card .n-select {
margin-bottom: 8px;
}
.binding-card .n-button,
.qr-card .n-button,
.ready-card .n-button {
margin-top: 8px;
}
.ready-title {
font-size: 28px;
}
.screen-qr {
background: #fff;
line-height: 0;
padding: 12px;
}
.screen-center {
align-items: center;
display: flex;
justify-content: center;
min-height: 100dvh;
padding: 20px;
}
.screen-page {
background: #111;
color: #eee;
min-height: 100dvh;
}
.screen-content {
align-items: center;
display: flex;
justify-content: center;
min-height: 100dvh;
text-align: center;
}
.screen-cover {
background: rgb(255 255 255 / 8%);
border-radius: 16px;
padding: 48px;
}
.cover-label {
font-size: clamp(28px, 6vw, 72px);
margin: 0 0 20px;
}
.cover-image {
display: block;
margin: 0 auto 24px;
max-height: 55dvh;
max-width: min(80vw, 720px);
object-fit: contain;
}
.screen-message {
font-size: clamp(42px, 10vw, 140px);
font-weight: 700;
}
.screen-media {
max-width: 90vw;
width: 900px;
}
.screen-media audio,
.screen-media video {
max-height: 75dvh;
width: 100%;
}
.media-title {
font-size: 32px;
}
</style>