generated from VLADIMIR/template_frontend
up
This commit is contained in:
+45
-11
@@ -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<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> {
|
||||
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<string> {
|
||||
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})`
|
||||
}
|
||||
|
||||
@@ -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<Game>({
|
||||
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(() => {
|
||||
<n-button @click="updateGame()">Сохранить изменения</n-button>
|
||||
</n-flex>
|
||||
</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>
|
||||
<p class="screens-title">Выпуск билетов</p>
|
||||
<n-space vertical>
|
||||
<n-input-number v-model:value="ticketCount" :min="1" :max="100" />
|
||||
<div v-for="ticket in issuedTickets" :key="ticket.ID" class="issued-ticket">
|
||||
<div>Билет №{{ ticket.ID }}</div>
|
||||
<a
|
||||
:href="ticket.URL"
|
||||
class="payment-link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<a :href="ticket.URL" class="payment-link" target="_blank" rel="noreferrer">
|
||||
Открыть страницу билета
|
||||
</a>
|
||||
<n-button size="small" @click="openQR(ticket.URL!, `Билет №${ticket.ID}`)">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NDatePicker,
|
||||
@@ -9,14 +10,19 @@ import {
|
||||
NSelect,
|
||||
NSpace,
|
||||
NTag,
|
||||
NUpload,
|
||||
type UploadFileInfo,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
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 { useAuthStore } from '@/stores/auth'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { formatGameDateTime } from '@/utils/datetime'
|
||||
|
||||
const client = getAuthClient()
|
||||
@@ -25,6 +31,80 @@ const router = useRouter()
|
||||
|
||||
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() {
|
||||
try {
|
||||
games.value = []
|
||||
@@ -121,6 +201,7 @@ async function createGame() {
|
||||
<template>
|
||||
<div class="center-block-custom">
|
||||
<div class="width1200">
|
||||
<n-button v-if="canImport" @click="openImport">Импорт игры</n-button>
|
||||
<div class="game-block game-block-hover" @click="openCreateGame()">
|
||||
<div class="game-create-content">
|
||||
<span class="game-create-plus">+</span>
|
||||
@@ -152,6 +233,64 @@ async function createGame() {
|
||||
</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-card
|
||||
@@ -176,7 +315,10 @@ async function createGame() {
|
||||
<p>Формат игры</p>
|
||||
<n-select
|
||||
v-model:value="newGameFormat"
|
||||
:options="[{ label: 'Online', value: 'online' }, { label: 'Offline', value: 'offline' }]"
|
||||
:options="[
|
||||
{ label: 'Online', value: 'online' },
|
||||
{ label: 'Offline', value: 'offline' },
|
||||
]"
|
||||
aria-label="Формат игры"
|
||||
/>
|
||||
|
||||
|
||||
@@ -287,3 +287,61 @@ test('successful recovery clears the previous temporary error', async () => {
|
||||
assert.equal(store.error, null)
|
||||
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/,
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user