Compare commits

...

2 Commits

Author SHA1 Message Date
VLADIMIR 8d24eb2844 update files 2026-08-31 00:05:01 +07:00
VLADIMIR a3f5eef6be up api 2026-08-30 22:11:28 +07:00
12 changed files with 2941 additions and 2459 deletions
File diff suppressed because one or more lines are too long
-2430
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2431
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>
+7
View File
@@ -735,6 +735,9 @@ message UploadFileReq {
message UploadFileRsp {
string error = 1;
string filename = 2;
// Тип загруженного файла: image | pdf | audio (определяется сервером по
// содержимому). Удобен редактору для предзаполнения file_type улики.
string file_type = 3 [json_name = "file_type"];
}
message DownloadFileReq {
@@ -810,6 +813,10 @@ message Place {
message Application {
string name = 1;
string image = 2;
// Тип файла улики: image | pdf | audio. Значение выводится сервером из
// расширения файла (см. file_storage.FileType); пустое значение возможно
// только для legacy-строк без расширения.
string file_type = 3 [json_name = "file_type"];
}
message Door {
@@ -166,6 +166,9 @@ export type UploadFileReq = {
export type UploadFileRsp = {
error: string | undefined;
filename: string | undefined;
// Тип загруженного файла: image | pdf | audio (определяется сервером по
// содержимому). Удобен редактору для предзаполнения file_type улики.
file_type: string | undefined;
};
export type DownloadFileReq = {
@@ -226,6 +229,10 @@ export type Place = {
export type Application = {
name: string | undefined;
image: string | undefined;
// Тип файла улики: image | pdf | audio. Значение выводится сервером из
// расширения файла (см. file_storage.FileType); пустое значение возможно
// только для legacy-строк без расширения.
file_type: string | undefined;
};
export type Door = {
+42 -2
View File
@@ -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>
+355 -12
View File
@@ -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">
Приложение: {{ application.name }}
<!-- <div class="application-label">{{ application.number }}</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>
@@ -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;
+43 -11
View File
@@ -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
View File
@@ -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[]
}
+46
View File
@@ -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
}