This commit is contained in:
2026-10-04 12:46:28 +07:00
parent b1e7e2fe8b
commit d0b6a87c47
+158 -17
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Flag, Pause, Play, Reset, Settings, Trophy } from '@vicons/carbon' import { Flag, Pause, Play, Reset, Settings, Trophy } from '@vicons/carbon'
import { Icon } from '@vicons/utils' import { Icon } from '@vicons/utils'
import { NButton, NTag } from 'naive-ui' import { NAlert, NButton, NTag } from 'naive-ui'
import { import {
NCard, NCard,
NFlex, NFlex,
@@ -15,7 +15,7 @@ import {
NText, NText,
} from 'naive-ui' } from 'naive-ui'
import { useMessage } from 'naive-ui' import { useMessage } from 'naive-ui'
import { computed, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { getAuthClient } from '@/api/auth_client' import { getAuthClient } from '@/api/auth_client'
@@ -68,6 +68,15 @@ const showIssueTicketsModal = ref(false)
const ticketCount = ref(1) const ticketCount = ref(1)
const issuedTickets = ref<Ticket[]>([]) const issuedTickets = ref<Ticket[]>([])
const isIssuingTickets = ref(false) const isIssuingTickets = ref(false)
const showScreenScanner = ref(false)
const screenScannerError = ref('')
const screenScannerVideo = ref<HTMLVideoElement>()
const screenBinding = ref(false)
const screenScanning = ref(false)
let screenScannerStream: MediaStream | undefined
let screenScannerFrame = 0
type BarcodeDetector = { detect(video: HTMLVideoElement): Promise<{ rawValue: string }[]> }
const game = ref<Game>({ const game = ref<Game>({
ID: undefined, ID: undefined,
@@ -113,6 +122,7 @@ const currentScreenMode = computed<ScreenMode | undefined>(() => {
}) })
const timerScreen = computed(() => screens.value[0]) const timerScreen = computed(() => screens.value[0])
const previewScreen = computed(() => screens.value.find((screen) => screen.status === 'connected') || screens.value[0]) const previewScreen = computed(() => screens.value.find((screen) => screen.status === 'connected') || screens.value[0])
const hasDisconnectedScreen = computed(() => screens.value.some((screen) => screen.status === 'disconnected'))
const timerStateLabel = computed(() => { const timerStateLabel = computed(() => {
switch (timerScreen.value?.timerState) { switch (timerScreen.value?.timerState) {
case 'SCREEN_TIMER_STATE_RUNNING': case 'SCREEN_TIMER_STATE_RUNNING':
@@ -186,6 +196,88 @@ async function getGameScreens() {
} }
getGameScreens() getGameScreens()
function stopScreenScanner() {
cancelAnimationFrame(screenScannerFrame)
screenScannerStream?.getTracks().forEach((track) => track.stop())
screenScannerStream = undefined
screenScanning.value = false
}
function closeScreenScanner() {
stopScreenScanner()
showScreenScanner.value = false
}
function parseScreenQRCode(rawValue: string) {
try {
const url = new URL(rawValue, window.location.origin)
const screenID = url.searchParams.get('screenId')
const accessToken = url.searchParams.get('accessToken')
if (url.pathname !== '/screen' || !screenID || !accessToken) return
return { screenID, accessToken }
} catch {
return
}
}
async function bindScannedScreen(screenID: string, accessToken: string) {
if (screenBinding.value) return
screenBinding.value = true
try {
await client.BindScreen({ screenID, accessToken, gameID: Number(gameId) })
message.success('Экран привязан к игре')
await getGameScreens()
closeScreenScanner()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось привязать экран')
} finally {
screenBinding.value = false
}
}
async function startScreenScanner() {
const DetectorClass = (window as Window & { BarcodeDetector?: new () => BarcodeDetector }).BarcodeDetector
if (!DetectorClass || !navigator.mediaDevices?.getUserMedia) {
screenScannerError.value = 'Сканирование камерой не поддерживается в этом браузере.'
showScreenScanner.value = true
return
}
showScreenScanner.value = true
screenScannerError.value = ''
try {
screenScannerStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
screenScanning.value = true
await nextTick()
if (!screenScannerVideo.value) return
screenScannerVideo.value.srcObject = screenScannerStream
await screenScannerVideo.value.play()
const detector = new DetectorClass()
const scan = async () => {
if (!screenScannerVideo.value || !screenScannerStream) return
try {
const code = (await detector.detect(screenScannerVideo.value))[0]?.rawValue
if (code) {
const screen = parseScreenQRCode(code)
if (screen) {
stopScreenScanner()
await bindScannedScreen(screen.screenID, screen.accessToken)
return
}
screenScannerError.value = 'Это не QR-код экрана.'
}
screenScannerFrame = requestAnimationFrame(scan)
} catch {
screenScannerError.value = 'Не удалось распознать QR-код.'
stopScreenScanner()
}
}
screenScannerFrame = requestAnimationFrame(scan)
} catch {
screenScannerError.value = 'Не удалось открыть камеру.'
stopScreenScanner()
}
}
async function getGameMap() { async function getGameMap() {
if (dragState || mapSaving.value) return if (dragState || mapSaving.value) return
const res = await client.GetGameMap({ gameID: Number(gameId) }) const res = await client.GetGameMap({ gameID: Number(gameId) })
@@ -518,6 +610,8 @@ useSimplePolling(() => {
getGameMap() getGameMap()
}, 2000) }, 2000)
onBeforeUnmount(stopScreenScanner)
async function resetGame() { async function resetGame() {
await client.ResetGame({ await client.ResetGame({
ID: game.value.ID, ID: game.value.ID,
@@ -744,22 +838,32 @@ useSimplePolling(() => {
</n-flex> </n-flex>
</div> </div>
<div v-if="screens.length > 0" class="screens-block"> <div class="screens-block">
<p class="screens-title">Экраны</p> <n-flex align="center" justify="space-between">
<div v-for="screen in screens" :key="screen.ID" class="screen-row"> <p class="screens-title">Экраны</p>
<div> <n-button size="small" type="primary" @click="startScreenScanner">Привязать экран</n-button>
<div>Экран {{ screen.ID }}</div> </n-flex>
<div class="screen-status">{{ screen.status }}</div> <n-alert v-if="hasDisconnectedScreen" type="warning" title="Проверьте подключение экрана">
<div v-if="screen.error" class="screen-error">{{ screen.error }}</div> Один или несколько экранов не выходят на связь более минуты.
</n-alert>
<p v-if="screens.length === 0" class="screen-empty">К игре пока не привязаны экраны.</p>
<template v-else>
<div v-for="screen in screens" :key="screen.ID" class="screen-row">
<div>
<div>Экран {{ screen.ID }}</div>
<div class="screen-status">
{{ screen.status === 'connected' ? 'Онлайн' : 'Оффлайн' }}
</div>
<div v-if="screen.error" class="screen-error">{{ screen.error }}</div>
</div>
<n-flex justify="end">
<n-tag v-if="screen.soundEnabled" type="success">Со звуком</n-tag>
<n-button v-else size="small" @click="setSoundScreen(screen)">Назначить звук</n-button>
<n-button size="small" type="error" ghost @click="unbindScreen(screen)">
Отвязать
</n-button>
</n-flex>
</div> </div>
<n-flex justify="end">
<n-tag v-if="screen.soundEnabled" type="success">Со звуком</n-tag>
<n-button v-else size="small" @click="setSoundScreen(screen)">Назначить звук</n-button>
<n-button size="small" type="error" ghost @click="unbindScreen(screen)"
>Отвязать</n-button
>
</n-flex>
</div>
<n-space class="screen-mode-controls"> <n-space class="screen-mode-controls">
<n-button <n-button
size="small" size="small"
@@ -848,6 +952,7 @@ useSimplePolling(() => {
>Перемотать</n-button >Перемотать</n-button
> >
</n-space> </n-space>
</template>
</div> </div>
<div class="map-preview-layout"> <div class="map-preview-layout">
@@ -1005,6 +1110,30 @@ useSimplePolling(() => {
</div> </div>
</div> </div>
<n-modal v-model:show="showScreenScanner" @after-leave="stopScreenScanner">
<n-card
style="width: min(600px, 94vw)"
title="Привязать экран"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<template #header-extra>
<n-button @click="closeScreenScanner"> x </n-button>
</template>
<n-space vertical>
<video ref="screenScannerVideo" class="screen-scanner-video" muted playsinline />
<n-alert v-if="screenScannerError" type="error">{{ screenScannerError }}</n-alert>
<n-button v-if="!screenScanning && !screenBinding" type="primary" @click="startScreenScanner">
Сканировать QR-код
</n-button>
<n-button v-if="screenScanning" @click="stopScreenScanner">Остановить камеру</n-button>
<n-button v-if="screenBinding" type="primary" loading>Привязка…</n-button>
</n-space>
</n-card>
</n-modal>
<!-- Окно настроек --> <!-- Окно настроек -->
<n-modal v-model:show="showSettingsModal"> <n-modal v-model:show="showSettingsModal">
<n-card <n-card
@@ -1541,11 +1670,23 @@ useSimplePolling(() => {
font-size: 13px; font-size: 13px;
} }
.screen-empty {
color: #aaa;
margin: 12px 0 0;
}
.screen-error { .screen-error {
color: #f2a2a2; color: #f2a2a2;
font-size: 13px; font-size: 13px;
} }
.screen-scanner-video {
aspect-ratio: 1;
background-color: #000;
object-fit: cover;
width: 100%;
}
.screen-mode-controls { .screen-mode-controls {
margin-top: 12px; margin-top: 12px;
} }