generated from VLADIMIR/template_frontend
Compare commits
2 Commits
2a33998a8c
...
8d24eb2844
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d24eb2844 | |||
| a3f5eef6be |
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">
|
<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-CRAnZlT1.js"></script>
|
<script type="module" crossorigin src="/assets/index-D7Q2J__k.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CtSjox4D.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BsEbL24Y.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -735,6 +735,9 @@ message UploadFileReq {
|
|||||||
message UploadFileRsp {
|
message UploadFileRsp {
|
||||||
string error = 1;
|
string error = 1;
|
||||||
string filename = 2;
|
string filename = 2;
|
||||||
|
// Тип загруженного файла: image | pdf | audio (определяется сервером по
|
||||||
|
// содержимому). Удобен редактору для предзаполнения file_type улики.
|
||||||
|
string file_type = 3 [json_name = "file_type"];
|
||||||
}
|
}
|
||||||
|
|
||||||
message DownloadFileReq {
|
message DownloadFileReq {
|
||||||
@@ -810,6 +813,10 @@ message Place {
|
|||||||
message Application {
|
message Application {
|
||||||
string name = 1;
|
string name = 1;
|
||||||
string image = 2;
|
string image = 2;
|
||||||
|
// Тип файла улики: image | pdf | audio. Значение выводится сервером из
|
||||||
|
// расширения файла (см. file_storage.FileType); пустое значение возможно
|
||||||
|
// только для legacy-строк без расширения.
|
||||||
|
string file_type = 3 [json_name = "file_type"];
|
||||||
}
|
}
|
||||||
|
|
||||||
message Door {
|
message Door {
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ export type UploadFileReq = {
|
|||||||
export type UploadFileRsp = {
|
export type UploadFileRsp = {
|
||||||
error: string | undefined;
|
error: string | undefined;
|
||||||
filename: string | undefined;
|
filename: string | undefined;
|
||||||
|
// Тип загруженного файла: image | pdf | audio (определяется сервером по
|
||||||
|
// содержимому). Удобен редактору для предзаполнения file_type улики.
|
||||||
|
file_type: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DownloadFileReq = {
|
export type DownloadFileReq = {
|
||||||
@@ -226,6 +229,10 @@ export type Place = {
|
|||||||
export type Application = {
|
export type Application = {
|
||||||
name: string | undefined;
|
name: string | undefined;
|
||||||
image: string | undefined;
|
image: string | undefined;
|
||||||
|
// Тип файла улики: image | pdf | audio. Значение выводится сервером из
|
||||||
|
// расширения файла (см. file_storage.FileType); пустое значение возможно
|
||||||
|
// только для legacy-строк без расширения.
|
||||||
|
file_type: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Door = {
|
export type Door = {
|
||||||
|
|||||||
@@ -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 { getURL } from '@/api/generated/crabs/evening_detective_server/client';
|
||||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
import { useSimplePolling } from '@/composables/useSimplePolling';
|
import { useSimplePolling } from '@/composables/useSimplePolling';
|
||||||
|
import {
|
||||||
|
applicationFileType,
|
||||||
|
applicationFileURL,
|
||||||
|
} from '@/utils/application';
|
||||||
|
|
||||||
const client = getAuthClient()
|
const client = getAuthClient()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -124,7 +128,8 @@ const deletedTeam = ref<Team>({
|
|||||||
|
|
||||||
const giveApplicationApplication = ref<Application>({
|
const giveApplicationApplication = ref<Application>({
|
||||||
name: '',
|
name: '',
|
||||||
image: ''
|
image: '',
|
||||||
|
file_type: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const url = window.location.href
|
const url = window.location.href
|
||||||
@@ -447,10 +452,21 @@ useSimplePolling(() => {
|
|||||||
<p>Команда</p>
|
<p>Команда</p>
|
||||||
<h3>{{ giveApplicationTeam.name }}</h3>
|
<h3>{{ giveApplicationTeam.name }}</h3>
|
||||||
|
|
||||||
|
|
||||||
<p>Улика</p>
|
<p>Улика</p>
|
||||||
<h3>{{ giveApplicationApplication.name }}</h3>
|
<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>
|
</n-space>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<n-flex justify="end">
|
<n-flex justify="end">
|
||||||
@@ -606,4 +622,28 @@ useSimplePolling(() => {
|
|||||||
background-color: white;
|
background-color: white;
|
||||||
padding: 12px;
|
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>
|
</style>
|
||||||
|
|||||||
+355
-12
@@ -2,10 +2,16 @@
|
|||||||
import { Key24Regular } from '@vicons/fluent'
|
import { Key24Regular } from '@vicons/fluent'
|
||||||
import { LockOutlined } from '@vicons/material'
|
import { LockOutlined } from '@vicons/material'
|
||||||
import { Icon } from '@vicons/utils'
|
import { Icon } from '@vicons/utils'
|
||||||
import { NButton, NDynamicInput, NInput, NInputGroup, NSpace, NSwitch, NText, NUpload, NUploadDragger, type UploadFileInfo } from 'naive-ui'
|
import { NButton, NCard, NDynamicInput, NInput, NInputGroup, NModal, NSpace, NSwitch, NTag, NText, NUpload, NUploadDragger, type UploadFileInfo } from 'naive-ui'
|
||||||
import type { PropType } from 'vue'
|
import { computed, type PropType,ref } from 'vue'
|
||||||
|
|
||||||
import type { Application, Door, Key, Place } from '@/api/generated/crabs/evening_detective_server'
|
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';
|
import MessagePaper from './MessagePaper.vue';
|
||||||
|
|
||||||
@@ -55,10 +61,11 @@ const updateMode = (mode: string) => {
|
|||||||
emit('update:mode', mode)
|
emit('update:mode', mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCreateApplication(): Application {
|
function onCreateApplication(): EditableApplication {
|
||||||
return {
|
return {
|
||||||
name: undefined,
|
name: undefined,
|
||||||
image: undefined,
|
image: undefined,
|
||||||
|
file_type: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +82,114 @@ function onCreateKey(): Key {
|
|||||||
name: undefined,
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -103,7 +218,8 @@ function onCreateKey(): Key {
|
|||||||
<hr class="hr" />
|
<hr class="hr" />
|
||||||
<div class="message-content">
|
<div class="message-content">
|
||||||
<div v-if="props.place?.image">
|
<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">
|
<img :src="props.place?.image" class="message-image">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +237,19 @@ function onCreateKey(): Key {
|
|||||||
<hr class="hr" v-if="props.place?.applications?.length" />
|
<hr class="hr" v-if="props.place?.applications?.length" />
|
||||||
<div class="message-footer application-block" v-for="application in props.place?.applications"
|
<div class="message-footer application-block" v-for="application in props.place?.applications"
|
||||||
v-bind:key="application.name">
|
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>
|
</div>
|
||||||
|
|
||||||
<p class="gray-block">
|
<p class="gray-block">
|
||||||
@@ -145,11 +273,30 @@ function onCreateKey(): Key {
|
|||||||
<hr class="show-hr" />
|
<hr class="show-hr" />
|
||||||
<div class="message-show-content font">
|
<div class="message-show-content font">
|
||||||
<div v-if="place?.image?.length">
|
<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" />
|
<img v-bind:src="place?.image" class="message-image" />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
<hr class="show-hr" v-if="props.place?.doors?.length" />
|
<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"
|
<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 }}
|
{{ door.code }} {{ door.name }}
|
||||||
</div>
|
</div>
|
||||||
</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-улики (и улики без файла): в самом низу после
|
||||||
Приложение: {{ application.name }}
|
пунктирной линии; PDF открывается по нажатию на название -->
|
||||||
<!-- <div class="application-label">{{ application.number }}</div> -->
|
<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 }}
|
||||||
|
</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>
|
</div>
|
||||||
</MessagePaper>
|
</MessagePaper>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,8 +375,33 @@ function onCreateKey(): Key {
|
|||||||
<n-dynamic-input v-model:value="props.place!.applications" :on-create="onCreateApplication">
|
<n-dynamic-input v-model:value="props.place!.applications" :on-create="onCreateApplication">
|
||||||
<template #create-button-default> Добавить улику </template>
|
<template #create-button-default> Добавить улику </template>
|
||||||
<template #default="{ value }">
|
<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="Название" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n-dynamic-input>
|
</n-dynamic-input>
|
||||||
@@ -274,6 +456,30 @@ function onCreateKey(): Key {
|
|||||||
</div>
|
</div>
|
||||||
</n-space>
|
</n-space>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -326,6 +532,101 @@ function onCreateKey(): Key {
|
|||||||
color: burlywood;
|
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 {
|
.door-block {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 10px 5px 10px 0;
|
margin: 10px 5px 10px 0;
|
||||||
@@ -391,6 +692,7 @@ function onCreateKey(): Key {
|
|||||||
background-size: cover;
|
background-size: cover;
|
||||||
box-shadow: 0 3px 15px rgb(98 98 98);
|
box-shadow: 0 3px 15px rgb(98 98 98);
|
||||||
transform: rotate(-3deg);
|
transform: rotate(-3deg);
|
||||||
|
cursor: zoom-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-image {
|
.message-image {
|
||||||
@@ -449,8 +751,12 @@ function onCreateKey(): Key {
|
|||||||
.message-show-content {
|
.message-show-content {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
/* BFC: плавающий блок улик остаётся внутри текста и не наезжает
|
||||||
|
на следующие секции (двери/действия) при коротком тексте */
|
||||||
|
display: flow-root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* PDF-улики внизу после пунктирной линии */
|
||||||
.message-show-footer {
|
.message-show-footer {
|
||||||
padding-right: 50px;
|
padding-right: 50px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -458,6 +764,43 @@ function onCreateKey(): Key {
|
|||||||
position: relative;
|
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 {
|
.button-dialog {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import IntroBlock from '@/components/IntroBlock.vue'
|
|||||||
import PlaceBlock from '@/components/PlaceBlock.vue'
|
import PlaceBlock from '@/components/PlaceBlock.vue'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type { EditableIntroduction, EditablePlace } from '@/types/editor'
|
import type { EditableApplication, EditableIntroduction, EditablePlace } from '@/types/editor'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const client = getAuthClient()
|
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) {
|
if (!file) {
|
||||||
return ''
|
return { filename: '', fileType: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Читаем как ArrayBuffer
|
// Читаем как ArrayBuffer
|
||||||
@@ -157,10 +162,15 @@ async function uploadFile(file: File | null, filename: string): Promise<string>
|
|||||||
})
|
})
|
||||||
if (resUploadFile.error != '') {
|
if (resUploadFile.error != '') {
|
||||||
message.error(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() {
|
async function updateScenario() {
|
||||||
@@ -170,10 +180,11 @@ async function updateScenario() {
|
|||||||
if (file == null) {
|
if (file == null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resUploadFilename = await uploadFile(file, 'scenarios_' + scenario.value.id + '_' + file.name)
|
const uploaded = await uploadFile(file, 'scenarios_' + scenario.value.id + '_' + file.name)
|
||||||
if (resUploadFilename === '') {
|
if (uploaded.filename === '') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
resUploadFilename = uploaded.filename
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await client.UpdateScenario({
|
const res = await client.UpdateScenario({
|
||||||
@@ -284,11 +295,32 @@ async function updatePlace(place: EditablePlace) {
|
|||||||
if (file == null) {
|
if (file == null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const resUploadFilename = await uploadFile(file, 'scenarios_' + scenario.value.id + '_place_' + place.code + '_' + file.name)
|
const uploaded = await uploadFile(file, 'scenarios_' + scenario.value.id + '_place_' + place.code + '_' + file.name)
|
||||||
if (resUploadFilename === '') {
|
if (uploaded.filename === '') {
|
||||||
return
|
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)
|
await addNotFoundDoors(scenario.value, place)
|
||||||
@@ -335,7 +367,7 @@ async function updateIntro() {
|
|||||||
if (file == null) {
|
if (file == null) {
|
||||||
return
|
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 === '') {
|
if (audio === '') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { UploadFileInfo } from 'naive-ui'
|
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
|
// Служебные поля интерфейса, которых нет в сгенерированной схеме API
|
||||||
// (типы из генератора — type-алиасы, их нельзя расширить через declare module).
|
// (типы из генератора — type-алиасы, их нельзя расширить через declare module).
|
||||||
@@ -21,3 +21,9 @@ export type EditableIntroduction = Introduction & {
|
|||||||
/** Выбранные файлы для загрузки аудио вступления */
|
/** Выбранные файлы для загрузки аудио вступления */
|
||||||
fileList?: UploadFileInfo[]
|
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