This commit is contained in:
2026-09-18 16:43:29 +07:00
parent 0107274a08
commit d71add5998
9 changed files with 291 additions and 1 deletions
+60
View File
@@ -516,6 +516,29 @@ service EveningDetectiveServer {
}; };
} }
rpc IssueGameTickets(IssueGameTicketsReq) returns (IssueGameTicketsRsp) {
option (google.api.http) = {
post: "/api/games/{gameId}/tickets"
body: "*"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
security: { security_requirement: {} }
tags: "Билеты";
summary: "Выпустить билеты на игру";
};
}
rpc GetTicket(GetTicketReq) returns (GetTicketRsp) {
option (google.api.http) = {
get: "/api/tickets/{token}"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
security: { security_requirement: {} }
tags: "Билеты";
summary: "Получить данные билета";
};
}
// Создать сессию экрана. Экран получает токен и показывает QR для привязки. // Создать сессию экрана. Экран получает токен и показывает QR для привязки.
rpc CreateScreenSession(CreateScreenSessionReq) returns (CreateScreenSessionRsp) { rpc CreateScreenSession(CreateScreenSessionReq) returns (CreateScreenSessionRsp) {
option (google.api.http) = { option (google.api.http) = {
@@ -1354,6 +1377,7 @@ message AddGameReq {
google.protobuf.Timestamp start_at = 3; google.protobuf.Timestamp start_at = 3;
int32 scenario_id = 4; int32 scenario_id = 4;
string format = 5; string format = 5;
string location = 6;
} }
message AddGameRsp { message AddGameRsp {
@@ -1379,6 +1403,7 @@ message Game {
optional google.protobuf.Timestamp started_at = 8; optional google.protobuf.Timestamp started_at = 8;
optional google.protobuf.Timestamp ended_at = 9; optional google.protobuf.Timestamp ended_at = 9;
string format = 10; string format = 10;
string location = 11;
} }
enum ScreenMode { enum ScreenMode {
@@ -1695,6 +1720,7 @@ message UpdateGameReq {
google.protobuf.Timestamp start_at = 4; google.protobuf.Timestamp start_at = 4;
int32 scenario_id = 5; int32 scenario_id = 5;
string format = 6; string format = 6;
string location = 7;
} }
message UpdateGameRsp { message UpdateGameRsp {
@@ -1861,3 +1887,37 @@ message UpdateScenarioBackgroundReq {
message UpdateScenarioBackgroundRsp { message UpdateScenarioBackgroundRsp {
string error = 1; string error = 1;
} }
message IssueGameTicketsReq {
int32 gameId = 1;
int32 count = 2;
}
message IssueGameTicketsRsp {
string error = 1;
repeated Ticket tickets = 2;
}
message Ticket {
int32 id = 1;
string url = 2;
}
message GetTicketReq {
string token = 1;
}
message GetTicketRsp {
string error = 1;
TicketPage ticket = 2;
}
message TicketPage {
int32 id = 1;
string game_name = 2;
google.protobuf.Timestamp start_at = 3;
string location = 4;
string status = 5;
string game_status = 6;
optional google.protobuf.Timestamp used_at = 7;
}
@@ -474,6 +474,7 @@ export type AddGameReq = {
startAt: wellKnownTimestamp | undefined; startAt: wellKnownTimestamp | undefined;
scenarioId: number | undefined; scenarioId: number | undefined;
format: string | undefined; format: string | undefined;
location: string | undefined;
}; };
export type AddGameRsp = { export type AddGameRsp = {
@@ -500,6 +501,7 @@ export type Game = {
startedAt?: wellKnownTimestamp; startedAt?: wellKnownTimestamp;
endedAt?: wellKnownTimestamp; endedAt?: wellKnownTimestamp;
format: string | undefined; format: string | undefined;
location: string | undefined;
}; };
export type ScreenState = { export type ScreenState = {
@@ -780,6 +782,7 @@ export type UpdateGameReq = {
startAt: wellKnownTimestamp | undefined; startAt: wellKnownTimestamp | undefined;
scenarioId: number | undefined; scenarioId: number | undefined;
format: string | undefined; format: string | undefined;
location: string | undefined;
}; };
export type UpdateGameRsp = { export type UpdateGameRsp = {
@@ -947,6 +950,40 @@ export type UpdateScenarioBackgroundRsp = {
error: string | undefined; error: string | undefined;
}; };
export type IssueGameTicketsReq = {
gameId: number | undefined;
count: number | undefined;
};
export type IssueGameTicketsRsp = {
error: string | undefined;
tickets: Ticket[] | undefined;
};
export type Ticket = {
id: number | undefined;
url: string | undefined;
};
export type GetTicketReq = {
token: string | undefined;
};
export type GetTicketRsp = {
error: string | undefined;
ticket: TicketPage | undefined;
};
export type TicketPage = {
id: number | undefined;
gameName: string | undefined;
startAt: wellKnownTimestamp | undefined;
location: string | undefined;
status: string | undefined;
gameStatus: string | undefined;
usedAt?: wellKnownTimestamp;
};
export interface EveningDetectiveServer { export interface EveningDetectiveServer {
GetParticipation(request: GetParticipationReq): Promise<ParticipationRsp>; GetParticipation(request: GetParticipationReq): Promise<ParticipationRsp>;
AnswerParticipation(request: AnswerParticipationReq): Promise<ParticipationRsp>; AnswerParticipation(request: AnswerParticipationReq): Promise<ParticipationRsp>;
@@ -996,6 +1033,8 @@ export interface EveningDetectiveServer {
AddGame(request: AddGameReq): Promise<AddGameRsp>; AddGame(request: AddGameReq): Promise<AddGameRsp>;
GetGames(request: GetGamesReq): Promise<GetGamesRsp>; GetGames(request: GetGamesReq): Promise<GetGamesRsp>;
GetGame(request: GetGameReq): Promise<GetGameRsp>; GetGame(request: GetGameReq): Promise<GetGameRsp>;
IssueGameTickets(request: IssueGameTicketsReq): Promise<IssueGameTicketsRsp>;
GetTicket(request: GetTicketReq): Promise<GetTicketRsp>;
// Создать сессию экрана. Экран получает токен и показывает QR для привязки. // Создать сессию экрана. Экран получает токен и показывает QR для привязки.
CreateScreenSession(request: CreateScreenSessionReq): Promise<CreateScreenSessionRsp>; CreateScreenSession(request: CreateScreenSessionReq): Promise<CreateScreenSessionRsp>;
// Получить состояние экрана по его токену. Вызывается самим экраном. // Получить состояние экрана по его токену. Вызывается самим экраном.
@@ -1882,6 +1921,46 @@ export function createEveningDetectiveServerClient(
method: "GetGame", method: "GetGame",
}) as Promise<GetGameRsp>; }) as Promise<GetGameRsp>;
}, },
IssueGameTickets(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.gameId) {
throw new Error("missing required field request.gameId");
}
const path = `api/games/${request.gameId}/tickets`; // 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: "IssueGameTickets",
}) as Promise<IssueGameTicketsRsp>;
},
GetTicket(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}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "GET",
body,
}, {
service: "EveningDetectiveServer",
method: "GetTicket",
}) as Promise<GetTicketRsp>;
},
CreateScreenSession(request) { // eslint-disable-line @typescript-eslint/no-unused-vars CreateScreenSession(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/screens`; // eslint-disable-line quotes const path = `api/screens`; // eslint-disable-line quotes
const body = JSON.stringify(request); const body = JSON.stringify(request);
+11 -1
View File
@@ -48,7 +48,7 @@ const gameId = route.params.id
const message = useMessage() const message = useMessage()
const showSettingsModal = ref(false) const showSettingsModal = ref(false)
const settingsDraft = ref({ name: '', description: '', format: 'online' }) const settingsDraft = ref({ name: '', description: '', format: 'online', location: '' })
const showGiveApplicationModal = ref(false) const showGiveApplicationModal = ref(false)
const showAddTeamModal = ref(false) const showAddTeamModal = ref(false)
const showDeleteTeamModal = ref(false) const showDeleteTeamModal = ref(false)
@@ -67,6 +67,7 @@ const game = ref<Game>({
startAt: undefined, startAt: undefined,
status: undefined, status: undefined,
format: undefined, format: undefined,
location: undefined,
scenario: undefined, scenario: undefined,
teams: undefined, teams: undefined,
}) })
@@ -321,6 +322,7 @@ function openSettings() {
name: game.value.name || '', name: game.value.name || '',
description: game.value.description || '', description: game.value.description || '',
format: game.value.format || 'online', format: game.value.format || 'online',
location: game.value.location || '',
} }
showSettingsModal.value = true showSettingsModal.value = true
} }
@@ -335,6 +337,7 @@ async function updateGame() {
name: settingsDraft.value.name, name: settingsDraft.value.name,
description: settingsDraft.value.description, description: settingsDraft.value.description,
format: settingsDraft.value.format, format: settingsDraft.value.format,
location: settingsDraft.value.location.trim(),
startAt: game.value.startAt, startAt: game.value.startAt,
scenarioId: game.value.scenario?.id, scenarioId: game.value.scenario?.id,
}) })
@@ -981,6 +984,13 @@ useSimplePolling(() => {
type="textarea" type="textarea"
placeholder="Убийство пастуха..." placeholder="Убийство пастуха..."
/> />
<p class="settings-header">Место проведения</p>
<n-input
v-model:value="settingsDraft.location"
type="text"
placeholder="Оставьте пустым для онлайн-игры"
/>
</n-space> </n-space>
<template #footer> <template #footer>
<n-flex justify="end"> <n-flex justify="end">
+11
View File
@@ -48,6 +48,7 @@ const showCreateGameModal = ref(false)
const newGameName = ref('') const newGameName = ref('')
const newGameFormat = ref('online') const newGameFormat = ref('online')
const newGameDescription = ref('') const newGameDescription = ref('')
const newGameLocation = ref('')
const newGameScenarioId = ref<number | null>(null) const newGameScenarioId = ref<number | null>(null)
const newGameStartAt = ref<number | null>(null) const newGameStartAt = ref<number | null>(null)
const isCreating = ref(false) const isCreating = ref(false)
@@ -85,6 +86,7 @@ function openCreateGame() {
newGameName.value = '' newGameName.value = ''
newGameFormat.value = 'online' newGameFormat.value = 'online'
newGameDescription.value = '' newGameDescription.value = ''
newGameLocation.value = ''
newGameScenarioId.value = null newGameScenarioId.value = null
newGameStartAt.value = null newGameStartAt.value = null
showCreateGameModal.value = true showCreateGameModal.value = true
@@ -103,6 +105,7 @@ async function createGame() {
const res = await client.AddGame({ const res = await client.AddGame({
name: name, name: name,
description: newGameDescription.value, description: newGameDescription.value,
location: newGameLocation.value.trim(),
format: newGameFormat.value, format: newGameFormat.value,
// Сервер парсит startAt как google.protobuf.Timestamp — нужен RFC 3339 с таймзоной // Сервер парсит startAt как google.protobuf.Timestamp — нужен RFC 3339 с таймзоной
startAt: new Date(startAtMs).toISOString(), startAt: new Date(startAtMs).toISOString(),
@@ -143,6 +146,7 @@ async function createGame() {
<div v-if="formatGameDateTime(game.startAt)" class="game-date"> <div v-if="formatGameDateTime(game.startAt)" class="game-date">
Начало: {{ formatGameDateTime(game.startAt) }} Начало: {{ formatGameDateTime(game.startAt) }}
</div> </div>
<div v-if="game.location" class="game-date">Место: {{ game.location }}</div>
<div v-if="game.status == 'finish'" class="game-actions"> <div v-if="game.status == 'finish'" class="game-actions">
<n-button size="small" ghost @click.stop="toResults(game.id!)">Результаты</n-button> <n-button size="small" ghost @click.stop="toResults(game.id!)">Результаты</n-button>
</div> </div>
@@ -185,6 +189,13 @@ async function createGame() {
placeholder="Убийство пастуха..." placeholder="Убийство пастуха..."
/> />
<p>Место проведения</p>
<n-input
v-model:value="newGameLocation"
type="text"
placeholder="Оставьте пустым для онлайн-игры"
/>
<p>Сценарий (дело из каталога)</p> <p>Сценарий (дело из каталога)</p>
<n-select <n-select
v-model:value="newGameScenarioId" v-model:value="newGameScenarioId"
+1
View File
@@ -194,6 +194,7 @@ async function addGame(name: string, id: number) {
const resAddGame = await client.AddGame({ const resAddGame = await client.AddGame({
name: ('Тестовая игра: ' + name).trim(), name: ('Тестовая игра: ' + name).trim(),
description: 'Тестовая игра', description: 'Тестовая игра',
location: '',
format: 'online', format: 'online',
startAt: now, startAt: now,
scenarioId: id, scenarioId: id,
+1
View File
@@ -45,6 +45,7 @@ const game = ref<Game | undefined>({
startAt: undefined, startAt: undefined,
status: undefined, status: undefined,
format: undefined, format: undefined,
location: undefined,
scenario: undefined, scenario: undefined,
teams: undefined, teams: undefined,
}) })
+116
View File
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { NButton, NCard, NQrCode } from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import type { TicketPage as TicketData } from '@/api/generated/crabs/evening_detective_server'
import { buildClient } from '@/api/generated/crabs/evening_detective_server/client'
import { formatGameDateTime } from '@/utils/datetime'
const route = useRoute()
const client = buildClient()
const token = computed(() => String(route.params.token || ''))
const ticket = ref<TicketData>()
const loading = ref(false)
const error = ref('')
const ticketURL = computed(() => window.location.href)
async function load() {
loading.value = true
error.value = ''
try {
const result = await client.GetTicket({ token: token.value })
if (result.error || !result.ticket) {
error.value = result.error || 'Билет не найден'
return
}
ticket.value = result.ticket
} catch {
error.value = 'Не удалось загрузить билет. Попробуйте ещё раз.'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<div class="ticket-page center-block-custom">
<div class="width700">
<n-card class="ticket-card" title="Билет на игру">
<p v-if="error" class="error" role="alert">{{ error }}</p>
<template v-else-if="ticket">
<h2>{{ ticket.gameName }}</h2>
<dl>
<div>
<dt>Начало</dt>
<dd>{{ formatGameDateTime(ticket.startAt) }}</dd>
</div>
<div>
<dt>Место</dt>
<dd>{{ ticket.location || 'Онлайн' }}</dd>
</div>
</dl>
<div class="qr-block">
<n-qr-code :value="ticketURL" :size="240" />
<p>Покажите этот QR-код организатору на входе.</p>
</div>
<p v-if="ticket.status === 'used'" class="used" role="status">
Билет уже использован{{ ticket.usedAt ? ` ${formatGameDateTime(ticket.usedAt)}` : '' }}.
</p>
<p v-else-if="ticket.gameStatus === 'finish'" class="used" role="status">
Игра уже завершена.
</p>
<p v-else class="active" role="status">Билет действителен.</p>
</template>
<n-button v-else :loading="loading" @click="load">
{{ loading ? 'Загрузка' : 'Повторить' }}
</n-button>
</n-card>
</div>
</div>
</template>
<style scoped>
.ticket-card {
margin-top: 10px;
}
h2 {
margin-bottom: 20px;
}
dl {
display: grid;
gap: 12px;
}
dt {
color: #aaa;
font-size: 13px;
}
dd {
margin: 0;
}
.qr-block {
display: grid;
gap: 12px;
justify-items: center;
margin: 28px 0;
text-align: center;
}
.error,
.used {
color: #aaa;
}
.active {
color: #63e2b7;
}
</style>
+5
View File
@@ -8,6 +8,11 @@ const router = createRouter({
name: 'participation', name: 'participation',
component: () => import('../views/ParticipationView.vue'), component: () => import('../views/ParticipationView.vue'),
}, },
{
path: '/tickets/:token',
name: 'ticket',
component: () => import('../views/TicketView.vue'),
},
{ {
path: '/', path: '/',
name: 'home', name: 'home',
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import TicketPage from '@/components/TicketPage.vue'
</script>
<template>
<TicketPage />
</template>