This commit is contained in:
2026-08-28 23:30:16 +07:00
parent 8a535a3f3c
commit 2a33998a8c
14 changed files with 2757 additions and 2457 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
+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
+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-CNblC4ZU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BlFsLzpe.css">
<script type="module" crossorigin src="/assets/index-CRAnZlT1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CtSjox4D.css">
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -16,7 +16,7 @@ export default defineConfigWithVueTs(
files: ['**/*.{ts,mts,tsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**', 'src/api/generated/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
+2 -2
View File
@@ -70,7 +70,7 @@ function getTextForCounter(count: number): string[] {
<Footsteps />
</Icon>
</div>
<div v-for="n in getTextForCounter(allPlacesCount)" class="places-text places-text-number">{{ n }}</div>
<div v-for="(n, index) in getTextForCounter(allPlacesCount)" :key="index" class="places-text places-text-number">{{ n }}</div>
</div>
<div class="counter-block">
<div class="places-text">
@@ -78,7 +78,7 @@ function getTextForCounter(count: number): string[] {
<ContactMailRound />
</Icon>
</div>
<div v-for="n in getTextForCounter(newPlacesCount)" class="places-text places-text-number">{{ n }}</div>
<div v-for="(n, index) in getTextForCounter(newPlacesCount)" :key="index" class="places-text places-text-number">{{ n }}</div>
</div>
</div>
+1 -1
View File
@@ -377,7 +377,7 @@ useSimplePolling(() => {
<p class="place-image-plus">+</p>
</div>
<div v-for="team in game.teams" class="team-block">
<div v-for="team in game.teams" :key="team.id" class="team-block">
<div class="team-content-block">
<n-flex justify="space-between">
+198
View File
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { NButton, NInput, NSpace, NText, NUpload, NUploadDragger } from 'naive-ui'
import { computed, type PropType } from 'vue'
import { getURL } from '@/api/generated/crabs/evening_detective_server/client'
import type { EditableIntroduction } from '@/types/editor'
import MessagePaper from './MessagePaper.vue'
const props = defineProps({
introduction: {
type: Object as PropType<EditableIntroduction>,
required: true,
},
mode: {
type: String,
required: true,
},
})
const emit = defineEmits<{
'update:mode': [value: string]
}>()
const updateMode = (mode: string) => {
emit('update:mode', mode)
}
const hasText = computed(() => Boolean(props.introduction?.text))
const hasAudio = computed(() => Boolean(props.introduction?.audio))
// Бэкенд отдаёт аудио полным URL (домен + имя файла); на случай голого
// имени файла строим URL до /api/files самостоятельно.
const audioUrl = computed(() => {
const value = props.introduction?.audio
if (!value) return undefined
if (/^https?:\/\//i.test(value)) return value
return `${getURL()}/api/files/${encodeURIComponent(value)}`
})
const removeAudio = () => {
if (props.introduction) {
props.introduction!.audio = ''
props.introduction!.fileList = undefined
}
}
</script>
<template>
<!-- Просмотр вступления командой (первый лист дела) -->
<div v-if="mode == 'show'">
<MessagePaper>
<div class="intro-title font">Вступление</div>
<hr class="intro-hr" />
<div class="intro-text font">{{ introduction.text }}</div>
<div v-if="hasAudio" class="intro-audio-block">
<audio :src="audioUrl" controls preload="none" class="intro-audio"></audio>
</div>
</MessagePaper>
</div>
<!-- Просмотр вступления в редакторе: клик в режим редактирования -->
<div v-if="mode == 'show-editor'" class="place-block-hover show-editor-block" @click="updateMode('edit')">
<div class="message-header">
Вступление
</div>
<hr class="hr" />
<div class="message-content">
<div v-if="hasAudio" class="intro-audio-block">
<audio :src="audioUrl" controls preload="none" class="intro-audio"></audio>
</div>
<span v-if="hasText">{{ introduction.text }}</span>
<span v-else class="intro-empty">Введение не задано нажмите, чтобы добавить</span>
</div>
</div>
<!-- Редактирование вступления автором -->
<div v-if="mode == 'edit'" class="place-block">
<n-space vertical>
<p class="settings-header">Текст вступления</p>
<n-input v-model:value="props.introduction!.text" type="textarea"
:autosize="{ minRows: 4, maxRows: 12 }"
placeholder="Дорогие сыщики! В этом деле вам предстоит..." />
<p class="settings-header">Аудио вступления</p>
<div class="intro-edit-audio">
<n-space vertical>
<audio v-if="hasAudio" :src="audioUrl" controls preload="none" class="intro-audio"></audio>
<n-text v-else depth="3">Аудио не задано</n-text>
<n-button @click="removeAudio()" :disabled="!hasAudio">Убрать аудио</n-button>
</n-space>
<n-upload directory-dnd :max="1" v-model:file-list="props.introduction!.fileList">
<n-upload-dragger>
<n-text style="font-size: 16px">
Щелкните или перетащите аудиофайл в эту область для загрузки
</n-text>
</n-upload-dragger>
</n-upload>
</div>
<div class="slot-block">
<slot></slot>
</div>
</n-space>
</div>
</template>
<style scoped>
/* Бумажный лист вступления (просмотр командой и в каталоге) */
.font {
font-family: 'font_old_typer';
font-size: 18px;
}
.intro-title {
text-align: center;
font-size: 26px;
letter-spacing: 3px;
color: var(--second-color);
}
.intro-hr {
margin: 10px 0;
border: dashed 1px;
border-color: #222;
}
.intro-text {
font-weight: 500;
white-space: pre-wrap;
line-height: 1.5;
}
/* Панели в стиле блоков точек (см. PlaceBlock.vue) */
.place-block-hover,
.place-block {
margin: 10px 0;
padding: 20px;
border-radius: 10px;
border: 1px solid #222;
background-color: #222;
}
.place-block-hover:hover {
color: #63e2b7;
cursor: pointer;
border: 1px solid #63e2b7;
}
.show-editor-block {
cursor: pointer;
}
.hr {
margin: 10px 0;
border: dashed 1px;
border-color: #eee;
}
.message-header {
font-size: 20px;
padding-right: 50px;
}
.message-content {
font-weight: 500;
white-space: pre-wrap;
}
.intro-empty {
color: #777;
}
.settings-header {
margin-top: 20px;
}
.intro-audio-block {
margin-top: 15px;
}
.intro-audio {
width: 100%;
max-width: 460px;
border-radius: 5px;
}
.intro-edit-audio {
display: flex;
flex-flow: row wrap;
gap: 20px;
align-items: flex-start;
}
.slot-block {
margin-top: 10px;
}
</style>
+9 -4
View File
@@ -2,13 +2,18 @@
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 } from 'naive-ui'
import { NButton, NDynamicInput, NInput, NInputGroup, NSpace, NSwitch, NText, NUpload, NUploadDragger, type UploadFileInfo } from 'naive-ui'
import type { PropType } from 'vue'
import type { Application, Door, Key, Place } from '@/api/generated/crabs/evening_detective_server'
import MessagePaper from './MessagePaper.vue';
// Точка сценария + служебное поле fileList (выбранные файлы для загрузки картинки)
type PlaceWithFileList = Place & {
fileList?: UploadFileInfo[]
}
const props = defineProps({
newPlaceCode: {
type: String,
@@ -20,7 +25,7 @@ const props = defineProps({
type: String,
},
place: {
type: Object as PropType<Place>,
type: Object as PropType<PlaceWithFileList>,
},
mode: {
type: String,
@@ -123,7 +128,7 @@ function onCreateKey(): Key {
<span v-if="props.place?.hidden">
Скрытая точка
</span>
<span v-for="key in props.place?.keys" class="key-block">
<span v-for="key in props.place?.keys" :key="key.name" class="key-block">
<Icon class="lock-icon">
<Key24Regular />
</Icon> {{ key.name }}
@@ -184,7 +189,7 @@ function onCreateKey(): Key {
<n-space>
<n-space vertical>
<img :src="props.place!.image" class="message-edit-image">
<n-button @click="props.place!.image = '', props.place!.fileList = null">Убрать картинку</n-button>
<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 />
+4
View File
@@ -7,6 +7,7 @@ import { useRoute } from 'vue-router'
import { getAuthClient } from '@/api/auth_client'
import type { Scenario } from '@/api/generated/crabs/evening_detective_server'
import HeaderMenu from '@/components/HeaderMenu.vue'
import IntroBlock from '@/components/IntroBlock.vue'
const message = useMessage()
const client = getAuthClient()
@@ -50,6 +51,9 @@ getScenario(Number(scenarioId))
<n-tag :bordered="false" type="success" class="status-block">Автор: {{ scenario.author?.username
}}</n-tag>
</div>
<IntroBlock
v-if="scenario.story?.introduction && (scenario.story.introduction.text || scenario.story.introduction.audio)"
:introduction="scenario.story.introduction" mode="show" />
</div>
</div>
+73 -10
View File
@@ -3,16 +3,18 @@ import { Settings } from '@vicons/carbon'
import { Icon } from '@vicons/utils'
import { NButton, type UploadFileInfo, useMessage } from 'naive-ui'
import { NAlert, NCard, NFlex, NInput, NModal, NSpace, NTag, NText, NUpload, NUploadDragger } from 'naive-ui'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getArchiveClient } from '@/api/archive_client'
import { getAuthClient } from '@/api/auth_client'
import type { Place, Scenario } from '@/api/generated/crabs/evening_detective_server'
import type { Scenario } from '@/api/generated/crabs/evening_detective_server'
import HeaderMenu from '@/components/HeaderMenu.vue'
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'
const authStore = useAuthStore()
const client = getAuthClient()
@@ -40,6 +42,14 @@ const scenario = ref<Scenario>({
publishedAt: undefined,
})
const intro = ref<EditableIntroduction>({ text: '', audio: '' })
const introMode = ref('show-editor')
// Точки сценария со служебными полями редактора (oldCode, mode, fileList)
const editorPlaces = computed<EditablePlace[]>(() => {
return (scenario.value.story?.places || []) as EditablePlace[]
})
async function getScenario(id: number) {
const res = await client.GetFullScenario({ id: id })
if (res.error != '') {
@@ -48,9 +58,12 @@ async function getScenario(id: number) {
}
scenario.value = res.scenario!
scenario.value.story?.places?.forEach((item) => {
item.oldCode = item.code
item.mode = 'show-editor'
const editable = item as EditablePlace
editable.oldCode = editable.code
editable.mode = 'show-editor'
})
intro.value = scenario.value.story?.introduction || { text: '', audio: '' }
introMode.value = 'show-editor'
}
getScenario(Number(scenarioId))
@@ -232,7 +245,7 @@ async function cancelAddPlace() {
mode.value = 'mini-add'
}
async function addNotFoundDoors(scenario: Scenario, place: Place) {
async function addNotFoundDoors(scenario: Scenario, place: EditablePlace) {
for (const door of place.doors || []) {
const place = scenario.story?.places?.find((place) => {
return place.code == door.code
@@ -259,13 +272,13 @@ async function addNotFoundDoors(scenario: Scenario, place: Place) {
}
}
async function cancelUpdatePlace(place: Place) {
async function cancelUpdatePlace(place: EditablePlace) {
await getScenario(Number(scenarioId))
place.mode = 'show-editor'
}
async function updatePlace(place: Place) {
async function updatePlace(place: EditablePlace) {
if (place.fileList && place.fileList.length > 0) {
const file = place.fileList[0].file || null
if (file == null) {
@@ -293,7 +306,7 @@ async function updatePlace(place: Place) {
place.mode = 'show-editor'
}
async function deletePlace(place: Place) {
async function deletePlace(place: EditablePlace) {
const res = await client.DeleteScenarioPlace({
id: scenario.value.id,
code: place.oldCode,
@@ -305,6 +318,49 @@ async function deletePlace(place: Place) {
await getScenario(Number(scenarioId))
}
// Бэкенд хранит аудио вступления как имя файла, а отдаёт полным URL
// (домен + имя). При сохранении отправляем только имя файла.
function toRawFilename(value: string | undefined): string {
if (!value) {
return ''
}
return value.split('/').pop() || ''
}
async function updateIntro() {
let audio = ''
// NUpload кладёт выбранный файл в intro.fileList (поле объекта вступления)
if (intro.value.fileList && intro.value.fileList.length > 0) {
const file = intro.value.fileList[0].file || null
if (file == null) {
return
}
audio = await uploadFile(file, 'scenarios_' + scenario.value.id + '_intro_' + file.name)
if (audio === '') {
return
}
} else {
audio = toRawFilename(intro.value.audio)
}
const res = await client.UpdateScenarioIntro({
id: scenario.value.id,
introduction: {
text: intro.value.text,
audio: audio,
},
})
if (res.error != '') {
message.error(res.error!)
return
}
await getScenario(Number(scenarioId))
}
async function cancelUpdateIntro() {
await getScenario(Number(scenarioId))
}
function getRandomComplimentForNastya() {
const compliments = [
{ text: 'Настя, ты просто ослепительна сегодня!', nameForm: 'Настя' },
@@ -348,6 +404,13 @@ function getRandomComplimentForNastya() {
</n-button>
</div>
<IntroBlock :introduction="intro" v-model:mode="introMode">
<n-flex justify="end">
<n-button @click="cancelUpdateIntro()">Отмена</n-button>
<n-button @click="updateIntro()">Сохранить вступление</n-button>
</n-flex>
</IntroBlock>
<PlaceBlock v-model:mode="mode" v-model:newPlaceCode="code" v-model:newPlaceName="name"
v-model:newPlaceText="text">
<n-flex justify="end">
@@ -356,8 +419,8 @@ function getRandomComplimentForNastya() {
</n-flex>
</PlaceBlock>
<PlaceBlock :place="place" v-model:mode="place.mode" v-for="place in scenario.story?.places"
v-bind:key="place.oldCode">
<PlaceBlock :place="place" v-model:mode="place.mode" v-for="place in editorPlaces"
v-bind:key="place.oldCode ?? place.code ?? ''">
<n-flex justify="end">
<n-button @click="deletePlace(place)" type="error" ghost>Удалить</n-button>
<n-button @click="cancelUpdatePlace(place)">Отмена</n-button>
+13 -6
View File
@@ -2,12 +2,13 @@
import { NButton, NFlex, NQrCode } from 'naive-ui'
import { useMessage } from 'naive-ui';
import { nextTick, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRoute } from 'vue-router';
import { getAuthClient } from '@/api/auth_client';
import type { Game, Place } from '@/api/generated/crabs/evening_detective_server';
import type { Game, Introduction, Place } from '@/api/generated/crabs/evening_detective_server';
import GameInputForm from '@/components/GameInputForm.vue'
import HeaderMenu from '@/components/HeaderMenu.vue'
import IntroBlock from '@/components/IntroBlock.vue'
import PlaceBlock from '@/components/PlaceBlock.vue'
import { useSimplePolling } from '@/composables/useSimplePolling';
import { useAuthStore } from '@/stores/auth';
@@ -17,7 +18,6 @@ import MessagePaper from './MessagePaper.vue';
const authStore = useAuthStore()
const client = getAuthClient()
const message = useMessage()
const router = useRouter()
const route = useRoute()
const teamId = route.params.id
const password = route.query.password
@@ -27,6 +27,7 @@ const newPlacesCount = ref(0)
const scrollContainer = ref<HTMLDivElement | null>();
const story = ref<AdvancedStory>({
introduction: undefined,
places: []
})
@@ -74,6 +75,7 @@ async function getStory() {
const oldCount = story.value.places.length
story.value = {
introduction: res.story!.introduction,
places: res.story!.places?.map((item, i) => {
console.log(oldStory?.places[i]?.mode)
return {
@@ -116,6 +118,7 @@ async function addAction(newCode: string) {
}
type AdvancedStory = {
introduction?: Introduction;
places: AdvancedPlace[];
}
@@ -148,19 +151,23 @@ useSimplePolling(() => {
<GameInputForm :addAction="addAction" :allPlacesCount="story.places.length" :newPlacesCount="newPlacesCount"
:gameStatus="game?.status || ''"></GameInputForm>
<IntroBlock
v-if="game?.status && game.status != 'draft' && story.introduction && (story.introduction.text || story.introduction.audio)"
:introduction="story.introduction" mode="show">
</IntroBlock>
<PlaceBlock :place="advancedPlace.place" v-model:mode="advancedPlace.mode"
v-for="advancedPlace in story.places" v-bind:key="advancedPlace.place.code" :addAction="addAction">
</PlaceBlock>
<MessagePaper class="qr-main-block" v-if="story.places.length == 0">
<MessagePaper class="qr-main-block" v-if="story.places.length == 0 && game?.status == 'draft'">
<n-space vertical>
<h2 class="qr-main-block-text">Это код этого дела</h2>
<p class="qr-main-block-text">Поделись с командой</p>
<div class="qr-block">
<n-qr-code :value="url" :size="300" :padding="0" />
</div>
<p v-if="game?.status == 'draft'" class="qr-main-block-text">Игра скоро начнется</p>
<p v-if="game?.status == 'run'" class="qr-main-block-text">Игра началась!!!</p>
<p class="qr-main-block-text">Игра скоро начнется</p>
</n-space>
</MessagePaper>
+23
View File
@@ -0,0 +1,23 @@
import type { UploadFileInfo } from 'naive-ui'
import type { Introduction, Place } from '@/api/generated/crabs/evening_detective_server'
// Служебные поля интерфейса, которых нет в сгенерированной схеме API
// (типы из генератора — type-алиасы, их нельзя расширить через declare module).
// Редактор сценария хранит эти поля прямо на объектах API-моделей.
/** Точка сценария со служебными полями редактора */
export type EditablePlace = Place & {
/** Код точки до редактирования (нужен для UpdateScenarioPlace) */
oldCode?: string
/** Режим отображения блока точки в редакторе (заполняется в getScenario) */
mode: string
/** Выбранные файлы для загрузки картинки точки */
fileList?: UploadFileInfo[]
}
/** Вступление сценария со служебными полями редактора */
export type EditableIntroduction = Introduction & {
/** Выбранные файлы для загрузки аудио вступления */
fileList?: UploadFileInfo[]
}