This commit is contained in:
2026-09-18 17:48:09 +07:00
parent f2c0b1c917
commit 3925879784
87 changed files with 1391 additions and 1149 deletions
@@ -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);
+7
View File
@@ -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' }"
+3 -1
View File
@@ -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">
Игра уже завершена.
+166
View File
@@ -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>
+5
View File
@@ -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',
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import TicketScannerPage from '@/components/TicketScannerPage.vue'
</script>
<template>
<TicketScannerPage />
</template>