diff --git a/src/api/binary_client.ts b/src/api/binary_client.ts index ecf5e5b..64f60d1 100644 --- a/src/api/binary_client.ts +++ b/src/api/binary_client.ts @@ -1,3 +1,4 @@ +import type { GameImportRsp } from '@/api/generated/crabs/evening_detective_server' import { useAuthStore } from '@/stores/auth' import { retryWithAuth } from './authRetry' @@ -12,7 +13,7 @@ export type UploadArchiveResult = { ID: number | null } -// Клиент бинарных файлов: ZIP сценариев и PDF команд. Сгенерированный +// Клиент бинарных файлов: ZIP сценариев и игр, PDF команд. Сгенерированный // HTTP-хендлер парсит ответы как JSON, поэтому здесь используется fetch. export function getBinaryClient() { const authStore = useAuthStore() @@ -45,8 +46,7 @@ function buildBinaryClient( } const blob = await response.blob() const filename = - parseContentDispositionFilename(response.headers.get('Content-Disposition')) || - fallbackName + parseContentDispositionFilename(response.headers.get('Content-Disposition')) || fallbackName return { blob, filename } } @@ -58,6 +58,34 @@ function buildBinaryClient( return download(`api/games/${id}/teams/qr.pdf`, `game-${id}.pdf`) } + function downloadGamePackage(id: number): Promise { + return download(`api/games/${id}/export/download`, `game-${id}.zip`) + } + + async function uploadGamePackage(file: File): Promise { + const response = await request('api/games/import', { + method: 'POST', + headers: { 'Content-Type': 'application/zip' }, + body: file, + }) + if (!response.ok) throw new Error(await gatewayErrorMessage(response)) + const data: unknown = await response.json() + if ( + !data || + typeof data !== 'object' || + !('gameID' in data) || + typeof data.gameID !== 'number' || + !Number.isSafeInteger(data.gameID) || + data.gameID <= 0 || + !('packageID' in data) || + typeof data.packageID !== 'string' || + !('repeated' in data) || + typeof data.repeated !== 'boolean' + ) + throw new Error('Некорректный ответ импорта игры') + return { gameID: data.gameID, packageID: data.packageID, repeated: data.repeated } + } + async function uploadScenarioArchive(file: File): Promise { const contentType = file.type || 'application/zip' const response = await request('api/scenarios/archive', { @@ -66,15 +94,19 @@ function buildBinaryClient( body: file, }) + if (!response.ok) throw new Error(await gatewayErrorMessage(response)) const data = await response.json().catch(() => null) - if (!response.ok) { - throw new Error(data?.message || `Ошибка сервера (${response.status})`) - } return { ID: typeof data?.ID === 'number' ? data.ID : null } } - return { downloadScenarioArchive, downloadTeamQR, uploadScenarioArchive } + return { + downloadScenarioArchive, + downloadTeamQR, + uploadScenarioArchive, + downloadGamePackage, + uploadGamePackage, + } } // Извлекает имя файла из Content-Disposition: attachment; filename="name.zip" @@ -95,11 +127,13 @@ function parseContentDispositionFilename(value: string | null): string { return filename.replace(/[\\/:*?"<>|]/g, '_') } -// Ошибки grpc-gateway приходят JSON-объектом {"code": ..., "message": ...} +// grpc-gateway и бинарные хендлеры используют разные поля ошибки. async function gatewayErrorMessage(response: Response): Promise { - const data = await response.json().catch(() => null) - if (data && typeof data.message === 'string' && data.message !== '') { - return data.message + const data: unknown = await response.json().catch(() => null) + if (data && typeof data === 'object') { + if ('message' in data && typeof data.message === 'string' && data.message !== '') + return data.message + if ('error' in data && typeof data.error === 'string' && data.error !== '') return data.error } return `Ошибка сервера (${response.status})` } diff --git a/src/components/GamePage.vue b/src/components/GamePage.vue index 05f67ee..a9ea3f1 100644 --- a/src/components/GamePage.vue +++ b/src/components/GamePage.vue @@ -49,6 +49,8 @@ import HeaderMenu from '@/components/HeaderMenu.vue' import TicketCheckModal from '@/components/TicketCheckModal.vue' import UiBlock from '@/components/UiBlock.vue' import { useSimplePolling } from '@/composables/useSimplePolling' +import { useAuthStore } from '@/stores/auth' +import { useUIStore } from '@/stores/ui' import { applicationFileType, applicationFileURL } from '@/utils/application' import { formatGameDateTime, parseDateWithoutTimezone } from '@/utils/datetime' @@ -109,6 +111,14 @@ const game = ref({ teams: undefined, export: undefined, }) +const authStore = useAuthStore() +const uiStore = useUIStore() +const canExport = computed( + () => uiStore.environment === 'prod' && authStore.hasAnyRole(['admin', 'organizer', 'developer']), +) +const isStartingExport = ref(false) +const isDownloadingPackage = ref(false) + const participationOptions = [ { label: 'Ожидает ответа', value: 'pending' }, { label: 'Подтверждено', value: 'confirmed' }, @@ -466,6 +476,46 @@ async function toTeamStory(id: number, password: string, newTab: boolean = false router.push(uri) } +async function preparePackage() { + if ( + !canExport.value || + isStartingExport.value || + game.value.export?.state === 'preparing' || + game.value.export?.state === 'ready' || + game.value.status !== 'draft' + ) + return + isStartingExport.value = true + try { + const res = await client.StartGameExport({ ID: game.value.ID }) + game.value.export = res.export + } catch (err) { + message.error(err instanceof Error ? err.message : 'Не удалось подготовить пакет') + } finally { + isStartingExport.value = false + } +} + +async function downloadPackage() { + if (!canExport.value || isDownloadingPackage.value || game.value.export?.state !== 'ready') return + isDownloadingPackage.value = true + try { + const { blob, filename } = await binaryClient.downloadGamePackage(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 : 'Не удалось скачать пакет') + } finally { + isDownloadingPackage.value = false + } +} + async function downloadTeamQR() { if (isDownloadingQR.value || !game.value.teams?.length) return isDownloadingQR.value = true @@ -855,18 +905,54 @@ useSimplePolling(() => { Сохранить изменения + +

Передача игры на площадку

+ +

+ Пакет содержит снимок на момент подготовки. Последующие онлайн-изменения в него не + попадут. Готовность пакета не подтверждает импорт на площадку. +

+ Подготовка пакета… + Пакет готов + {{ + game.export.failure || 'Не удалось подготовить пакет' + }} + Скачать пакет + {{ + game.export?.state === 'failed' ? 'Повторить подготовку' : 'Подготовить пакет' + }} +

+ Подготовка доступна до старта игры. +

+
+

Выпуск билетов

Билет №{{ ticket.ID }}
- + Открыть страницу билета diff --git a/src/components/GamesPage.vue b/src/components/GamesPage.vue index 772e789..b40d894 100644 --- a/src/components/GamesPage.vue +++ b/src/components/GamesPage.vue @@ -1,5 +1,6 @@