Compare commits

...

3 Commits

Author SHA1 Message Date
VLADIMIR 1bf463de02 update agents 2026-08-31 01:33:24 +07:00
VLADIMIR 8d24eb2844 update files 2026-08-31 00:05:01 +07:00
VLADIMIR a3f5eef6be up api 2026-08-30 22:11:28 +07:00
13 changed files with 3045 additions and 2459 deletions
+104
View File
@@ -0,0 +1,104 @@
# AGENTS.md — Вечерний детектив (фронтенд)
Правила работы агента над этим репозиторием. Читай целиком перед началом любой задачи.
Онлайн-детективная игра: команды проходят «дело» — сценарий из точек, дверей, ключей и улик.
## 1. Стек и команды
- **Стек:** Vue 3.5 (`<script setup lang="ts">`), Vite 8, Pinia 3, Vue Router 5, Naive UI (тёмная тема), @vicons, axios.
- **API:** клиенты генерируются из `proto/main.proto``src/api/generated/`.
| Команда | Назначение |
|---|---|
| `npm run dev` | dev-сервер (Vite, `--host`) |
| `npm run type-check` | проверка типов (vue-tsc) |
| `npm run lint` | ESLint с автофиксами |
| `npm run lint:css` | Stylelint (алфавитный порядок свойств) |
| `npm run format` | Prettier (`src/`) |
| `npm run build` | type-check + сборка |
| `npm run proto:gen` | генерация API-клиентов из proto (после изменения proto) |
## 2. Структура и соглашения
- `src/views/*View.vue` — тонкие обёртки над страницами (только импорт и `<Page />`).
- `src/components/*Page.vue` — страницы (GamesPage, OfficePage, TeamStoryPage…).
- `src/components/*.vue` — переиспользуемые блоки (HeaderMenu, MessagePaper, PlaceBlock, IntroBlock…).
- `src/api/generated/**`**сгенерированный код, руками не редактировать.** Обновляется только через `npm run proto:gen`.
- `src/stores/`, `src/composables/`, `src/utils/`, `src/types/` — по назначению.
- Алиас `@/``src/`. Импорты: `@/components/...` для общих блоков, относительные для соседних файлов.
- UI-тексты — на русском.
## 3. Правила выполнения задач
1. **Сначала изучи.** Прочитай `package.json`, `tsconfig.app.json`, релевантные компоненты и существующие паттерны. Следуй им; не вводи новый стек, механизмы и зависимости без согласования с пользователем.
2. **Код:** `defineProps` с типами (`Object as PropType<T>`), типизированные `emit`, производные значения через `computed`. Не дублируй логику — выноси в `composables/`/`utils/`.
3. **Импорты:** сортируй (simple-import-sort): сторонние → `@/` → относительные. Удаляй неиспользуемые (unused-imports). Точки с запятой не ставим, одинарные кавычки, ширина 100 (Prettier).
4. **API:** только через сгенерированные клиенты (`getAuthClient()` и т.п.). После вызова проверяй `res.error` и показывай `message.error(res.error!)`. Поля из proto необязательны — бери с `!` как в существующем коде.
5. **Ошибки и состояния:** используй `useMessage()` из naive-ui; загрузку — простым `ref` + асинхронная функция (существующий паттерн), поллинг — `useSimplePolling`.
6. **Стили:** `scoped`, свойства по алфавиту (stylelint). Не добавляй новые цвета хардкодом — только палитра из раздела 4.
7. **Адаптивность:** мобильная вёрстка по умолчанию, брейкпоинт `@media (width >= 1024px)` (существующий паттерн). Скрывай скроллбары как `.center-block-custom`.
8. **Проверки перед сдачей (всегда):** `npm run type-check``npm run lint``npm run lint:css`. При затрагивании сборки — `npm run build`. Сообщи в ответе, какие проверки прошли.
9. **Запрещено:** `git commit` и `git push` (глобальное правило). Не переписывай чужой код без необходимости — минимальные целевые изменения.
## 4. Правила дизайна (канон «бумажного детектива»)
Стилистика проекта — тёмный «кабинет сыщика»: тёмный фон, «бумажные листы дел» с текстурой и шрифтом печатной машинки, бронзовые/золотые акценты, зелёный акцент интерактива.
### 4.1 Палитра (использовать только эти значения)
| Роль | Значение |
|---|---|
| Фон страницы | `#111` |
| Панели / карточки / блоки | `#222` (border-radius: 10px) |
| Основной текст | `#eee` |
| Вторичный текст (подписи, служебное) | `#aaa`, `#777` |
| Акцент hover/active | `#63e2b7` |
| Бронза (заголовки на бумаге, PDF-ссылки) | `--second-color: rgb(97 74 22)` |
| Золото (ключи, «требуемые ключи») | `gold` |
| Кнопки-действия на бумаге | текст `#bfa07d`, фон — текстура `belt.png` |
| Подписи улик/приложений | `burlywood` |
| Текст на бумажном листе | `black` |
### 4.2 Типографика
- **UI (формы, панели, навигация):** системный стек из `base.css`, 15px.
- **Бумажные листы дел:** `font-family: 'font_old_typer'` (шрифт подключён в `main.css`, файл `src/assets/fonts/a_OldTyper.ttf`). Контент — 18px, заголовок вступления — 26px с `letter-spacing: 3px`.
- Никогда не используй `font_old_typer` для UI-элементов (кнопки, формы, меню).
### 4.3 Бумажный лист (компонент MessagePaper)
Контент дел (вступление, точки, приложения) оборачивается в `MessagePaper`:
- Фон — текстура `paper.jpg` (`background-size: cover`), тень `0 0 5px black`, слои-подложки с поворотами `rotate(-3deg)` / `rotate(2deg)`.
- Разделители — пунктирные `hr`: `border: dashed 1px` (на бумаге — `black`, в тёмных панелях — `#eee`).
- **«Фотографии»** (картинка точки, картинки-улики): подложка с текстурой `paper_white.jpg`, паддинг 7px, `transform: rotate(-3deg)`, тень `0 3px 15px rgb(98 98 98)`, `cursor: zoom-in`, клик — просмотр в `n-modal` + `n-card`.
- **Улики-картинки в потоке текста:** блок `float: right`, текст обтекает; контейнер — `display: flow-root` (BFC, чтобы не наезжало на двери).
- **Кнопки действий («двери»):** фон `belt.png` (`background-size: cover`), текст `#bfa07d`, `border-radius: 5px`, inline-block, паддинг `5px 12px`.
- **Сворачивание листа:** иконка `collapse.png` (45×28), развёрнутый лист — `rotate(-180deg)`.
### 4.4 Компоненты и иконки
- UI-контролы — только naive-ui (`n-*`): тёмная тема задана в `App.vue` (`NConfigProvider :theme="darkTheme"`). Не стилизуй нативные `<input>/<button>`.
- Иконки — `@vicons` через обёртку `<Icon>` из `@vicons/utils`, размер через `size` (например `<Icon size="28"><MenuOutlined /></Icon>`).
- Модальные окна просмотра — `n-modal` + `n-card`, ширина `min(94vw, 1200px)`, `role="dialog" aria-modal="true"`.
### 4.5 Раскладка страниц
- Фиксированный `HeaderMenu` сверху, высота 70px; контент — контейнер `.center-block-custom` (`margin-top: 70px`, высота `calc(100dvh - 70px)`, скрытый скроллбар).
- Ширина контента: `.width700` — бумажные листы/дела, `.width1200` — списки и таблицы.
- Карточки списков (игры, сценарии): фон `#222`, `border-radius: 10px`, hover — `color: #63e2b7` + `cursor: pointer`.
- Интерактивные блоки-«листы» в редакторе: `.place-block-hover` — hover-рамка `1px solid #63e2b7`.
### 4.6 Поведение и состояния
- Hover интерактивных элементов — только `#63e2b7` (не изобретай новые цвета).
- Заголовки и служебные подписи — вторичные цвета палитры (`#aaa`/`#777`), без ярких акцентов.
- Новые цвета/токены добавлять только через `src/assets/base.css` в `:root` (существующие: `--main-color`, `--second-color`, `--main-back-color`, `--main-back-item-color`) — и только если без них нельзя обойтись палитрой.
## 5. Чек-лист перед сдачей
1. ✅ Соблюдены соглашения структуры и импортов (разделы 2–3).
2. ✅ Использована только палитра и стилистика раздела 4.
3. ✅ Адаптивность и скролл-контейнеры как в существующих страницах.
4.`npm run type-check`, `npm run lint`, `npm run lint:css` — без ошибок (отчёт в ответе).
5. ✅ Сгенерированный код не тронут; git commit/push не выполнялись.
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"> <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>
+7
View File
@@ -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 = {
+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 { 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>
+354 -11
View File
@@ -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-улики (и улики без файла): в самом низу после
пунктирной линии; 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 }} Приложение: {{ 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> </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;
+43 -11
View File
@@ -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
View File
@@ -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[]
}
+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
}