This commit is contained in:
2026-10-11 02:37:34 +07:00
parent 6de5c336a2
commit 053f9bac68
4 changed files with 339 additions and 19 deletions
+45 -11
View File
@@ -1,3 +1,4 @@
import type { GameImportRsp } from '@/api/generated/crabs/evening_detective_server'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { retryWithAuth } from './authRetry' import { retryWithAuth } from './authRetry'
@@ -12,7 +13,7 @@ export type UploadArchiveResult = {
ID: number | null ID: number | null
} }
// Клиент бинарных файлов: ZIP сценариев и PDF команд. Сгенерированный // Клиент бинарных файлов: ZIP сценариев и игр, PDF команд. Сгенерированный
// HTTP-хендлер парсит ответы как JSON, поэтому здесь используется fetch. // HTTP-хендлер парсит ответы как JSON, поэтому здесь используется fetch.
export function getBinaryClient() { export function getBinaryClient() {
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -45,8 +46,7 @@ function buildBinaryClient(
} }
const blob = await response.blob() const blob = await response.blob()
const filename = const filename =
parseContentDispositionFilename(response.headers.get('Content-Disposition')) || parseContentDispositionFilename(response.headers.get('Content-Disposition')) || fallbackName
fallbackName
return { blob, filename } return { blob, filename }
} }
@@ -58,6 +58,34 @@ function buildBinaryClient(
return download(`api/games/${id}/teams/qr.pdf`, `game-${id}.pdf`) return download(`api/games/${id}/teams/qr.pdf`, `game-${id}.pdf`)
} }
function downloadGamePackage(id: number): Promise<DownloadFileResult> {
return download(`api/games/${id}/export/download`, `game-${id}.zip`)
}
async function uploadGamePackage(file: File): Promise<GameImportRsp> {
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<UploadArchiveResult> { async function uploadScenarioArchive(file: File): Promise<UploadArchiveResult> {
const contentType = file.type || 'application/zip' const contentType = file.type || 'application/zip'
const response = await request('api/scenarios/archive', { const response = await request('api/scenarios/archive', {
@@ -66,15 +94,19 @@ function buildBinaryClient(
body: file, body: file,
}) })
if (!response.ok) throw new Error(await gatewayErrorMessage(response))
const data = await response.json().catch(() => null) 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 { ID: typeof data?.ID === 'number' ? data.ID : null }
} }
return { downloadScenarioArchive, downloadTeamQR, uploadScenarioArchive } return {
downloadScenarioArchive,
downloadTeamQR,
uploadScenarioArchive,
downloadGamePackage,
uploadGamePackage,
}
} }
// Извлекает имя файла из Content-Disposition: attachment; filename="name.zip" // Извлекает имя файла из Content-Disposition: attachment; filename="name.zip"
@@ -95,11 +127,13 @@ function parseContentDispositionFilename(value: string | null): string {
return filename.replace(/[\\/:*?"<>|]/g, '_') return filename.replace(/[\\/:*?"<>|]/g, '_')
} }
// Ошибки grpc-gateway приходят JSON-объектом {"code": ..., "message": ...} // grpc-gateway и бинарные хендлеры используют разные поля ошибки.
async function gatewayErrorMessage(response: Response): Promise<string> { async function gatewayErrorMessage(response: Response): Promise<string> {
const data = await response.json().catch(() => null) const data: unknown = await response.json().catch(() => null)
if (data && typeof data.message === 'string' && data.message !== '') { if (data && typeof data === 'object') {
return data.message 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})` return `Ошибка сервера (${response.status})`
} }
+92 -6
View File
@@ -49,6 +49,8 @@ import HeaderMenu from '@/components/HeaderMenu.vue'
import TicketCheckModal from '@/components/TicketCheckModal.vue' import TicketCheckModal from '@/components/TicketCheckModal.vue'
import UiBlock from '@/components/UiBlock.vue' import UiBlock from '@/components/UiBlock.vue'
import { useSimplePolling } from '@/composables/useSimplePolling' import { useSimplePolling } from '@/composables/useSimplePolling'
import { useAuthStore } from '@/stores/auth'
import { useUIStore } from '@/stores/ui'
import { applicationFileType, applicationFileURL } from '@/utils/application' import { applicationFileType, applicationFileURL } from '@/utils/application'
import { formatGameDateTime, parseDateWithoutTimezone } from '@/utils/datetime' import { formatGameDateTime, parseDateWithoutTimezone } from '@/utils/datetime'
@@ -109,6 +111,14 @@ const game = ref<Game>({
teams: undefined, teams: undefined,
export: 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 = [ const participationOptions = [
{ label: 'Ожидает ответа', value: 'pending' }, { label: 'Ожидает ответа', value: 'pending' },
{ label: 'Подтверждено', value: 'confirmed' }, { label: 'Подтверждено', value: 'confirmed' },
@@ -466,6 +476,46 @@ async function toTeamStory(id: number, password: string, newTab: boolean = false
router.push(uri) 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() { async function downloadTeamQR() {
if (isDownloadingQR.value || !game.value.teams?.length) return if (isDownloadingQR.value || !game.value.teams?.length) return
isDownloadingQR.value = true isDownloadingQR.value = true
@@ -855,18 +905,54 @@ useSimplePolling(() => {
<n-button @click="updateGame()">Сохранить изменения</n-button> <n-button @click="updateGame()">Сохранить изменения</n-button>
</n-flex> </n-flex>
</UiBlock> </UiBlock>
<UiBlock v-if="canExport">
<p class="screens-title">Передача игры на площадку</p>
<n-space vertical>
<p>
Пакет содержит снимок на момент подготовки. Последующие онлайн-изменения в него не
попадут. Готовность пакета не подтверждает импорт на площадку.
</p>
<n-alert v-if="game.export?.state === 'preparing'" type="info" role="status"
>Подготовка пакета…</n-alert
>
<n-alert v-else-if="game.export?.state === 'ready'" type="success" role="status"
>Пакет готов</n-alert
>
<n-alert v-else-if="game.export?.state === 'failed'" type="error" role="alert">{{
game.export.failure || 'Не удалось подготовить пакет'
}}</n-alert>
<n-button
v-if="game.export?.state === 'ready'"
:loading="isDownloadingPackage"
:disabled="isDownloadingPackage"
@click="downloadPackage"
>Скачать пакет</n-button
>
<n-button
v-else
:loading="isStartingExport || game.export?.state === 'preparing'"
:disabled="
isStartingExport ||
game.export?.state === 'preparing' ||
game.status !== 'draft'
"
@click="preparePackage"
>{{
game.export?.state === 'failed' ? 'Повторить подготовку' : 'Подготовить пакет'
}}</n-button
>
<p v-if="game.status && game.status !== 'draft'">
Подготовка доступна до старта игры.
</p>
</n-space>
</UiBlock>
<UiBlock> <UiBlock>
<p class="screens-title">Выпуск билетов</p> <p class="screens-title">Выпуск билетов</p>
<n-space vertical> <n-space vertical>
<n-input-number v-model:value="ticketCount" :min="1" :max="100" /> <n-input-number v-model:value="ticketCount" :min="1" :max="100" />
<div v-for="ticket in issuedTickets" :key="ticket.ID" class="issued-ticket"> <div v-for="ticket in issuedTickets" :key="ticket.ID" class="issued-ticket">
<div>Билет №{{ ticket.ID }}</div> <div>Билет №{{ ticket.ID }}</div>
<a <a :href="ticket.URL" class="payment-link" target="_blank" rel="noreferrer">
:href="ticket.URL"
class="payment-link"
target="_blank"
rel="noreferrer"
>
Открыть страницу билета Открыть страницу билета
</a> </a>
<n-button size="small" @click="openQR(ticket.URL!, `Билет №${ticket.ID}`)"> <n-button size="small" @click="openQR(ticket.URL!, `Билет №${ticket.ID}`)">
+144 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
NAlert,
NButton, NButton,
NCard, NCard,
NDatePicker, NDatePicker,
@@ -9,14 +10,19 @@ import {
NSelect, NSelect,
NSpace, NSpace,
NTag, NTag,
NUpload,
type UploadFileInfo,
useMessage, useMessage,
} from 'naive-ui' } from 'naive-ui'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getAuthClient } from '@/api/auth_client' import { getAuthClient } from '@/api/auth_client'
import type { Game, Scenario } from '@/api/generated/crabs/evening_detective_server' import { getBinaryClient } from '@/api/binary_client'
import type { Game, GameImportRsp, Scenario } from '@/api/generated/crabs/evening_detective_server'
import HeaderMenu from '@/components/HeaderMenu.vue' import HeaderMenu from '@/components/HeaderMenu.vue'
import { useAuthStore } from '@/stores/auth'
import { useUIStore } from '@/stores/ui'
import { formatGameDateTime } from '@/utils/datetime' import { formatGameDateTime } from '@/utils/datetime'
const client = getAuthClient() const client = getAuthClient()
@@ -25,6 +31,80 @@ const router = useRouter()
const games = ref<Game[]>([]) const games = ref<Game[]>([])
const uiStore = useUIStore()
const authStore = useAuthStore()
const binaryClient = getBinaryClient()
const canImport = computed(
() =>
uiStore.environment === 'venue' && authStore.hasAnyRole(['admin', 'organizer', 'developer']),
)
const showImportModal = ref(false)
const importFiles = ref<UploadFileInfo[]>([])
const importResult = ref<GameImportRsp | null>(null)
const importError = ref('')
const isImporting = ref(false)
const isOpeningImport = ref(false)
function openImport() {
importFiles.value = []
importResult.value = null
importError.value = ''
showImportModal.value = true
}
function changeImportFile({ fileList }: { fileList: UploadFileInfo[] }) {
if (isImporting.value) return
importFiles.value = fileList.slice(-1)
importError.value = ''
importResult.value = null
}
async function importGame() {
if (!canImport.value || isImporting.value) return
const file = importFiles.value[0]?.file
if (!file || file.size === 0) {
importError.value = 'Выберите непустой ZIP-файл'
return
}
if (!file.name.toLowerCase().endsWith('.zip')) {
importError.value = 'Выберите файл с расширением .zip'
return
}
if (file.size > 1024 ** 3) {
importError.value = 'Размер пакета не должен превышать 1 ГиБ'
return
}
isImporting.value = true
importError.value = ''
try {
importResult.value = await binaryClient.uploadGamePackage(file)
await getGames()
} catch (err) {
importError.value = err instanceof Error ? err.message : 'Не удалось импортировать игру'
} finally {
isImporting.value = false
}
}
async function openImportedGame() {
const id = importResult.value?.gameID
if (!id || isOpeningImport.value) return
isOpeningImport.value = true
importError.value = ''
try {
const res = await client.GetGame({ ID: id })
if (!res.game?.ID)
throw new Error(
'Импортированная игра недоступна или удалена. Повторный импорт пакета не восстанавливает удалённую игру.',
)
await router.push('/games/' + id)
} catch (err) {
importError.value = err instanceof Error ? err.message : 'Не удалось проверить доступность игры'
} finally {
isOpeningImport.value = false
}
}
async function getGames() { async function getGames() {
try { try {
games.value = [] games.value = []
@@ -121,6 +201,7 @@ async function createGame() {
<template> <template>
<div class="center-block-custom"> <div class="center-block-custom">
<div class="width1200"> <div class="width1200">
<n-button v-if="canImport" @click="openImport">Импорт игры</n-button>
<div class="game-block game-block-hover" @click="openCreateGame()"> <div class="game-block game-block-hover" @click="openCreateGame()">
<div class="game-create-content"> <div class="game-create-content">
<span class="game-create-plus">+</span> <span class="game-create-plus">+</span>
@@ -152,6 +233,64 @@ async function createGame() {
</div> </div>
</div> </div>
<n-modal
v-model:show="showImportModal"
:mask-closable="!isImporting"
:close-on-esc="!isImporting"
>
<n-card
style="width: min(94vw, 600px)"
title="Импорт игры"
:bordered="false"
role="dialog"
aria-modal="true"
>
<template #header-extra>
<n-button
:disabled="isImporting"
aria-label="Закрыть импорт"
@click="showImportModal = false"
>Закрыть</n-button
>
</template>
<n-space vertical>
<p>Выберите один ZIP-пакет размером до 1 ГиБ. Содержимое проверит сервер.</p>
<n-upload
:file-list="importFiles"
:default-upload="false"
:max="1"
accept=".zip"
:disabled="isImporting"
@change="changeImportFile"
>
<n-button :disabled="isImporting">Выбрать ZIP</n-button>
</n-upload>
<p v-if="importFiles[0]?.file">
{{ importFiles[0].name }} — {{ importFiles[0].file.size.toLocaleString('ru-RU') }} байт
</p>
<n-alert v-if="isImporting" type="info" role="status">Импортируем пакет…</n-alert>
<n-alert v-if="importError" type="error" role="alert">{{ importError }}</n-alert>
<n-alert v-if="importResult" type="success" role="status">{{
importResult.repeated ? 'Пакет уже импортирован' : 'Игра импортирована'
}}</n-alert>
<n-button
v-if="importResult"
:loading="isOpeningImport"
:disabled="isOpeningImport"
@click="openImportedGame"
>Открыть игру</n-button
>
<n-button
v-else
:loading="isImporting"
:disabled="isImporting || !importFiles.length || !canImport"
@click="importGame"
>Загрузить пакет</n-button
>
</n-space>
</n-card>
</n-modal>
<!-- Окно создания игры --> <!-- Окно создания игры -->
<n-modal v-model:show="showCreateGameModal"> <n-modal v-model:show="showCreateGameModal">
<n-card <n-card
@@ -176,7 +315,10 @@ async function createGame() {
<p>Формат игры</p> <p>Формат игры</p>
<n-select <n-select
v-model:value="newGameFormat" v-model:value="newGameFormat"
:options="[{ label: 'Online', value: 'online' }, { label: 'Offline', value: 'offline' }]" :options="[
{ label: 'Online', value: 'online' },
{ label: 'Offline', value: 'offline' },
]"
aria-label="Формат игры" aria-label="Формат игры"
/> />
+58
View File
@@ -287,3 +287,61 @@ test('successful recovery clears the previous temporary error', async () => {
assert.equal(store.error, null) assert.equal(store.error, null)
assert.equal(store.username, 'Recovered') assert.equal(store.username, 'Recovered')
}) })
test('game ZIP upload preserves body and authorization through refresh', async () => {
storeWithTokens()
let attempts = 0
const file = new File([new Uint8Array([80, 75, 0, 255])], 'game.zip', { type: 'text/plain' })
globalThis.fetch = async (url, init) => {
if (String(url).endsWith('/refresh'))
return json({ accessToken: 'fresh', refreshToken: 'refresh-new' })
assert.ok(String(url).endsWith('/api/games/import'))
assert.equal(init?.body, file)
const headers = new Headers(init?.headers)
assert.equal(headers.get('Content-Type'), 'application/zip')
assert.equal(headers.get('Authorization'), attempts === 0 ? 'Bearer expired' : 'Bearer fresh')
attempts++
return attempts === 1
? new Response('', { status: 401 })
: json({ gameID: 7, packageID: 'package', repeated: true })
}
assert.deepEqual(await getBinaryClient().uploadGamePackage(file), {
gameID: 7,
packageID: 'package',
repeated: true,
})
assert.equal(attempts, 2)
globalThis.fetch = async () => json({ gameID: '7' })
await assert.rejects(getBinaryClient().uploadGamePackage(file), /Некорректный ответ/)
})
test('game ZIP download filename, server errors and network failure', async () => {
storeWithTokens()
globalThis.fetch = async (url, init) => {
assert.ok(String(url).endsWith('/api/games/7/export/download'))
assert.equal(new Headers(init?.headers).get('Authorization'), 'Bearer expired')
return new Response('ZIP', {
headers: {
'Content-Disposition': "attachment; filename*=UTF-8''%D0%B8%D0%B3%D1%80%D0%B0.zip",
},
})
}
const result = await getBinaryClient().downloadGamePackage(7)
assert.equal(result.filename, 'игра.zip')
assert.equal(await result.blob.text(), 'ZIP')
for (const field of ['message', 'error']) {
globalThis.fetch = async () => Response.json({ [field]: 'Конфликт пакета' }, { status: 409 })
await assert.rejects(getBinaryClient().downloadGamePackage(7), /Конфликт пакета/)
await assert.rejects(
getBinaryClient().uploadGamePackage(new File(['ZIP'], 'game.zip')),
/Конфликт пакета/,
)
}
globalThis.fetch = async () => {
throw new TypeError('Failed to fetch')
}
await assert.rejects(
getBinaryClient().uploadGamePackage(new File(['ZIP'], 'game.zip')),
/Failed to fetch/,
)
})