From 25b4954bbdd935b8a83f3fea4c7e1484af0c9ee6 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Wed, 9 Sep 2026 12:39:28 +0700 Subject: [PATCH] add qr-s --- proto/main.proto | 10 +++++ .../{archive_client.ts => binary_client.ts} | 37 +++++++++++++------ .../crabs/evening_detective_server/index.ts | 21 +++++++++++ src/components/GamePage.vue | 26 +++++++++++++ src/components/ScenariosEditorPage.vue | 4 +- src/components/ScenariosPage.vue | 6 +-- 6 files changed, 88 insertions(+), 16 deletions(-) rename src/api/{archive_client.ts => binary_client.ts} (68%) diff --git a/proto/main.proto b/proto/main.proto index 7f28583..910759a 100644 --- a/proto/main.proto +++ b/proto/main.proto @@ -484,6 +484,16 @@ service EveningDetectiveServer { }; } + rpc DownloadTeamQR(GetGameReq) returns (google.api.HttpBody) { + option (google.api.http) = { + get: "/api/games/{id}/teams/qr.pdf" + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags : "Игры"; + summary: "Скачать PDF с QR-кодами всех команд игры"; + }; + } + rpc GetGameAnswers(GetGameAnswersReq) returns (GetGameAnswersRsp) { option (google.api.http) = { get: "/api/games/{gameId}/answers" diff --git a/src/api/archive_client.ts b/src/api/binary_client.ts similarity index 68% rename from src/api/archive_client.ts rename to src/api/binary_client.ts index 78a4a78..2f50a3e 100644 --- a/src/api/archive_client.ts +++ b/src/api/binary_client.ts @@ -2,7 +2,7 @@ import { useAuthStore } from '@/stores/auth' import { getURL } from './generated/crabs/evening_detective_server/client' -export type DownloadArchiveResult = { +export type DownloadFileResult = { blob: Blob filename: string } @@ -12,15 +12,14 @@ export type UploadArchiveResult = { error: string | null } -// Клиент для бинарных операций со сценарием (ZIP-архив). Сгенерированный -// HTTP-хендлер всегда парсит ответ как JSON, поэтому для архивов используем -// сырой fetch: скачивание отдаёт байты архива, загрузка принимает их напрямую. -export function getArchiveClient() { +// Клиент бинарных файлов: ZIP сценариев и PDF команд. Сгенерированный +// HTTP-хендлер парсит ответы как JSON, поэтому здесь используется fetch. +export function getBinaryClient() { const authStore = useAuthStore() - return buildArchiveClient(authStore.refreshTokenAction, authStore.getToken) + return buildBinaryClient(authStore.refreshTokenAction, authStore.getToken) } -function buildArchiveClient(refreshTokens: () => Promise, getToken: () => string | null) { +function buildBinaryClient(refreshTokens: () => Promise, getToken: () => string | null) { async function request(path: string, init: RequestInit): Promise { const headers: Record = {} const token = getToken() @@ -49,18 +48,26 @@ function buildArchiveClient(refreshTokens: () => Promise, getToken: () return response } - async function downloadScenarioArchive(id: number): Promise { - const response = await request(`api/scenarios/${id}/archive`, { method: 'GET' }) + async function download(path: string, fallbackName: string): Promise { + const response = await request(path, { method: 'GET' }) if (!response.ok) { throw new Error(await gatewayErrorMessage(response)) } const blob = await response.blob() const filename = parseContentDispositionFilename(response.headers.get('Content-Disposition')) || - `scenario-${id}.zip` + fallbackName return { blob, filename } } + function downloadScenarioArchive(id: number): Promise { + return download(`api/scenarios/${id}/archive`, `scenario-${id}.zip`) + } + + function downloadTeamQR(id: number): Promise { + return download(`api/games/${id}/teams/qr.pdf`, `game-${id}.pdf`) + } + async function uploadScenarioArchive(file: File): Promise { const contentType = file.type || 'application/zip' const response = await request('api/scenarios/archive', { @@ -78,7 +85,7 @@ function buildArchiveClient(refreshTokens: () => Promise, getToken: () return { id: typeof data?.id === 'number' ? data.id : null, error } } - return { downloadScenarioArchive, uploadScenarioArchive } + return { downloadScenarioArchive, downloadTeamQR, uploadScenarioArchive } } // Извлекает имя файла из Content-Disposition: attachment; filename="name.zip" @@ -86,6 +93,14 @@ function parseContentDispositionFilename(value: string | null): string { if (!value) { return '' } + const utf8 = value.match(/filename\*=UTF-8''([^;]+)/i) + if (utf8) { + try { + return decodeURIComponent(utf8[1]!.trim()).replace(/[\\/:*?"<>|]/g, '_') + } catch { + // Некорректный filename*: пробуем обычный filename ниже. + } + } const match = value.match(/filename="?([^";]+)"?/i) const filename = match ? match[1] : '' return filename.replace(/[\\/:*?"<>|]/g, '_') diff --git a/src/api/generated/crabs/evening_detective_server/index.ts b/src/api/generated/crabs/evening_detective_server/index.ts index 170f1f9..9f0400c 100644 --- a/src/api/generated/crabs/evening_detective_server/index.ts +++ b/src/api/generated/crabs/evening_detective_server/index.ts @@ -729,6 +729,7 @@ export interface EveningDetectiveServer { AddGame(request: AddGameReq): Promise; GetGames(request: GetGamesReq): Promise; GetGame(request: GetGameReq): Promise; + DownloadTeamQR(request: GetGameReq): Promise; GetGameAnswers(request: GetGameAnswersReq): Promise; GetGameResults(request: GetGameResultsReq): Promise; UpdateGame(request: UpdateGameReq): Promise; @@ -1537,6 +1538,26 @@ export function createEveningDetectiveServerClient( method: "GetGame", }) as Promise; }, + DownloadTeamQR(request) { // eslint-disable-line @typescript-eslint/no-unused-vars + if (!request.id) { + throw new Error("missing required field request.id"); + } + const path = `api/games/${request.id}/teams/qr.pdf`; // 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: "DownloadTeamQR", + }) as Promise; + }, GetGameAnswers(request) { // eslint-disable-line @typescript-eslint/no-unused-vars if (!request.gameId) { throw new Error("missing required field request.gameId"); diff --git a/src/components/GamePage.vue b/src/components/GamePage.vue index 8a3930b..84a2c8a 100644 --- a/src/components/GamePage.vue +++ b/src/components/GamePage.vue @@ -8,6 +8,7 @@ import { ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import { getAuthClient } from '@/api/auth_client' +import { getBinaryClient } from '@/api/binary_client' import type { Application, Game, @@ -22,6 +23,8 @@ import { applicationFileType, applicationFileURL } from '@/utils/application' import { formatGameDateTime, parseDateWithoutTimezone } from '@/utils/datetime' const client = getAuthClient() +const binaryClient = getBinaryClient() +const isDownloadingQR = ref(false) const router = useRouter() const route = useRoute() const gameId = route.params.id @@ -95,6 +98,26 @@ async function toTeamStory(id: number, password: string, newTab: boolean = false router.push(uri) } +async function downloadTeamQR() { + if (isDownloadingQR.value || !game.value.teams?.length) return + isDownloadingQR.value = true + try { + const { blob, filename } = await binaryClient.downloadTeamQR(game.value.id!) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + link.remove() + setTimeout(() => URL.revokeObjectURL(url), 1000) + } catch (err) { + message.error(err instanceof Error ? err.message : 'Не удалось скачать QR команд') + } finally { + isDownloadingQR.value = false + } +} + function toResults() { router.push('/games/' + gameId + '/results') } @@ -410,6 +433,9 @@ useSimplePolling(() => { Итоги + + QR команд + diff --git a/src/components/ScenariosEditorPage.vue b/src/components/ScenariosEditorPage.vue index a029a52..0060113 100644 --- a/src/components/ScenariosEditorPage.vue +++ b/src/components/ScenariosEditorPage.vue @@ -17,8 +17,8 @@ import { import { computed, ref } from 'vue' import { useRoute } from 'vue-router' -import { getArchiveClient } from '@/api/archive_client' import { getAuthClient } from '@/api/auth_client' +import { getBinaryClient } from '@/api/binary_client' import type { Conclusion, Scenario } from '@/api/generated/crabs/evening_detective_server' import HeaderMenu from '@/components/HeaderMenu.vue' import IntroBlock from '@/components/IntroBlock.vue' @@ -36,7 +36,7 @@ import { applicationFileURL } from '@/utils/application' const authStore = useAuthStore() const client = getAuthClient() -const archiveClient = getArchiveClient() +const archiveClient = getBinaryClient() const route = useRoute() const scenarioId = route.params.id diff --git a/src/components/ScenariosPage.vue b/src/components/ScenariosPage.vue index 7d21b36..2ab2d88 100644 --- a/src/components/ScenariosPage.vue +++ b/src/components/ScenariosPage.vue @@ -17,13 +17,13 @@ import { import { ref } from 'vue' import { useRouter } from 'vue-router' -import { getArchiveClient } from '@/api/archive_client' import { getAuthClient } from '@/api/auth_client' +import { getBinaryClient } from '@/api/binary_client' import type { Scenario } from '@/api/generated/crabs/evening_detective_server' import HeaderMenu from '@/components/HeaderMenu.vue' const client = getAuthClient() -const archiveClient = getArchiveClient() +const archiveClient = getBinaryClient() const scenarios = ref([]) @@ -218,8 +218,8 @@ getScenarios()