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">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Вечерний детектив</title>
|
<title>Вечерний детектив</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BhT0EYtM.js"></script>
|
<script type="module" crossorigin src="/assets/index-CNblC4ZU.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DoB8eiys.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BlFsLzpe.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<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 { ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import { getArchiveClient } from '@/api/archive_client'
|
||||||
import { getAuthClient } from '@/api/auth_client'
|
import { getAuthClient } from '@/api/auth_client'
|
||||||
import type { Place, Scenario } from '@/api/generated/crabs/evening_detective_server'
|
import type { Place, Scenario } from '@/api/generated/crabs/evening_detective_server'
|
||||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
@@ -15,12 +16,14 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const client = getAuthClient()
|
const client = getAuthClient()
|
||||||
|
const archiveClient = getArchiveClient()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const scenarioId = route.params.id
|
const scenarioId = route.params.id
|
||||||
|
|
||||||
const showSettingsModal = ref(false)
|
const showSettingsModal = ref(false)
|
||||||
const statusScenarioName = ref('')
|
const statusScenarioName = ref('')
|
||||||
const deletedScenarioName = ref('')
|
const deletedScenarioName = ref('')
|
||||||
|
const isDownloadingArchive = ref(false)
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
@@ -105,6 +108,27 @@ async function draftScenario(id: number) {
|
|||||||
showSettingsModal.value = false
|
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> {
|
async function uploadFile(file: File | null, filename: string): Promise<string> {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return ''
|
return ''
|
||||||
@@ -370,6 +394,15 @@ function getRandomComplimentForNastya() {
|
|||||||
|
|
||||||
<n-button @click="updateScenario()" ghost> Сохранить изменения </n-button>
|
<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" />
|
<hr class="settings-hr" />
|
||||||
<p>Создать тестовую игру</p>
|
<p>Создать тестовую игру</p>
|
||||||
<n-button @click="addGame(scenario.name || '', scenario.id!)" ghost>
|
<n-button @click="addGame(scenario.name || '', scenario.id!)" ghost>
|
||||||
@@ -443,6 +476,11 @@ function getRandomComplimentForNastya() {
|
|||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-hint {
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-hr {
|
.settings-hr {
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Archive } from '@vicons/carbon'
|
||||||
|
import { Icon } from '@vicons/utils'
|
||||||
import { useMessage } from 'naive-ui'
|
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 { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { getArchiveClient } from '@/api/archive_client'
|
||||||
import { getAuthClient } from '@/api/auth_client'
|
import { getAuthClient } from '@/api/auth_client'
|
||||||
import type { Scenario } from '@/api/generated/crabs/evening_detective_server'
|
import type { Scenario } from '@/api/generated/crabs/evening_detective_server'
|
||||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
|
||||||
const client = getAuthClient()
|
const client = getAuthClient()
|
||||||
|
const archiveClient = getArchiveClient()
|
||||||
|
|
||||||
const scenarios = ref<Scenario[]>([])
|
const scenarios = ref<Scenario[]>([])
|
||||||
|
|
||||||
@@ -19,6 +23,10 @@ const message = useMessage()
|
|||||||
const showAddScenarioModal = ref(false)
|
const showAddScenarioModal = ref(false)
|
||||||
const newScenarioName = ref('')
|
const newScenarioName = ref('')
|
||||||
|
|
||||||
|
const showImportModal = ref(false)
|
||||||
|
const importFileList = ref<UploadFileInfo[]>([])
|
||||||
|
const isImporting = ref(false)
|
||||||
|
|
||||||
async function getScenarios() {
|
async function getScenarios() {
|
||||||
scenarios.value = []
|
scenarios.value = []
|
||||||
const res = await client.GetMyScenarios({})
|
const res = await client.GetMyScenarios({})
|
||||||
@@ -38,6 +46,33 @@ async function toScenarioEditor(id: number) {
|
|||||||
router.push('/scenarios/' + id + '/editor')
|
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()
|
getScenarios()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -51,6 +86,17 @@ getScenarios()
|
|||||||
</div>
|
</div>
|
||||||
</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!)"
|
<div class="scenario-block" v-for="scenario in scenarios" @click="toScenarioEditor(scenario.id!)"
|
||||||
v-bind:key="scenario.id">
|
v-bind:key="scenario.id">
|
||||||
<div class="scenario-image-block scenario-image" :style="{ backgroundImage: `url(${scenario.image})` }"></div>
|
<div class="scenario-image-block scenario-image" :style="{ backgroundImage: `url(${scenario.image})` }"></div>
|
||||||
@@ -86,6 +132,37 @@ getScenarios()
|
|||||||
</n-card>
|
</n-card>
|
||||||
</n-modal>
|
</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" />
|
<HeaderMenu active="scenarios" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -126,6 +203,16 @@ getScenarios()
|
|||||||
background-color: #111;
|
background-color: #111;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-icon svg {
|
||||||
|
height: 100px;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-hint {
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.scenario-image {
|
.scenario-image {
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user