This commit is contained in:
2026-10-05 01:18:19 +07:00
parent d785bb3b09
commit 7208fffe44
5 changed files with 115 additions and 72 deletions
+53 -3
View File
@@ -43,6 +43,7 @@ import GameMapPanel from '@/components/GameMapPanel.vue'
import GameScreensPanel from '@/components/GameScreensPanel.vue'
import GameTeamsPanel from '@/components/GameTeamsPanel.vue'
import HeaderMenu from '@/components/HeaderMenu.vue'
import TicketCheckModal from '@/components/TicketCheckModal.vue'
import UiBlock from '@/components/UiBlock.vue'
import { useSimplePolling } from '@/composables/useSimplePolling'
import { applicationFileType, applicationFileURL } from '@/utils/application'
@@ -54,10 +55,10 @@ const isDownloadingQR = ref(false)
const router = useRouter()
const route = useRoute()
const gameId = route.params.id
type GameTab = 'settings' | 'teams' | 'screens' | 'game' | 'answers'
type GameTab = 'settings' | 'teams' | 'screens' | 'game' | 'payment' | 'answers'
function isGameTab(value: string): value is GameTab {
return ['settings', 'teams', 'screens', 'game', 'answers'].includes(value)
return ['settings', 'teams', 'screens', 'game', 'payment', 'answers'].includes(value)
}
const queryTab = typeof route.query.tab === 'string' ? route.query.tab : ''
@@ -85,6 +86,9 @@ const deletedGameName = ref('')
const showQR = ref(false)
const urlQR = ref('')
const titleQR = ref('')
const showTicketCheckModal = ref(false)
const paymentURL =
'https://www.sberbank.ru/ru/choise_bank?requisiteNumber=79609361994&bankCode=100000000111'
const ticketCount = ref(1)
const issuedTickets = ref<Ticket[]>([])
const isIssuingTickets = ref(false)
@@ -750,7 +754,14 @@ useSimplePolling(() => {
<n-input-number v-model:value="ticketCount" :min="1" :max="100" />
<div v-for="ticket in issuedTickets" :key="ticket.ID" class="issued-ticket">
<div>Билет №{{ ticket.ID }}</div>
<a :href="ticket.URL" target="_blank" rel="noreferrer">Открыть страницу билета</a>
<a
:href="ticket.URL"
class="payment-link"
target="_blank"
rel="noreferrer"
>
Открыть страницу билета
</a>
<n-button size="small" @click="openQR(ticket.URL!, `Билет №${ticket.ID}`)">
Показать QR
</n-button>
@@ -875,6 +886,34 @@ useSimplePolling(() => {
/>
</div>
</n-tab-pane>
<n-tab-pane name="payment" tab="Оплата">
<div class="game-tab-content">
<UiBlock>
<n-flex align="center" justify="space-between">
<p class="screens-title">Билеты</p>
<n-button size="small" @click="showTicketCheckModal = true">
Проверить билет
</n-button>
</n-flex>
</UiBlock>
<UiBlock>
<p class="screens-title">Оплата игры</p>
<div class="qr-block">
<n-qr-code :value="paymentURL" :size="300" :padding="0" />
</div>
<p>
<a
:href="paymentURL"
class="payment-link"
target="_blank"
rel="noreferrer"
>
Открыть ссылку оплаты
</a>
</p>
</UiBlock>
</div>
</n-tab-pane>
<n-tab-pane name="answers" tab="Проверка ответов">
<div class="game-tab-content">
<div v-if="answersQuestions.length == 0" class="answers-empty">
@@ -911,6 +950,8 @@ useSimplePolling(() => {
</div>
</div>
<TicketCheckModal v-model:show="showTicketCheckModal" />
<!-- Окно выдачи улик -->
<n-modal v-model:show="showGiveApplicationModal">
<n-card
@@ -1211,6 +1252,15 @@ useSimplePolling(() => {
padding: 16px;
}
.payment-link {
color: #63e2b7;
}
.payment-link:focus,
.payment-link:hover {
color: #8ff0cc;
}
.give-application-image-block {
background-image: url('@/assets/images/paper_white.jpg');
background-size: cover;
-8
View File
@@ -88,14 +88,6 @@ const openMenu = ref(false)
>
Игры
</button>
<button
type="button"
class="menu-item-block"
v-if="hasPermission('games_page')"
@click="router.push('/tickets/check')"
>
Проверка билетов
</button>
<button
type="button"
class="menu-item-block"
@@ -1,18 +1,14 @@
<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 { NAlert, NButton, NCard, NInput, NModal, NSpace, useMessage } from 'naive-ui'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
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 show = defineModel<boolean>('show', { required: true })
type BarcodeDetector = { detect(video: HTMLVideoElement): Promise<{ rawValue: string }[]> }
const authStore = useAuthStore()
const router = useRouter()
const client = getAuthClient()
const message = useMessage()
const value = ref('')
@@ -20,7 +16,7 @@ const ticket = ref<TicketPage>()
const error = ref('')
const loading = ref(false)
const video = ref<HTMLVideoElement>()
const cameraOn = ref(false)
const scanning = ref(false)
let stream: MediaStream | undefined
let frame = 0
@@ -66,66 +62,91 @@ async function acceptTicket() {
}
}
async function scanCamera() {
const DetectorClass = (window as Window & { BarcodeDetector?: new () => Detector }).BarcodeDetector
async function startScanning() {
const DetectorClass = (window as Window & { BarcodeDetector?: new () => BarcodeDetector })
.BarcodeDetector
if (!DetectorClass || !navigator.mediaDevices?.getUserMedia) {
error.value = 'Сканирование камерой не поддерживается. Введите ссылку вручную.'
return
}
error.value = ''
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
cameraOn.value = true
scanning.value = true
await nextTick()
if (video.value) {
video.value.srcObject = stream
await video.value.play()
}
if (!video.value) return
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
if (!scanning.value || !video.value) return
try {
const code = (await detector.detect(video.value))[0]?.rawValue
if (code) {
value.value = code
stopScanning()
await findTicket()
return
}
frame = requestAnimationFrame(scan)
} catch {
error.value = 'Не удалось распознать QR-код.'
stopScanning()
}
frame = requestAnimationFrame(scan)
}
frame = requestAnimationFrame(scan)
} catch {
error.value = 'Не удалось открыть камеру. Введите ссылку вручную.'
stopScanning()
}
}
function stopCamera() {
cameraOn.value = false
function stopScanning() {
cancelAnimationFrame(frame)
stream?.getTracks().forEach((track) => track.stop())
stream = undefined
scanning.value = false
}
onMounted(() => {
if (!authStore.isAuthenticated) router.replace('/login')
function close() {
stopScanning()
show.value = false
}
watch(show, (isShown) => {
if (isShown) startScanning()
else stopScanning()
})
onBeforeUnmount(stopCamera)
onBeforeUnmount(stopScanning)
</script>
<template>
<HeaderMenu active="games" />
<div class="center-block-custom center-middle-block-custom scanner-page">
<n-card title="Проверка билета" class="scanner-card">
<n-modal v-model:show="show">
<n-card
style="width: min(600px, 94vw)"
title="Проверка билета"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<template #header-extra>
<n-button @click="close"> x </n-button>
</template>
<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>
<video v-if="scanning" ref="video" class="ticket-scanner-video" muted playsinline />
<n-alert v-if="error" type="error">{{ error }}</n-alert>
<n-button v-if="!scanning" @click="startScanning">Сканировать QR-код</n-button>
<n-button v-else @click="stopScanning">Остановить камеру</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>
<n-button type="primary" :loading="loading" @click="findTicket">
Проверить билет
</n-button>
<template v-if="ticket">
<n-alert :type="ticket.status === 'active' ? 'success' : 'warning'">
{{ ticket.gameName }}<br />
@@ -145,22 +166,14 @@ onBeforeUnmount(stopCamera)
</template>
</n-space>
</n-card>
</div>
</n-modal>
</template>
<style scoped>
.scanner-page {
padding: 20px;
}
.scanner-card {
max-width: 700px;
width: 100%;
}
.scanner-video {
.ticket-scanner-video {
aspect-ratio: 1;
background: #000;
background-color: #000;
max-height: 60vh;
object-fit: cover;
width: 100%;
}