Files
evening_detective_frontend/src/components/PlaceBlock.vue
T
2026-09-02 00:59:46 +07:00

956 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { Key24Regular } from '@vicons/fluent'
import { LockOutlined } from '@vicons/material'
import { Icon } from '@vicons/utils'
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'
// Точка сценария + служебное поле fileList (выбранные файлы для загрузки картинки)
type PlaceWithFileList = Place & {
fileList?: UploadFileInfo[]
}
const props = defineProps({
newPlaceCode: {
type: String,
},
newPlaceName: {
type: String,
},
newPlaceText: {
type: String,
},
place: {
type: Object as PropType<PlaceWithFileList>,
},
mode: {
type: String,
required: true,
},
addAction: {
type: Function as PropType<(param: string) => void>,
},
})
const emit = defineEmits<{
'update:newPlaceCode': [value: string]
'update:newPlaceName': [value: string]
'update:newPlaceText': [value: string]
'update:mode': [value: string]
}>()
const updateCode = (code: string) => {
emit('update:newPlaceCode', code)
}
const updateName = (name: string) => {
emit('update:newPlaceName', name)
}
const updateText = (text: string) => {
emit('update:newPlaceText', text)
}
const updateMode = (mode: string) => {
emit('update:mode', mode)
}
function onCreateApplication(): EditableApplication {
return {
name: undefined,
image: undefined,
file_type: undefined,
}
}
function onCreateDoor(): Door {
return {
code: undefined,
name: undefined,
keys: undefined,
}
}
function onCreateKey(): Key {
return {
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>
<div
v-if="mode == 'mini-add'"
class="place-block-hover show-editor-block"
@click="updateMode('add')"
>
<p class="place-image-plus">+</p>
</div>
<div v-if="mode == 'add'" class="place-block">
<n-space vertical>
<n-input-group>
<n-input
:style="{ width: '20%' }"
:value="newPlaceCode"
@update:value="updateCode"
type="text"
placeholder="Ы-1"
/>
<n-input
:style="{ width: '80%' }"
:value="newPlaceName"
@update:value="updateName"
type="text"
placeholder="Дом №Ы"
/>
</n-input-group>
<n-input
:value="newPlaceText"
@update:value="updateText"
type="textarea"
:autosize="{ minRows: 3 }"
placeholder="В начале было слово..."
/>
<slot></slot>
</n-space>
</div>
<div
v-if="mode == 'show-editor'"
class="place-block-hover show-editor-block"
@click="updateMode('edit')"
>
<div class="message-header">{{ props.place?.code }}: {{ props.place?.name }}</div>
<hr class="hr" />
<div class="message-content">
<div v-if="props.place?.image">
<div
class="message-image-border"
@click.stop="openImageView(props.place?.name || '', props.place?.image || '')"
>
<img :src="props.place?.image" class="message-image" />
</div>
</div>
{{ props.place?.text }}
</div>
<div v-for="door in props.place?.doors" v-bind:key="door.code" class="door-block">
{{ door.name }}
<span v-for="key in door.keys" :key="key.name" class="key-block">
<Icon class="lock-icon">
<LockOutlined />
</Icon>
{{ key.name }}
</span>
</div>
<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"
>
<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">
<span v-if="props.place?.hidden"> Скрытая точка </span>
<span v-for="key in props.place?.keys" :key="key.name" class="key-block">
<Icon class="lock-icon">
<Key24Regular />
</Icon>
{{ key.name }}
</span>
</p>
</div>
<div v-if="mode == 'show'">
<MessagePaper>
<div class="message-show-header font">
{{ place?.code }}: {{ place?.name }}
<span class="collapse-icon collapse-icon-up" @click="updateMode('show-collapse')"></span>
</div>
<hr class="show-hr" />
<div class="message-show-content font">
<div v-if="place?.image?.length">
<div
class="message-image-border"
@click="openImageView(place?.name || '', place?.image || '')"
>
<img v-bind:src="place?.image" class="message-image" />
</div>
</div>
<!-- Если у точки нет своей картинки, первая картинка-улика занимает
её место в самом начале текста -->
<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"
v-on:click="addAction!(door.code!)"
>
<div class="button-dialog-text">{{ door.code }} {{ door.name }}</div>
</div>
<!-- Аудио и 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 }}
</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>
<div v-if="mode == 'show-collapse'">
<MessagePaper>
<div class="message-header font">
{{ place?.code }}: {{ place?.name }}
<span class="collapse-icon" @click="updateMode('show')"></span>
</div>
</MessagePaper>
</div>
<div v-if="mode == 'edit'" class="place-block">
<n-space vertical>
<n-input-group>
<n-input
:style="{ width: '20%' }"
v-model:value="props.place!.code"
type="text"
placeholder="Ы-1"
/>
<n-input
:style="{ width: '80%' }"
v-model:value="props.place!.name"
type="text"
placeholder="Дом №Ы"
/>
</n-input-group>
<n-input
v-model:value="props.place!.text"
type="textarea"
:autosize="{ minRows: 3 }"
placeholder="В начале было слово..."
/>
<p class="settings-header">Картинка</p>
<div>
<n-space>
<n-space vertical>
<img :src="props.place!.image" class="message-edit-image" />
<n-button @click="((props.place!.image = ''), (props.place!.fileList = undefined))"
>Убрать картинку</n-button
>
</n-space>
<n-space vertical>
<n-input
v-model:value="props.place!.image"
type="text"
placeholder="Не задано"
disabled
/>
<n-upload directory-dnd :max="1" v-model:file-list="props.place!.fileList">
<n-upload-dragger>
<n-text style="font-size: 16px">
Щелкните или перетащите файл в эту область для загрузки
</n-text>
</n-upload-dragger>
</n-upload>
</n-space>
</n-space>
</div>
<p class="settings-header">Видимость точки</p>
<n-switch v-model:value="props.place!.hidden">
<template #checked> Скрытая точка </template>
<template #unchecked> Обычная точка </template>
</n-switch>
Улики
<n-dynamic-input v-model:value="props.place!.applications" :on-create="onCreateApplication">
<template #create-button-default> Добавить улику </template>
<template #default="{ value }">
<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>
Действия
<n-dynamic-input v-model:value="props.place!.doors" :on-create="onCreateDoor">
<template #create-button-default> Добавить действие </template>
<template #action="{ index, create, remove }">
<div style="display: flex; flex-direction: column">
<n-button @click="() => create(index)" class="plus-minus-buttons">+</n-button>
<n-button @click="() => remove(index)" class="plus-minus-buttons">-</n-button>
</div>
</template>
<template #default="{ value: door }">
<div style="display: flex; flex-direction: column; width: 100%">
<div class="door-add-block">
<n-input-group>
<n-input
:style="{ width: '30%' }"
v-model:value="door.code"
type="text"
placeholder="Код"
/>
<n-input
:style="{ width: '70%' }"
v-model:value="door.name"
type="text"
placeholder="Название"
/>
</n-input-group>
<p class="need-keys-text">Требуемые ключи</p>
<div class="door-field">
<n-dynamic-input v-model:value="door.keys" :on-create="onCreateKey">
<template #create-button-default> Добавить ключ </template>
<template #default="{ value: key }">
<div style="align-items: center; display: flex; width: 100%">
<n-input v-model:value="key.name" type="text" placeholder="Название" />
</div>
</template>
</n-dynamic-input>
</div>
</div>
</div>
</template>
</n-dynamic-input>
Получаемые ключи
<n-dynamic-input v-model:value="props.place!.keys" :on-create="onCreateKey">
<template #create-button-default> Добавить ключ </template>
<template #default="{ value: key }">
<div style="align-items: center; display: flex; width: 100%">
<n-input v-model:value="key.name" type="text" placeholder="Название" />
</div>
</template>
</n-dynamic-input>
<div class="slot-block">
<slot></slot>
</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>
.hr {
border: dashed 1px;
/* border-color: black; */
border-color: #eee;
margin: 10px 0;
}
.place-block-hover,
.place-block {
background-color: #222;
border: 1px solid #222;
border-radius: 10px;
margin: 10px 0;
padding: 20px;
}
.place-block-hover:hover {
border: 1px solid #63e2b7;
color: #63e2b7;
cursor: pointer;
}
.place-image-plus {
align-items: center;
display: flex;
font-size: 30px;
justify-content: center;
}
.show-editor-block {
cursor: pointer;
}
.message-content {
font-weight: 500;
white-space: pre-wrap;
}
.gray-block {
color: #777;
margin-top: 5px;
}
.application-block {
color: burlywood;
margin-right: 5px;
}
.application-name {
margin-top: 5px;
}
.application-image-border {
background-image: url('@/assets/images/paper_white.jpg');
background-size: cover;
box-shadow: 0 3px 15px rgb(98 98 98);
cursor: zoom-in;
float: left;
margin-right: 15px;
max-width: 120px;
padding: 7px;
transform: rotate(-3deg);
width: 40%;
}
/* В редакторе улики показываются друг под другом:
картинка улики не плавает, а идёт под своим названием */
.message-footer .application-image-border {
float: none;
margin: 4px 0 10px;
max-width: 120px;
width: 120px;
}
.application-image {
width: 100%;
}
.application-image-block {
background-image: url('@/assets/images/paper_white.jpg');
background-size: cover;
box-shadow: 0 3px 15px rgb(98 98 98);
cursor: zoom-in;
margin: 14px 0 5px;
max-width: 120px;
padding: 7px;
}
.application-audio {
margin: 5px 0;
width: 100%;
}
.application-pdf-link {
color: var(--second-color);
cursor: pointer;
display: inline-block;
margin: 5px 0;
text-decoration: underline;
}
.view-application-image-block {
text-align: center;
}
.view-application-image {
max-height: 72vh;
max-width: 100%;
}
.view-application-pdf {
border: none;
height: 72vh;
width: 100%;
}
.view-application-audio {
width: 100%;
}
.application-edit-file {
background-color: #222;
border-radius: 5px;
margin: 8px 0 4px;
padding: 8px;
}
.application-edit-image {
border-radius: 4px;
margin-top: 6px;
max-width: 120px;
width: 100%;
}
.application-edit-audio {
margin-top: 6px;
width: 100%;
}
.application-label {
font-weight: 600;
}
.door-block {
background-color: #222;
border-radius: 5px;
color: #eee;
display: inline-block;
margin: 10px 5px 10px 0;
padding: 5px 10px;
}
.door-add-block {
background-color: #222;
border-radius: 5px;
color: #eee;
padding: 10px;
}
.key-block {
color: gold;
margin-left: 10px;
}
.lock-icon {
position: relative;
top: 2px;
}
.door-field {
margin-top: 10px;
}
.need-keys-text {
color: gold;
margin: 15px 0 0;
padding: 0;
}
.slot-block {
margin-top: 10px;
}
.plus-minus-buttons {
border: 1px solid #444;
border-radius: 40px;
color: #eee;
font-size: 25px;
margin: 0 0 10px 5px;
padding: 0 13px;
text-align: center;
}
.plus-minus-buttons:hover {
color: #63e2b7;
cursor: pointer;
}
.message-image-border {
background-image: url('@/assets/images/paper_white.jpg');
background-size: cover;
box-shadow: 0 3px 15px rgb(98 98 98);
cursor: zoom-in;
float: left;
margin-right: 15px;
max-width: 200px;
padding: 7px;
transform: rotate(-3deg);
width: 40%;
}
.message-image {
width: 100%;
}
.message-edit-image {
height: 135px;
width: 135px;
}
.message-header {
font-size: 20px;
padding-right: 50px;
}
.message-footer {
color: burlywood;
font-weight: 400;
padding-right: 50px;
position: relative;
}
.message-show-header {
font-size: 20px;
padding-right: 50px;
}
.collapse-icon {
background-image: url('@/assets/images/collapse.png');
background-position: center;
background-size: cover;
cursor: pointer;
height: 28px;
position: absolute;
right: 12px;
top: 12px;
width: 45px;
}
.collapse-icon-up {
transform: rotate(-180deg);
}
.show-hr {
border: dashed 1px;
border-color: black;
margin: 10px 0;
}
.font {
font-family: 'font_old_typer', serif;
font-size: 18px;
}
.message-show-content {
/* BFC: плавающий блок улик остаётся внутри текста и не наезжает
на следующие секции (двери/действия) при коротком тексте */
display: flow-root;
font-weight: 500;
white-space: pre-wrap;
}
/* PDF-улики внизу после пунктирной линии */
.message-show-footer {
color: var(--second-color);
font-weight: 400;
padding-right: 50px;
position: relative;
}
.pdf-application-name {
color: var(--second-color);
cursor: pointer;
text-decoration: underline;
}
/* Улики встроены в поток текста: блок прижат к правому краю,
текст обтекает его с левой стороны */
.show-applications-block {
align-items: flex-end;
display: flex;
flex-direction: column;
float: right;
gap: 12px;
margin: 4px 0 10px 14px;
}
.show-application {
align-items: flex-end;
display: flex;
flex-direction: column;
max-width: 100%;
}
.show-application .application-image-block {
margin: 4px 0 0;
}
.show-application .application-audio {
margin: 4px 0 0;
width: 260px;
}
.show-application .application-pdf-link {
margin: 4px 0 0;
}
.button-dialog {
background-image: url('@/assets/images/belt.png');
background-size: cover;
border-radius: 5px;
color: #bfa07d;
cursor: pointer;
display: inline-block;
font-size: 16px;
font-weight: 600;
margin-right: 10px;
margin-top: 10px;
}
.button-dialog-text {
padding: 5px 12px;
}
</style>