Files
evening_detective_frontend/src/components/TicketScannerPage.vue
T
2026-10-03 02:38:11 +07:00

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>