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
+92 -6
View File
@@ -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}`)">
+144 -2
View File
@@ -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="Формат игры"
/>