generated from VLADIMIR/template_frontend
update files
This commit is contained in:
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-2430
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+2431
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-CRAnZlT1.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CtSjox4D.css">
|
||||
<script type="module" crossorigin src="/assets/index-D7Q2J__k.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BsEbL24Y.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -13,6 +13,10 @@ import type { Application, Game, Team } from '@/api/generated/crabs/evening_dete
|
||||
import { getURL } from '@/api/generated/crabs/evening_detective_server/client';
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
import { useSimplePolling } from '@/composables/useSimplePolling';
|
||||
import {
|
||||
applicationFileType,
|
||||
applicationFileURL,
|
||||
} from '@/utils/application';
|
||||
|
||||
const client = getAuthClient()
|
||||
const router = useRouter()
|
||||
@@ -124,7 +128,8 @@ const deletedTeam = ref<Team>({
|
||||
|
||||
const giveApplicationApplication = ref<Application>({
|
||||
name: '',
|
||||
image: ''
|
||||
image: '',
|
||||
file_type: ''
|
||||
})
|
||||
|
||||
const url = window.location.href
|
||||
@@ -447,10 +452,21 @@ useSimplePolling(() => {
|
||||
<p>Команда</p>
|
||||
<h3>{{ giveApplicationTeam.name }}</h3>
|
||||
|
||||
|
||||
<p>Улика</p>
|
||||
<h3>{{ giveApplicationApplication.name }}</h3>
|
||||
|
||||
<div v-if="applicationFileType(giveApplicationApplication) == 'image' && giveApplicationApplication.image"
|
||||
class="give-application-image-block">
|
||||
<img :src="applicationFileURL(giveApplicationApplication.image)" class="give-application-image" />
|
||||
</div>
|
||||
<audio v-else-if="applicationFileType(giveApplicationApplication) == 'audio' && giveApplicationApplication.image"
|
||||
:src="applicationFileURL(giveApplicationApplication.image)" controls preload="none"
|
||||
class="give-application-audio"></audio>
|
||||
<a v-else-if="applicationFileType(giveApplicationApplication) == 'pdf' && giveApplicationApplication.image"
|
||||
:href="applicationFileURL(giveApplicationApplication.image)" target="_blank" rel="noopener"
|
||||
class="give-application-pdf-link">
|
||||
PDF
|
||||
</a>
|
||||
</n-space>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
@@ -606,4 +622,28 @@ useSimplePolling(() => {
|
||||
background-color: white;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.give-application-image-block {
|
||||
margin: 10px 0 5px;
|
||||
padding: 7px;
|
||||
max-width: 320px;
|
||||
background-image: url("@/assets/images/paper_white.jpg");
|
||||
background-size: cover;
|
||||
box-shadow: 0 3px 15px rgb(98 98 98);
|
||||
}
|
||||
|
||||
.give-application-image {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.give-application-audio {
|
||||
width: 100%;
|
||||
margin: 10px 0 5px;
|
||||
}
|
||||
|
||||
.give-application-pdf-link {
|
||||
display: inline-block;
|
||||
margin: 10px 0 5px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
+354
-11
@@ -2,10 +2,16 @@
|
||||
import { Key24Regular } from '@vicons/fluent'
|
||||
import { LockOutlined } from '@vicons/material'
|
||||
import { Icon } from '@vicons/utils'
|
||||
import { NButton, NDynamicInput, NInput, NInputGroup, NSpace, NSwitch, NText, NUpload, NUploadDragger, type UploadFileInfo } from 'naive-ui'
|
||||
import type { PropType } from 'vue'
|
||||
import { NButton, NCard, NDynamicInput, NInput, NInputGroup, NModal, NSpace, NSwitch, NTag, NText, NUpload, NUploadDragger, type UploadFileInfo } from 'naive-ui'
|
||||
import { computed, type PropType,ref } from 'vue'
|
||||
|
||||
import type { Application, Door, Key, Place } from '@/api/generated/crabs/evening_detective_server'
|
||||
import type { EditableApplication } from '@/types/editor'
|
||||
import {
|
||||
applicationFileName,
|
||||
applicationFileType,
|
||||
applicationFileURL,
|
||||
} from '@/utils/application'
|
||||
|
||||
import MessagePaper from './MessagePaper.vue';
|
||||
|
||||
@@ -55,10 +61,11 @@ const updateMode = (mode: string) => {
|
||||
emit('update:mode', mode)
|
||||
}
|
||||
|
||||
function onCreateApplication(): Application {
|
||||
function onCreateApplication(): EditableApplication {
|
||||
return {
|
||||
name: undefined,
|
||||
image: undefined,
|
||||
file_type: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +82,114 @@ function onCreateKey(): Key {
|
||||
name: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Приводит улику из шаблона n-dynamic-input к редакторскому типу
|
||||
// (в объекте лежит fileList, которого нет в схеме API).
|
||||
function editableApplication(value: Application): EditableApplication {
|
||||
return value as EditableApplication
|
||||
}
|
||||
|
||||
// Убирает файл улики: очищает ссылку, тип и выбранный для загрузки файл.
|
||||
function removeApplicationFile(application: EditableApplication) {
|
||||
application.image = undefined
|
||||
application.file_type = undefined
|
||||
application.fileList = undefined
|
||||
}
|
||||
|
||||
// Просмотр улики в модальном окне (картинка целиком / PDF во встроенном
|
||||
// просмотрщике / аудио с плеером). viewImage — просмотр обычной картинки
|
||||
// (например, картинки самой точки).
|
||||
const viewApplication = ref<Application | null>(null)
|
||||
const viewImage = ref<{ title: string; url: string } | null>(null)
|
||||
const showViewApplicationModal = ref(false)
|
||||
|
||||
function openApplicationView(application: Application) {
|
||||
viewApplication.value = application
|
||||
viewImage.value = null
|
||||
showViewApplicationModal.value = true
|
||||
}
|
||||
|
||||
function openImageView(title: string, url: string) {
|
||||
viewImage.value = { title: title, url: url }
|
||||
viewApplication.value = null
|
||||
showViewApplicationModal.value = true
|
||||
}
|
||||
|
||||
function closeApplicationView() {
|
||||
showViewApplicationModal.value = false
|
||||
viewApplication.value = null
|
||||
viewImage.value = null
|
||||
}
|
||||
|
||||
// Текст точки разбивается на две части: улика встаёт в поток текста, и
|
||||
// текст обтекает её целиком. Разрез выбирается по границе (перенос строки
|
||||
// или пробел), после которой остаётся ~45% текста — хвост достаточно
|
||||
// длинный, чтобы обойти картинку по всей высоте и не оставить пустого
|
||||
// места слева от неё.
|
||||
function splitTextForApplications(text: string | undefined): { before: string; after: string } {
|
||||
const value = text || ''
|
||||
if (value.length == 0) {
|
||||
return { before: '', after: '' }
|
||||
}
|
||||
const trailingTarget = Math.min(value.length, Math.max(120, Math.floor(value.length * 0.45)))
|
||||
let best = -1
|
||||
let bestDistance = Infinity
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const ch = value[i]
|
||||
if (ch != '\n' && ch != ' ') {
|
||||
continue
|
||||
}
|
||||
const distance = Math.abs(value.length - i - trailingTarget)
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance
|
||||
best = i
|
||||
}
|
||||
}
|
||||
if (best < 0) {
|
||||
// Текст без переносов и пробелов: блок ставится после всего текста.
|
||||
return { before: value, after: '' }
|
||||
}
|
||||
return { before: value.slice(0, best), after: value.slice(best) }
|
||||
}
|
||||
|
||||
const textBeforeApplications = computed(() => splitTextForApplications(props.place?.text).before)
|
||||
const textAfterApplications = computed(() => splitTextForApplications(props.place?.text).after)
|
||||
|
||||
// Если у точки нет своей картинки, первая картинка-улика занимает её место
|
||||
// в самом начале текста; остальные улики встают в поток текста как раньше.
|
||||
const firstImageApplication = computed<Application | null>(() => {
|
||||
if (props.place?.image) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
(props.place?.applications || []).find(
|
||||
(application) => applicationFileType(application) == 'image' && application.image,
|
||||
) || null
|
||||
)
|
||||
})
|
||||
|
||||
// Улики в потоке текста: только картинки (без первой картинки,
|
||||
// занявшей место картинки точки).
|
||||
const flowApplications = computed<Application[]>(() => {
|
||||
const applications = props.place?.applications || []
|
||||
return applications.filter((application) => {
|
||||
if (application === firstImageApplication.value) {
|
||||
return false
|
||||
}
|
||||
return applicationFileType(application) == 'image' && application.image
|
||||
})
|
||||
})
|
||||
|
||||
// Аудио, PDF и улики без файла — в самом низу после пунктирной линии.
|
||||
const bottomApplications = computed<Application[]>(() => {
|
||||
const applications = props.place?.applications || []
|
||||
return applications.filter((application) => {
|
||||
if (application === firstImageApplication.value) {
|
||||
return false
|
||||
}
|
||||
return !(applicationFileType(application) == 'image' && application.image)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -103,7 +218,8 @@ function onCreateKey(): Key {
|
||||
<hr class="hr" />
|
||||
<div class="message-content">
|
||||
<div v-if="props.place?.image">
|
||||
<div class="message-image-border">
|
||||
<div class="message-image-border"
|
||||
@click.stop="openImageView(props.place?.name || '', props.place?.image || '')">
|
||||
<img :src="props.place?.image" class="message-image">
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,7 +237,19 @@ function onCreateKey(): Key {
|
||||
<hr class="hr" v-if="props.place?.applications?.length" />
|
||||
<div class="message-footer application-block" v-for="application in props.place?.applications"
|
||||
v-bind:key="application.name">
|
||||
Приложение: {{ application.name }}
|
||||
<div class="application-name">Приложение: {{ application.name }}</div>
|
||||
<div v-if="applicationFileType(application) == 'image' && application.image" class="application-image-border"
|
||||
@click.stop="openApplicationView(application)">
|
||||
<img :src="applicationFileURL(application.image)" class="application-image">
|
||||
</div>
|
||||
<audio v-else-if="applicationFileType(application) == 'audio' && application.image"
|
||||
:src="applicationFileURL(application.image)" controls preload="none" class="application-audio"
|
||||
@click.stop></audio>
|
||||
<a v-else-if="applicationFileType(application) == 'pdf' && application.image"
|
||||
:href="applicationFileURL(application.image)" target="_blank" rel="noopener" class="application-pdf-link"
|
||||
@click.stop>
|
||||
PDF
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="gray-block">
|
||||
@@ -145,11 +273,30 @@ function onCreateKey(): Key {
|
||||
<hr class="show-hr" />
|
||||
<div class="message-show-content font">
|
||||
<div v-if="place?.image?.length">
|
||||
<div class="message-image-border">
|
||||
<div class="message-image-border"
|
||||
@click="openImageView(place?.name || '', place?.image || '')">
|
||||
<img v-bind:src="place?.image" class="message-image" />
|
||||
</div>
|
||||
</div>
|
||||
{{ place?.text }}
|
||||
<!-- Если у точки нет своей картинки, первая картинка-улика занимает
|
||||
её место в самом начале текста -->
|
||||
<div v-else-if="firstImageApplication" class="message-image-border"
|
||||
@click="openApplicationView(firstImageApplication)">
|
||||
<img :src="applicationFileURL(firstImageApplication.image)" class="message-image" />
|
||||
</div>
|
||||
{{ textBeforeApplications }}
|
||||
|
||||
<!-- Остальные улики-картинки встроены в поток текста: блок справа, текст обтекает его -->
|
||||
<div v-if="flowApplications.length" class="show-applications-block">
|
||||
<div class="show-application" v-for="application in flowApplications" :key="application.name">
|
||||
<!-- Картинка-улика без заголовка, просто фото внизу справа -->
|
||||
<div class="application-image-block" @click="openApplicationView(application)">
|
||||
<img :src="applicationFileURL(application.image)" class="application-image" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ textAfterApplications }}
|
||||
</div>
|
||||
<hr class="show-hr" v-if="props.place?.doors?.length" />
|
||||
<div v-for="door in props.place?.doors" :key="door.code" class="button-dialog font"
|
||||
@@ -158,10 +305,20 @@ function onCreateKey(): Key {
|
||||
{{ door.code }} {{ door.name }}
|
||||
</div>
|
||||
</div>
|
||||
<hr class="show-hr" v-if="props.place?.applications?.length" />
|
||||
<div class="message-show-footer font" v-for="application in props.place?.applications" :key="application.name">
|
||||
|
||||
<!-- Аудио и PDF-улики (и улики без файла): в самом низу после
|
||||
пунктирной линии; PDF открывается по нажатию на название -->
|
||||
<hr class="show-hr" v-if="bottomApplications.length" />
|
||||
<div class="message-show-footer font" v-for="application in bottomApplications" :key="application.name">
|
||||
<a v-if="applicationFileType(application) == 'pdf' && application.image"
|
||||
:href="applicationFileURL(application.image)" target="_blank" rel="noopener" class="pdf-application-name">
|
||||
Приложение: {{ application.name }}
|
||||
<!-- <div class="application-label">{{ application.number }}</div> -->
|
||||
</a>
|
||||
<template v-else>
|
||||
<div class="application-label">Приложение: {{ application.name }}</div>
|
||||
<audio v-if="applicationFileType(application) == 'audio' && application.image"
|
||||
:src="applicationFileURL(application.image)" controls preload="none" class="application-audio"></audio>
|
||||
</template>
|
||||
</div>
|
||||
</MessagePaper>
|
||||
</div>
|
||||
@@ -218,8 +375,33 @@ function onCreateKey(): Key {
|
||||
<n-dynamic-input v-model:value="props.place!.applications" :on-create="onCreateApplication">
|
||||
<template #create-button-default> Добавить улику </template>
|
||||
<template #default="{ value }">
|
||||
<div style="display: flex; align-items: center; width: 100%">
|
||||
<div style="display: flex; flex-direction: column; width: 100%">
|
||||
<n-input v-model:value="value.name" type="text" placeholder="Название" />
|
||||
<div v-if="value.image" class="application-edit-file">
|
||||
<n-space align="center" justify="space-between">
|
||||
<n-text depth="3">
|
||||
{{ applicationFileName(value.image) }}
|
||||
<n-tag size="small" :bordered="false">
|
||||
{{ applicationFileType(value) || 'файл' }}
|
||||
</n-tag>
|
||||
</n-text>
|
||||
<n-button size="small" quaternary @click="removeApplicationFile(editableApplication(value))">
|
||||
Убрать файл
|
||||
</n-button>
|
||||
</n-space>
|
||||
<img v-if="applicationFileType(value) == 'image'" :src="applicationFileURL(value.image)"
|
||||
class="application-edit-image" />
|
||||
<audio v-else-if="applicationFileType(value) == 'audio'" :src="applicationFileURL(value.image)"
|
||||
controls preload="none" class="application-edit-audio"></audio>
|
||||
<a v-else-if="applicationFileType(value) == 'pdf'" :href="applicationFileURL(value.image)"
|
||||
target="_blank" rel="noopener" class="application-pdf-link">
|
||||
PDF
|
||||
</a>
|
||||
</div>
|
||||
<n-upload :max="1" v-model:file-list="editableApplication(value).fileList"
|
||||
accept="image/*,application/pdf,audio/*">
|
||||
<n-button size="small">Загрузить файл улики</n-button>
|
||||
</n-upload>
|
||||
</div>
|
||||
</template>
|
||||
</n-dynamic-input>
|
||||
@@ -274,6 +456,30 @@ function onCreateKey(): Key {
|
||||
</div>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
<!-- Просмотр улики / картинки точки -->
|
||||
<n-modal v-model:show="showViewApplicationModal" :mask-closable="true">
|
||||
<n-card style="width: min(94vw, 1200px)" :bordered="false" role="dialog" aria-modal="true"
|
||||
:content-style="{ padding: '14px' }">
|
||||
<template #header>
|
||||
{{ viewApplication?.name || viewImage?.title }}
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-button quaternary circle @click="closeApplicationView()"> x </n-button>
|
||||
</template>
|
||||
<div v-if="viewApplication && applicationFileType(viewApplication) == 'image'"
|
||||
class="view-application-image-block">
|
||||
<img :src="applicationFileURL(viewApplication.image)" class="view-application-image" />
|
||||
</div>
|
||||
<iframe v-else-if="viewApplication && applicationFileType(viewApplication) == 'pdf'"
|
||||
:src="applicationFileURL(viewApplication.image)" class="view-application-pdf"></iframe>
|
||||
<audio v-else-if="viewApplication && applicationFileType(viewApplication) == 'audio'"
|
||||
:src="applicationFileURL(viewApplication.image)" controls autoplay class="view-application-audio"></audio>
|
||||
<div v-else-if="viewImage" class="view-application-image-block">
|
||||
<img :src="viewImage.url" class="view-application-image" />
|
||||
</div>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -326,6 +532,101 @@ function onCreateKey(): Key {
|
||||
color: burlywood;
|
||||
}
|
||||
|
||||
.application-name {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.application-image-border {
|
||||
width: 40%;
|
||||
max-width: 120px;
|
||||
float: left;
|
||||
padding: 7px;
|
||||
margin-right: 15px;
|
||||
background-image: url("@/assets/images/paper_white.jpg");
|
||||
background-size: cover;
|
||||
box-shadow: 0 3px 15px rgb(98 98 98);
|
||||
transform: rotate(-3deg);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* В редакторе улики показываются друг под другом:
|
||||
картинка улики не плавает, а идёт под своим названием */
|
||||
.message-footer .application-image-border {
|
||||
float: none;
|
||||
width: 120px;
|
||||
max-width: 120px;
|
||||
margin: 4px 0 10px;
|
||||
}
|
||||
|
||||
.application-image {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.application-image-block {
|
||||
margin: 14px 0 5px;
|
||||
padding: 7px;
|
||||
max-width: 120px;
|
||||
background-image: url("@/assets/images/paper_white.jpg");
|
||||
background-size: cover;
|
||||
box-shadow: 0 3px 15px rgb(98 98 98);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.application-audio {
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.application-pdf-link {
|
||||
display: inline-block;
|
||||
margin: 5px 0;
|
||||
color: var(--second-color);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.view-application-image-block {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.view-application-image {
|
||||
max-width: 100%;
|
||||
max-height: 72vh;
|
||||
}
|
||||
|
||||
.view-application-pdf {
|
||||
width: 100%;
|
||||
height: 72vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.view-application-audio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.application-edit-file {
|
||||
margin: 8px 0 4px;
|
||||
padding: 8px;
|
||||
border-radius: 5px;
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
.application-edit-image {
|
||||
width: 100%;
|
||||
max-width: 120px;
|
||||
margin-top: 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.application-edit-audio {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.application-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.door-block {
|
||||
display: inline-block;
|
||||
margin: 10px 5px 10px 0;
|
||||
@@ -391,6 +692,7 @@ function onCreateKey(): Key {
|
||||
background-size: cover;
|
||||
box-shadow: 0 3px 15px rgb(98 98 98);
|
||||
transform: rotate(-3deg);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.message-image {
|
||||
@@ -449,8 +751,12 @@ function onCreateKey(): Key {
|
||||
.message-show-content {
|
||||
font-weight: 500;
|
||||
white-space: pre-wrap;
|
||||
/* BFC: плавающий блок улик остаётся внутри текста и не наезжает
|
||||
на следующие секции (двери/действия) при коротком тексте */
|
||||
display: flow-root;
|
||||
}
|
||||
|
||||
/* PDF-улики внизу после пунктирной линии */
|
||||
.message-show-footer {
|
||||
padding-right: 50px;
|
||||
font-weight: 400;
|
||||
@@ -458,6 +764,43 @@ function onCreateKey(): Key {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pdf-application-name {
|
||||
color: var(--second-color);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Улики встроены в поток текста: блок прижат к правому краю,
|
||||
текст обтекает его с левой стороны */
|
||||
.show-applications-block {
|
||||
float: right;
|
||||
margin: 4px 0 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.show-application {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.show-application .application-image-block {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.show-application .application-audio {
|
||||
width: 260px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.show-application .application-pdf-link {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.button-dialog {
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -14,7 +14,7 @@ import IntroBlock from '@/components/IntroBlock.vue'
|
||||
import PlaceBlock from '@/components/PlaceBlock.vue'
|
||||
import router from '@/router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { EditableIntroduction, EditablePlace } from '@/types/editor'
|
||||
import type { EditableApplication, EditableIntroduction, EditablePlace } from '@/types/editor'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const client = getAuthClient()
|
||||
@@ -142,9 +142,14 @@ async function downloadArchive() {
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFile(file: File | null, filename: string): Promise<string> {
|
||||
type UploadFileResult = {
|
||||
filename: string
|
||||
fileType: string
|
||||
}
|
||||
|
||||
async function uploadFile(file: File | null, filename: string): Promise<UploadFileResult> {
|
||||
if (!file) {
|
||||
return ''
|
||||
return { filename: '', fileType: '' }
|
||||
}
|
||||
|
||||
// Читаем как ArrayBuffer
|
||||
@@ -157,10 +162,15 @@ async function uploadFile(file: File | null, filename: string): Promise<string>
|
||||
})
|
||||
if (resUploadFile.error != '') {
|
||||
message.error(resUploadFile.error!)
|
||||
return ''
|
||||
return { filename: '', fileType: '' }
|
||||
}
|
||||
|
||||
return resUploadFile.filename || ''
|
||||
// file_type возвращает сервер по содержимому файла (image | pdf | audio) —
|
||||
// редактор сохраняет его на улике.
|
||||
return {
|
||||
filename: resUploadFile.filename || '',
|
||||
fileType: resUploadFile.file_type || '',
|
||||
}
|
||||
}
|
||||
|
||||
async function updateScenario() {
|
||||
@@ -170,10 +180,11 @@ async function updateScenario() {
|
||||
if (file == null) {
|
||||
return
|
||||
}
|
||||
resUploadFilename = await uploadFile(file, 'scenarios_' + scenario.value.id + '_' + file.name)
|
||||
if (resUploadFilename === '') {
|
||||
const uploaded = await uploadFile(file, 'scenarios_' + scenario.value.id + '_' + file.name)
|
||||
if (uploaded.filename === '') {
|
||||
return
|
||||
}
|
||||
resUploadFilename = uploaded.filename
|
||||
}
|
||||
|
||||
const res = await client.UpdateScenario({
|
||||
@@ -284,11 +295,32 @@ async function updatePlace(place: EditablePlace) {
|
||||
if (file == null) {
|
||||
return
|
||||
}
|
||||
const resUploadFilename = await uploadFile(file, 'scenarios_' + scenario.value.id + '_place_' + place.code + '_' + file.name)
|
||||
if (resUploadFilename === '') {
|
||||
const uploaded = await uploadFile(file, 'scenarios_' + scenario.value.id + '_place_' + place.code + '_' + file.name)
|
||||
if (uploaded.filename === '') {
|
||||
return
|
||||
}
|
||||
place.image = resUploadFilename
|
||||
place.image = uploaded.filename
|
||||
}
|
||||
|
||||
// Загружаем файлы улик и проставляем file_type из ответа сервера.
|
||||
const applications = (place.applications || []) as EditableApplication[]
|
||||
for (const application of applications) {
|
||||
if (!application.fileList || application.fileList.length == 0) {
|
||||
continue
|
||||
}
|
||||
const file = application.fileList[0].file || null
|
||||
if (file == null) {
|
||||
continue
|
||||
}
|
||||
const uploaded = await uploadFile(
|
||||
file,
|
||||
'scenarios_' + scenario.value.id + '_place_' + place.code + '_app_' + file.name,
|
||||
)
|
||||
if (uploaded.filename === '') {
|
||||
return
|
||||
}
|
||||
application.image = uploaded.filename
|
||||
application.file_type = uploaded.fileType
|
||||
}
|
||||
|
||||
await addNotFoundDoors(scenario.value, place)
|
||||
@@ -335,7 +367,7 @@ async function updateIntro() {
|
||||
if (file == null) {
|
||||
return
|
||||
}
|
||||
audio = await uploadFile(file, 'scenarios_' + scenario.value.id + '_intro_' + file.name)
|
||||
audio = (await uploadFile(file, 'scenarios_' + scenario.value.id + '_intro_' + file.name)).filename
|
||||
if (audio === '') {
|
||||
return
|
||||
}
|
||||
|
||||
+7
-1
@@ -1,6 +1,6 @@
|
||||
import type { UploadFileInfo } from 'naive-ui'
|
||||
|
||||
import type { Introduction, Place } from '@/api/generated/crabs/evening_detective_server'
|
||||
import type { Application, Introduction, Place } from '@/api/generated/crabs/evening_detective_server'
|
||||
|
||||
// Служебные поля интерфейса, которых нет в сгенерированной схеме API
|
||||
// (типы из генератора — type-алиасы, их нельзя расширить через declare module).
|
||||
@@ -21,3 +21,9 @@ export type EditableIntroduction = Introduction & {
|
||||
/** Выбранные файлы для загрузки аудио вступления */
|
||||
fileList?: UploadFileInfo[]
|
||||
}
|
||||
|
||||
/** Улика со служебными полями редактора */
|
||||
export type EditableApplication = Application & {
|
||||
/** Выбранный файл улики для загрузки */
|
||||
fileList?: UploadFileInfo[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Application } from '@/api/generated/crabs/evening_detective_server'
|
||||
import { getURL } from '@/api/generated/crabs/evening_detective_server/client'
|
||||
|
||||
// Тип файла улики: явное поле file_type (image | pdf | audio) или
|
||||
// деривация из расширения имени файла (легаси-данные без file_type).
|
||||
export function applicationFileType(application: Application | undefined): string {
|
||||
const fileType = application?.file_type
|
||||
if (fileType === 'pdf' || fileType === 'image' || fileType === 'audio') {
|
||||
return fileType
|
||||
}
|
||||
const image = (application?.image || '').toLowerCase()
|
||||
if (/\.pdf($|\?)/.test(image)) {
|
||||
return 'pdf'
|
||||
}
|
||||
if (/\.(mp3|wav|ogg|m4a|aac|flac|opus)($|\?)/.test(image)) {
|
||||
return 'audio'
|
||||
}
|
||||
if (/\.(png|jpe?g|gif|webp|bmp|svg)($|\?)/.test(image)) {
|
||||
return 'image'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// Полный URL файла улики: относительные имена (ответ UploadFile) и пути
|
||||
// префиксуются хранилищем, внешние http/https ссылки не трогаются.
|
||||
export function applicationFileURL(image: string | undefined): string {
|
||||
if (!image) {
|
||||
return ''
|
||||
}
|
||||
const low = image.toLowerCase()
|
||||
if (low.startsWith('http://') || low.startsWith('https://')) {
|
||||
return image
|
||||
}
|
||||
if (image.startsWith('/')) {
|
||||
return getURL() + image
|
||||
}
|
||||
return `${getURL()}/api/files/${image}`
|
||||
}
|
||||
|
||||
// Последний сегмент имени файла для отображения в редакторе.
|
||||
export function applicationFileName(image: string | undefined): string {
|
||||
if (!image) {
|
||||
return ''
|
||||
}
|
||||
return image.split('/').pop() || image
|
||||
}
|
||||
Reference in New Issue
Block a user