generated from VLADIMIR/template_frontend
168 lines
5.3 KiB
Vue
168 lines
5.3 KiB
Vue
<script setup lang="ts">
|
|
import { NAlert, NButton, NCard, NInput, NSpace, useMessage } from 'naive-ui'
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
|
|
import { getAuthClient } from '@/api/auth_client'
|
|
import type { TicketPage } from '@/api/generated/crabs/evening_detective_server'
|
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { formatGameDateTime } from '@/utils/datetime'
|
|
|
|
type Detector = { detect(video: HTMLVideoElement): Promise<{ rawValue: string }[]> }
|
|
|
|
const authStore = useAuthStore()
|
|
const router = useRouter()
|
|
const client = getAuthClient()
|
|
const message = useMessage()
|
|
const value = ref('')
|
|
const ticket = ref<TicketPage>()
|
|
const error = ref('')
|
|
const loading = ref(false)
|
|
const video = ref<HTMLVideoElement>()
|
|
const cameraOn = ref(false)
|
|
let stream: MediaStream | undefined
|
|
let frame = 0
|
|
|
|
const token = computed(() => {
|
|
try {
|
|
return new URL(value.value.trim()).pathname.match(/\/tickets\/([^/]+)/)?.[1] || ''
|
|
} catch {
|
|
return value.value.trim()
|
|
}
|
|
})
|
|
|
|
async function findTicket() {
|
|
if (!token.value || loading.value) return
|
|
loading.value = true
|
|
error.value = ''
|
|
ticket.value = undefined
|
|
try {
|
|
const result = await client.GetTicket({ token: token.value })
|
|
if (!result.ticket) error.value = 'Билет не найден'
|
|
else ticket.value = result.ticket
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : 'Не удалось проверить билет'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function acceptTicket() {
|
|
if (!token.value || loading.value) return
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
const result = await client.AcceptTicket({ token: token.value })
|
|
if (!result.ticket) error.value = 'Не удалось принять билет'
|
|
else {
|
|
ticket.value = result.ticket
|
|
message.success('Билет принят')
|
|
}
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : 'Не удалось принять билет'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function scanCamera() {
|
|
const DetectorClass = (window as Window & { BarcodeDetector?: new () => Detector }).BarcodeDetector
|
|
if (!DetectorClass || !navigator.mediaDevices?.getUserMedia) {
|
|
error.value = 'Сканирование камерой не поддерживается. Введите ссылку вручную.'
|
|
return
|
|
}
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
|
|
cameraOn.value = true
|
|
await nextTick()
|
|
if (video.value) {
|
|
video.value.srcObject = stream
|
|
await video.value.play()
|
|
}
|
|
const detector = new DetectorClass()
|
|
const scan = async () => {
|
|
if (!cameraOn.value || !video.value) return
|
|
const codes = await detector.detect(video.value)
|
|
if (codes[0]?.rawValue) {
|
|
value.value = codes[0].rawValue
|
|
stopCamera()
|
|
await findTicket()
|
|
return
|
|
}
|
|
frame = requestAnimationFrame(scan)
|
|
}
|
|
frame = requestAnimationFrame(scan)
|
|
} catch {
|
|
error.value = 'Не удалось открыть камеру. Введите ссылку вручную.'
|
|
}
|
|
}
|
|
|
|
function stopCamera() {
|
|
cameraOn.value = false
|
|
cancelAnimationFrame(frame)
|
|
stream?.getTracks().forEach((track) => track.stop())
|
|
stream = undefined
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (!authStore.isAuthenticated) router.replace('/login')
|
|
})
|
|
onBeforeUnmount(stopCamera)
|
|
</script>
|
|
|
|
<template>
|
|
<HeaderMenu active="games" />
|
|
<div class="center-block-custom center-middle-block-custom scanner-page">
|
|
<n-card title="Проверка билета" class="scanner-card">
|
|
<n-space vertical>
|
|
<video v-if="cameraOn" ref="video" class="scanner-video" muted playsinline />
|
|
<n-button v-if="!cameraOn" @click="scanCamera">Сканировать камерой</n-button>
|
|
<n-button v-else @click="stopCamera">Остановить камеру</n-button>
|
|
<n-input
|
|
v-model:value="value"
|
|
placeholder="Или вставьте ссылку на билет"
|
|
@keyup.enter="findTicket"
|
|
/>
|
|
<n-button type="primary" :loading="loading" @click="findTicket">Проверить билет</n-button>
|
|
<n-alert v-if="error" type="error">{{ error }}</n-alert>
|
|
<template v-if="ticket">
|
|
<n-alert :type="ticket.status === 'active' ? 'success' : 'warning'">
|
|
{{ ticket.gameName }}<br />
|
|
Статус: {{ ticket.status === 'active' ? 'действителен' : 'уже использован' }}
|
|
<span v-if="ticket.usedBy"> · {{ ticket.usedBy }}</span>
|
|
<br v-if="ticket.usedAt" />
|
|
<span v-if="ticket.usedAt">Использован: {{ formatGameDateTime(ticket.usedAt) }}</span>
|
|
</n-alert>
|
|
<n-button
|
|
v-if="ticket.status === 'active' && ticket.gameStatus !== 'finish'"
|
|
type="primary"
|
|
:loading="loading"
|
|
@click="acceptTicket"
|
|
>
|
|
Принять билет
|
|
</n-button>
|
|
</template>
|
|
</n-space>
|
|
</n-card>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.scanner-page {
|
|
padding: 20px;
|
|
}
|
|
|
|
.scanner-card {
|
|
max-width: 700px;
|
|
width: 100%;
|
|
}
|
|
|
|
.scanner-video {
|
|
aspect-ratio: 1;
|
|
background: #000;
|
|
object-fit: cover;
|
|
width: 100%;
|
|
}
|
|
</style>
|