generated from VLADIMIR/template_frontend
add buttons
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Вечерний детектив</title>
|
||||
<script type="module" crossorigin src="/assets/index-BhT0EYtM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DoB8eiys.css">
|
||||
<script type="module" crossorigin src="/assets/index-CNblC4ZU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BlFsLzpe.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
import { getURL } from './generated/crabs/evening_detective_server/client'
|
||||
|
||||
export type DownloadArchiveResult = {
|
||||
blob: Blob
|
||||
filename: string
|
||||
}
|
||||
|
||||
export type UploadArchiveResult = {
|
||||
id: number | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
// Клиент для бинарных операций со сценарием (ZIP-архив). Сгенерированный
|
||||
// HTTP-хендлер всегда парсит ответ как JSON, поэтому для архивов используем
|
||||
// сырой fetch: скачивание отдаёт байты архива, загрузка принимает их напрямую.
|
||||
export function getArchiveClient() {
|
||||
const authStore = useAuthStore()
|
||||
return buildArchiveClient(authStore.refreshTokenAction, authStore.getToken)
|
||||
}
|
||||
|
||||
function buildArchiveClient(
|
||||
refreshTokens: () => Promise<boolean>,
|
||||
getToken: () => string | null,
|
||||
) {
|
||||
async function request(path: string, init: RequestInit): Promise<Response> {
|
||||
const headers: Record<string, string> = {}
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
let response = await fetch(`${getURL()}/${path}`, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init.headers as Record<string, string>) },
|
||||
})
|
||||
|
||||
// Токен протух — обновляем и повторяем запрос один раз.
|
||||
if (response.status == 401) {
|
||||
await refreshTokens()
|
||||
const newToken = getToken()
|
||||
if (newToken) {
|
||||
headers['Authorization'] = `Bearer ${newToken}`
|
||||
}
|
||||
response = await fetch(`${getURL()}/${path}`, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init.headers as Record<string, string>) },
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
async function downloadScenarioArchive(id: number): Promise<DownloadArchiveResult> {
|
||||
const response = await request(`api/scenarios/${id}/archive`, { 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`
|
||||
return { blob, filename }
|
||||
}
|
||||
|
||||
async function uploadScenarioArchive(file: File): Promise<UploadArchiveResult> {
|
||||
const contentType = file.type || 'application/zip'
|
||||
const response = await request('api/scenarios/archive', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': contentType },
|
||||
body: file,
|
||||
})
|
||||
|
||||
const data = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
return { id: null, error: data?.message || `Ошибка сервера (${response.status})` }
|
||||
}
|
||||
|
||||
const error = typeof data?.error === 'string' && data.error !== '' ? data.error : null
|
||||
return { id: typeof data?.id === 'number' ? data.id : null, error }
|
||||
}
|
||||
|
||||
return { downloadScenarioArchive, uploadScenarioArchive }
|
||||
}
|
||||
|
||||
// Извлекает имя файла из Content-Disposition: attachment; filename="name.zip"
|
||||
function parseContentDispositionFilename(value: string | null): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
const match = value.match(/filename="?([^";]+)"?/i)
|
||||
const filename = match ? match[1] : ''
|
||||
return filename.replace(/[\\/:*?"<>|]/g, '_')
|
||||
}
|
||||
|
||||
// Ошибки grpc-gateway приходят JSON-объектом {"code": ..., "message": ...}
|
||||
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
|
||||
}
|
||||
return `Ошибка сервера (${response.status})`
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { NAlert, NCard, NFlex, NInput, NModal, NSpace, NTag, NText, NUpload, NUp
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { getArchiveClient } from '@/api/archive_client'
|
||||
import { getAuthClient } from '@/api/auth_client'
|
||||
import type { Place, Scenario } from '@/api/generated/crabs/evening_detective_server'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
@@ -15,12 +16,14 @@ import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const client = getAuthClient()
|
||||
const archiveClient = getArchiveClient()
|
||||
const route = useRoute()
|
||||
const scenarioId = route.params.id
|
||||
|
||||
const showSettingsModal = ref(false)
|
||||
const statusScenarioName = ref('')
|
||||
const deletedScenarioName = ref('')
|
||||
const isDownloadingArchive = ref(false)
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
@@ -105,6 +108,27 @@ async function draftScenario(id: number) {
|
||||
showSettingsModal.value = false
|
||||
}
|
||||
|
||||
async function downloadArchive() {
|
||||
isDownloadingArchive.value = true
|
||||
try {
|
||||
const { blob, filename } = await archiveClient.downloadScenarioArchive(scenario.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()
|
||||
// Отзываем URL после того как браузер начал скачивание
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
message.success('Архив сценария скачан')
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось скачать архив')
|
||||
} finally {
|
||||
isDownloadingArchive.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFile(file: File | null, filename: string): Promise<string> {
|
||||
if (!file) {
|
||||
return ''
|
||||
@@ -370,6 +394,15 @@ function getRandomComplimentForNastya() {
|
||||
|
||||
<n-button @click="updateScenario()" ghost> Сохранить изменения </n-button>
|
||||
|
||||
<hr class="settings-hr" />
|
||||
<p class="settings-header">Архив сценария</p>
|
||||
<p class="settings-hint">
|
||||
Скачайте сценарий ZIP-архивом (scenario.json + изображения) и импортируйте его в другом аккаунте.
|
||||
</p>
|
||||
<n-button :loading="isDownloadingArchive" :disabled="!scenario.id" ghost @click="downloadArchive()">
|
||||
Скачать архив (.zip)
|
||||
</n-button>
|
||||
|
||||
<hr class="settings-hr" />
|
||||
<p>Создать тестовую игру</p>
|
||||
<n-button @click="addGame(scenario.name || '', scenario.id!)" ghost>
|
||||
@@ -443,6 +476,11 @@ function getRandomComplimentForNastya() {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
color: #999;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settings-hr {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { Archive } from '@vicons/carbon'
|
||||
import { Icon } from '@vicons/utils'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { NButton, NCard, NFlex,NInput, NModal, NTag } from 'naive-ui'
|
||||
import { NButton, NCard, NFlex, NInput, NModal, NTag, NText, NUpload, NUploadDragger, type UploadFileInfo } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { getArchiveClient } from '@/api/archive_client'
|
||||
import { getAuthClient } from '@/api/auth_client'
|
||||
import type { Scenario } from '@/api/generated/crabs/evening_detective_server'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
|
||||
const client = getAuthClient()
|
||||
const archiveClient = getArchiveClient()
|
||||
|
||||
const scenarios = ref<Scenario[]>([])
|
||||
|
||||
@@ -19,6 +23,10 @@ const message = useMessage()
|
||||
const showAddScenarioModal = ref(false)
|
||||
const newScenarioName = ref('')
|
||||
|
||||
const showImportModal = ref(false)
|
||||
const importFileList = ref<UploadFileInfo[]>([])
|
||||
const isImporting = ref(false)
|
||||
|
||||
async function getScenarios() {
|
||||
scenarios.value = []
|
||||
const res = await client.GetMyScenarios({})
|
||||
@@ -38,6 +46,33 @@ async function toScenarioEditor(id: number) {
|
||||
router.push('/scenarios/' + id + '/editor')
|
||||
}
|
||||
|
||||
function resetImport() {
|
||||
importFileList.value = []
|
||||
showImportModal.value = false
|
||||
}
|
||||
|
||||
async function importScenario() {
|
||||
const file = importFileList.value[0]?.file
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
isImporting.value = true
|
||||
try {
|
||||
const res = await archiveClient.uploadScenarioArchive(file)
|
||||
if (res.error) {
|
||||
message.error(res.error)
|
||||
return
|
||||
}
|
||||
message.success('Сценарий импортирован из архива')
|
||||
resetImport()
|
||||
await toScenarioEditor(res.id!)
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось импортировать архив')
|
||||
} finally {
|
||||
isImporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
getScenarios()
|
||||
</script>
|
||||
|
||||
@@ -51,6 +86,17 @@ getScenarios()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario-block" @click="showImportModal = true">
|
||||
<div class="scenario-image-block scenario-image-plus import-icon">
|
||||
<Icon>
|
||||
<Archive />
|
||||
</Icon>
|
||||
</div>
|
||||
<div class="scenario-content-block">
|
||||
<p class="scenario-title">Импортировать из архива</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario-block" v-for="scenario in scenarios" @click="toScenarioEditor(scenario.id!)"
|
||||
v-bind:key="scenario.id">
|
||||
<div class="scenario-image-block scenario-image" :style="{ backgroundImage: `url(${scenario.image})` }"></div>
|
||||
@@ -86,6 +132,37 @@ getScenarios()
|
||||
</n-card>
|
||||
</n-modal>
|
||||
|
||||
<!-- Окно импорта сценария из архива -->
|
||||
<n-modal v-model:show="showImportModal">
|
||||
<n-card style="width: 600px" title="Импорт сценария из архива" :bordered="false" size="huge" role="dialog"
|
||||
aria-modal="true">
|
||||
<template #header-extra>
|
||||
<n-button @click="resetImport()"> x </n-button>
|
||||
</template>
|
||||
<p class="import-hint">
|
||||
Выберите ZIP-архив сценария (файл scenario.json + папка images). Архив можно получить
|
||||
через «Скачать архив» в настройках сценария.
|
||||
</p>
|
||||
<n-upload :max="1" v-model:file-list="importFileList"
|
||||
accept=".zip,application/zip,application/x-zip-compressed,application/octet-stream">
|
||||
<n-upload-dragger>
|
||||
<n-text style="font-size: 16px">
|
||||
Щелкните или перетащите ZIP-архив в эту область
|
||||
</n-text>
|
||||
</n-upload-dragger>
|
||||
</n-upload>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="resetImport()">Отмена</n-button>
|
||||
<n-button type="primary" :loading="isImporting" :disabled="importFileList.length == 0"
|
||||
@click="importScenario()">
|
||||
Импортировать
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
|
||||
<HeaderMenu active="scenarios" />
|
||||
</template>
|
||||
|
||||
@@ -126,6 +203,16 @@ getScenarios()
|
||||
background-color: #111;
|
||||
}
|
||||
|
||||
.import-icon svg {
|
||||
height: 100px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.import-hint {
|
||||
color: #aaa;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.scenario-image {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
|
||||
Reference in New Issue
Block a user