generated from VLADIMIR/template_frontend
fix
This commit is contained in:
@@ -982,6 +982,16 @@ export type TicketPage = {
|
||||
status: string | undefined;
|
||||
gameStatus: string | undefined;
|
||||
usedAt?: wellKnownTimestamp;
|
||||
usedBy: string | undefined;
|
||||
};
|
||||
|
||||
export type AcceptTicketReq = {
|
||||
token: string | undefined;
|
||||
};
|
||||
|
||||
export type AcceptTicketRsp = {
|
||||
error: string | undefined;
|
||||
ticket: TicketPage | undefined;
|
||||
};
|
||||
|
||||
export interface EveningDetectiveServer {
|
||||
@@ -1035,6 +1045,7 @@ export interface EveningDetectiveServer {
|
||||
GetGame(request: GetGameReq): Promise<GetGameRsp>;
|
||||
IssueGameTickets(request: IssueGameTicketsReq): Promise<IssueGameTicketsRsp>;
|
||||
GetTicket(request: GetTicketReq): Promise<GetTicketRsp>;
|
||||
AcceptTicket(request: AcceptTicketReq): Promise<AcceptTicketRsp>;
|
||||
// Создать сессию экрана. Экран получает токен и показывает QR для привязки.
|
||||
CreateScreenSession(request: CreateScreenSessionReq): Promise<CreateScreenSessionRsp>;
|
||||
// Получить состояние экрана по его токену. Вызывается самим экраном.
|
||||
@@ -1961,6 +1972,26 @@ export function createEveningDetectiveServerClient(
|
||||
method: "GetTicket",
|
||||
}) as Promise<GetTicketRsp>;
|
||||
},
|
||||
AcceptTicket(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
if (!request.token) {
|
||||
throw new Error("missing required field request.token");
|
||||
}
|
||||
const path = `api/tickets/${request.token}/accept`; // eslint-disable-line quotes
|
||||
const body = JSON.stringify(request);
|
||||
const queryParams: string[] = [];
|
||||
let uri = path;
|
||||
if (queryParams.length > 0) {
|
||||
uri += `?${queryParams.join("&")}`
|
||||
}
|
||||
return handler({
|
||||
path: uri,
|
||||
method: "POST",
|
||||
body,
|
||||
}, {
|
||||
service: "EveningDetectiveServer",
|
||||
method: "AcceptTicket",
|
||||
}) as Promise<AcceptTicketRsp>;
|
||||
},
|
||||
CreateScreenSession(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const path = `api/screens`; // eslint-disable-line quotes
|
||||
const body = JSON.stringify(request);
|
||||
|
||||
@@ -78,6 +78,13 @@ const openMenu = ref(false)
|
||||
>
|
||||
Игры
|
||||
</div>
|
||||
<div
|
||||
class="menu-item-block"
|
||||
v-if="hasPermission('games_page')"
|
||||
@click="router.push('/tickets/check')"
|
||||
>
|
||||
Проверка билетов
|
||||
</div>
|
||||
<div
|
||||
class="menu-item-block"
|
||||
:class="{ active: props.active == 'catalog' }"
|
||||
|
||||
@@ -64,7 +64,9 @@ onMounted(load)
|
||||
<p>Покажите этот QR-код организатору.</p>
|
||||
|
||||
<p v-if="ticket.status === 'used'" class="used" role="status">
|
||||
Билет уже использован{{ ticket.usedAt ? ` ${formatGameDateTime(ticket.usedAt)}` : '' }}.
|
||||
Билет уже использован{{ ticket.usedAt ? ` ${formatGameDateTime(ticket.usedAt)}` : '' }}{{
|
||||
ticket.usedBy ? ` организатором ${ticket.usedBy}` : ''
|
||||
}}.
|
||||
</p>
|
||||
<p v-else-if="ticket.gameStatus === 'finish'" class="used" role="status">
|
||||
Игра уже завершена.
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<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.error || !result.ticket) error.value = result.error || 'Билет не найден'
|
||||
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.error || !result.ticket) error.value = result.error || 'Не удалось принять билет'
|
||||
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: 520px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scanner-video {
|
||||
background: #000;
|
||||
max-height: 50dvh;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,11 @@ const router = createRouter({
|
||||
name: 'participation',
|
||||
component: () => import('../views/ParticipationView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tickets/check',
|
||||
name: 'ticket-check',
|
||||
component: () => import('../views/TicketScannerView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tickets/:token',
|
||||
name: 'ticket',
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import TicketScannerPage from '@/components/TicketScannerPage.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TicketScannerPage />
|
||||
</template>
|
||||
Reference in New Issue
Block a user