generated from VLADIMIR/template_frontend
1017 lines
30 KiB
Vue
1017 lines
30 KiB
Vue
<script setup lang="ts">
|
||
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 { computed, ref } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
|
||
import { getAuthClient } from '@/api/auth_client'
|
||
import { getBinaryClient } from '@/api/binary_client'
|
||
import type { Conclusion, 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 QuestionBlock from '@/components/QuestionBlock.vue'
|
||
import router from '@/router'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import type {
|
||
EditableApplication,
|
||
EditableIntroduction,
|
||
EditablePlace,
|
||
EditableQuestion,
|
||
} from '@/types/editor'
|
||
import { applicationFileURL } from '@/utils/application'
|
||
|
||
const authStore = useAuthStore()
|
||
const client = getAuthClient()
|
||
const archiveClient = getBinaryClient()
|
||
const route = useRoute()
|
||
const scenarioId = route.params.id
|
||
|
||
const showSettingsModal = ref(false)
|
||
const statusScenarioName = ref('')
|
||
const deletedScenarioName = ref('')
|
||
const isDownloadingArchive = ref(false)
|
||
|
||
const message = useMessage()
|
||
|
||
const fileList = ref<UploadFileInfo[]>([])
|
||
|
||
const scenario = ref<Scenario>({
|
||
ID: undefined,
|
||
name: undefined,
|
||
story: undefined,
|
||
author: undefined,
|
||
status: undefined,
|
||
updatedAt: undefined,
|
||
createdAt: undefined,
|
||
publishedAt: undefined,
|
||
averageRating: undefined,
|
||
reviewsCount: undefined,
|
||
})
|
||
|
||
const backgroundFiles = ref<UploadFileInfo[]>([])
|
||
const savingBackground = ref(false)
|
||
|
||
const intro = ref<EditableIntroduction>({ text: '', audio: '' })
|
||
const introMode = ref('show-editor')
|
||
const conclusion = ref<Conclusion>({ text: '', video: '' })
|
||
const conclusionFiles = ref<UploadFileInfo[]>([])
|
||
const savingConclusion = ref(false)
|
||
const conclusionVideoURL = computed(() => applicationFileURL(conclusion.value.video))
|
||
|
||
function resetConclusion() {
|
||
conclusion.value = { text: '', video: '', ...scenario.value.story?.conclusion }
|
||
conclusionFiles.value = []
|
||
}
|
||
|
||
function removeConclusionVideo() {
|
||
conclusion.value.video = ''
|
||
conclusionFiles.value = []
|
||
}
|
||
|
||
async function updateConclusion(remove = false) {
|
||
savingConclusion.value = true
|
||
try {
|
||
let video = toRawFilename(conclusion.value.video)
|
||
if (!remove && conclusionFiles.value.length) {
|
||
const file = conclusionFiles.value[0].file
|
||
if (!file) return
|
||
const uploaded = await uploadFile(
|
||
file,
|
||
'scenarios_' + scenario.value.ID + '_conclusion_' + file.name,
|
||
)
|
||
if (!uploaded.filename) return
|
||
if (uploaded.fileType !== 'video') {
|
||
message.error('Закрывашка должна содержать видео MP4 или WebM')
|
||
return
|
||
}
|
||
video = uploaded.filename
|
||
}
|
||
const saved = remove ? { text: '', video: '' } : { text: conclusion.value.text, video }
|
||
const res = await client.UpdateScenarioConclusion({ ID: scenario.value.ID, conclusion: saved })
|
||
if (res.error) {
|
||
message.error(res.error!)
|
||
return
|
||
}
|
||
scenario.value.story!.conclusion = saved
|
||
resetConclusion()
|
||
message.success(remove ? 'Закрывашка удалена' : 'Закрывашка сохранена')
|
||
} catch {
|
||
message.error('Не удалось сохранить закрывашку')
|
||
} finally {
|
||
savingConclusion.value = false
|
||
}
|
||
}
|
||
|
||
// Точки сценария со служебными полями редактора (oldCode, mode, fileList)
|
||
const editorPlaces = computed<EditablePlace[]>(() => {
|
||
return (scenario.value.story?.places || []) as EditablePlace[]
|
||
})
|
||
|
||
const scrollContainer = ref<HTMLElement | null>(null)
|
||
const placeCards = new Map<EditablePlace, HTMLElement>()
|
||
|
||
function navigateToPlace(code: string | undefined) {
|
||
const place = code
|
||
? editorPlaces.value.find((place) => (place.oldCode ?? place.code) === code)
|
||
: undefined
|
||
const card = place && placeCards.get(place)
|
||
const container = scrollContainer.value
|
||
if (!card || !container) {
|
||
message.error(code ? `Точка «${code}» не найдена` : 'У действия не указан код точки')
|
||
return
|
||
}
|
||
card.focus({ preventScroll: true })
|
||
container.scrollTo({
|
||
top: container.scrollTop + card.getBoundingClientRect().top - container.getBoundingClientRect().top - 10,
|
||
behavior: 'smooth',
|
||
})
|
||
}
|
||
|
||
// Вопросы дела со служебными полями редактора (oldCode, mode)
|
||
const editorQuestions = computed<EditableQuestion[]>(() => {
|
||
return (scenario.value.story?.questions || []) as EditableQuestion[]
|
||
})
|
||
|
||
async function getScenario(id: number) {
|
||
const res = await client.GetFullScenario({ ID: id })
|
||
if (res.error != '') {
|
||
message.error(res.error!)
|
||
return
|
||
}
|
||
scenario.value = res.scenario!
|
||
resetConclusion()
|
||
scenario.value.story?.places?.forEach((item) => {
|
||
const editable = item as EditablePlace
|
||
editable.oldCode = editable.code
|
||
editable.mode = 'show-editor'
|
||
})
|
||
scenario.value.story?.questions?.forEach((item) => {
|
||
const editable = item as EditableQuestion
|
||
editable.oldCode = editable.code
|
||
editable.mode = 'show-editor'
|
||
})
|
||
intro.value = scenario.value.story?.introduction || { text: '', audio: '' }
|
||
introMode.value = 'show-editor'
|
||
}
|
||
getScenario(Number(scenarioId))
|
||
|
||
async function deleteScenario(id: number) {
|
||
const res = await client.DeleteScenario({ ID: id })
|
||
if (res.error != '') {
|
||
message.error(res.error!)
|
||
return
|
||
}
|
||
router.push('/scenarios')
|
||
}
|
||
|
||
async function publicScenario(id: number) {
|
||
const res = await client.PublicScenario({ ID: id })
|
||
if (res.error != '') {
|
||
message.error(res.error!)
|
||
return
|
||
}
|
||
await getScenario(Number(scenarioId))
|
||
statusScenarioName.value = ''
|
||
showSettingsModal.value = false
|
||
}
|
||
|
||
async function addGame(name: string, id: number) {
|
||
const now = new Date().toISOString()
|
||
const resAddGame = await client.AddGame({
|
||
name: ('Тестовая игра: ' + name).trim(),
|
||
description: 'Тестовая игра',
|
||
location: '',
|
||
format: 'online',
|
||
startAt: now,
|
||
scenarioID: id,
|
||
})
|
||
if (resAddGame.error != '') {
|
||
message.error(resAddGame.error!)
|
||
return
|
||
}
|
||
const resAddTeam = await client.AddTeam({
|
||
name: 'Тестовая команда',
|
||
captainEmail: '',
|
||
gameID: resAddGame.ID!,
|
||
})
|
||
if (resAddTeam.error != '') {
|
||
message.error(resAddTeam.error!)
|
||
return
|
||
}
|
||
router.push('/team-story/' + resAddTeam.ID! + '?password=' + resAddTeam.password!)
|
||
}
|
||
|
||
async function draftScenario(id: number) {
|
||
const res = await client.DraftScenario({ ID: id })
|
||
if (res.error != '') {
|
||
message.error(res.error!)
|
||
return
|
||
}
|
||
await getScenario(Number(scenarioId))
|
||
statusScenarioName.value = ''
|
||
showSettingsModal.value = false
|
||
}
|
||
|
||
async function downloadArchive() {
|
||
isDownloadingArchive.value = true
|
||
try {
|
||
const { blob, filename } = await archiveClient.downloadScenarioArchive(scenario.value.ID!)
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = filename
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
link.remove()
|
||
// Отзываем URL после того как браузер начал скачивание
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||
message.success('Архив сценария скачан')
|
||
} catch (err) {
|
||
message.error(err instanceof Error ? err.message : 'Не удалось скачать архив')
|
||
} finally {
|
||
isDownloadingArchive.value = false
|
||
}
|
||
}
|
||
|
||
type UploadFileResult = {
|
||
filename: string
|
||
fileType: string
|
||
}
|
||
|
||
async function uploadFile(file: File | null, filename: string): Promise<UploadFileResult> {
|
||
if (!file) {
|
||
return { filename: '', fileType: '' }
|
||
}
|
||
|
||
// Читаем как ArrayBuffer
|
||
const arrayBuffer = await file.arrayBuffer()
|
||
const bytes = new Uint8Array(arrayBuffer)
|
||
|
||
const resUploadFile = await client.UploadFile({
|
||
filename: filename,
|
||
data: uint8ToBase64(bytes),
|
||
})
|
||
if (resUploadFile.error != '') {
|
||
message.error(resUploadFile.error!)
|
||
return { filename: '', fileType: '' }
|
||
}
|
||
|
||
// file_type возвращает сервер по содержимому файла (image | pdf | audio | video) —
|
||
// редактор сохраняет его на улике.
|
||
return {
|
||
filename: resUploadFile.filename || '',
|
||
fileType: resUploadFile.file_type || '',
|
||
}
|
||
}
|
||
|
||
async function updateScenario() {
|
||
let resUploadFilename = ''
|
||
if (fileList.value && fileList.value.length > 0) {
|
||
const file = fileList.value[0].file || null
|
||
if (file == null) {
|
||
return
|
||
}
|
||
const uploaded = await uploadFile(file, 'scenarios_' + scenario.value.ID + '_' + file.name)
|
||
if (uploaded.filename === '') {
|
||
return
|
||
}
|
||
resUploadFilename = uploaded.filename
|
||
}
|
||
|
||
const res = await client.UpdateScenario({
|
||
ID: scenario.value.ID,
|
||
name: scenario.value.name,
|
||
description: scenario.value.description,
|
||
image: resUploadFilename,
|
||
})
|
||
if (res.error != '') {
|
||
message.error(res.error!)
|
||
return
|
||
}
|
||
await getScenario(Number(scenarioId))
|
||
showSettingsModal.value = false
|
||
}
|
||
|
||
function uint8ToBase64(uint8Array: Uint8Array) {
|
||
// Преобразуем Uint8Array в двоичную строку
|
||
const binaryString = fromCharCodeSafe(uint8Array)
|
||
// Кодируем в Base64
|
||
return btoa(binaryString)
|
||
}
|
||
|
||
function fromCharCodeSafe(arr: Uint8Array) {
|
||
let result = ''
|
||
for (let i = 0; i < arr.length; i += 50000) {
|
||
const chunk = arr.slice(i, i + 50000)
|
||
result += String.fromCharCode(...chunk)
|
||
}
|
||
return result
|
||
}
|
||
|
||
const code = ref('')
|
||
const name = ref('')
|
||
const text = ref('')
|
||
const mode = ref('mini-add')
|
||
|
||
async function addPlace() {
|
||
const res = await client.AddScenarioPlace({
|
||
ID: scenario.value.ID,
|
||
place: {
|
||
code: code.value,
|
||
name: name.value,
|
||
text: text.value,
|
||
image: undefined,
|
||
hidden: undefined,
|
||
applications: undefined,
|
||
doors: undefined,
|
||
keys: undefined,
|
||
},
|
||
})
|
||
if (res.error != '') {
|
||
message.error(res.error!)
|
||
return
|
||
}
|
||
await getScenario(Number(scenarioId))
|
||
|
||
code.value = ''
|
||
name.value = ''
|
||
text.value = ''
|
||
mode.value = 'add'
|
||
}
|
||
|
||
async function cancelAddPlace() {
|
||
code.value = ''
|
||
name.value = ''
|
||
text.value = ''
|
||
mode.value = 'mini-add'
|
||
}
|
||
|
||
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
|
||
})
|
||
if (place == undefined) {
|
||
const res = await client.AddScenarioPlace({
|
||
ID: scenario.ID,
|
||
place: {
|
||
code: door.code,
|
||
name: door.name,
|
||
text: undefined,
|
||
image: undefined,
|
||
hidden: true,
|
||
applications: undefined,
|
||
doors: undefined,
|
||
keys: undefined,
|
||
},
|
||
})
|
||
if (res.error != '') {
|
||
message.error(res.error!)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function cancelUpdatePlace(place: EditablePlace) {
|
||
await getScenario(Number(scenarioId))
|
||
|
||
place.mode = 'show-editor'
|
||
}
|
||
|
||
async function updatePlace(place: EditablePlace) {
|
||
if (place.fileList && place.fileList.length > 0) {
|
||
const file = place.fileList[0].file || null
|
||
if (file == null) {
|
||
return
|
||
}
|
||
const uploaded = await uploadFile(
|
||
file,
|
||
'scenarios_' + scenario.value.ID + '_place_' + place.code + '_' + file.name,
|
||
)
|
||
if (uploaded.filename === '') {
|
||
return
|
||
}
|
||
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)
|
||
const res = await client.UpdateScenarioPlace({
|
||
ID: scenario.value.ID,
|
||
code: place.oldCode,
|
||
place: place,
|
||
})
|
||
if (res.error != '') {
|
||
message.error(res.error!)
|
||
return
|
||
}
|
||
await getScenario(Number(scenarioId))
|
||
|
||
place.mode = 'show-editor'
|
||
}
|
||
|
||
async function deletePlace(place: EditablePlace) {
|
||
const res = await client.DeleteScenarioPlace({
|
||
ID: scenario.value.ID,
|
||
code: place.oldCode,
|
||
})
|
||
if (res.error != '') {
|
||
message.error(res.error!)
|
||
return
|
||
}
|
||
await getScenario(Number(scenarioId))
|
||
}
|
||
|
||
const newQuestionCode = ref('')
|
||
const newQuestionText = ref('')
|
||
const newQuestionAnswer = ref('')
|
||
const questionMode = ref('mini-add')
|
||
|
||
async function addQuestion() {
|
||
const res = await client.AddScenarioQuestion({
|
||
ID: scenario.value.ID,
|
||
question: {
|
||
code: newQuestionCode.value,
|
||
text: newQuestionText.value,
|
||
answer: newQuestionAnswer.value,
|
||
},
|
||
})
|
||
if (res.error != '') {
|
||
message.error(res.error!)
|
||
return
|
||
}
|
||
await getScenario(Number(scenarioId))
|
||
|
||
newQuestionCode.value = ''
|
||
newQuestionText.value = ''
|
||
newQuestionAnswer.value = ''
|
||
questionMode.value = 'add'
|
||
}
|
||
|
||
async function cancelAddQuestion() {
|
||
newQuestionCode.value = ''
|
||
newQuestionText.value = ''
|
||
newQuestionAnswer.value = ''
|
||
questionMode.value = 'mini-add'
|
||
}
|
||
|
||
async function updateQuestion(question: EditableQuestion) {
|
||
const res = await client.UpdateScenarioQuestion({
|
||
ID: scenario.value.ID,
|
||
code: question.oldCode,
|
||
question: {
|
||
code: question.code,
|
||
text: question.text,
|
||
answer: question.answer,
|
||
},
|
||
})
|
||
if (res.error != '') {
|
||
message.error(res.error!)
|
||
return
|
||
}
|
||
await getScenario(Number(scenarioId))
|
||
|
||
question.mode = 'show-editor'
|
||
}
|
||
|
||
async function cancelUpdateQuestion(question: EditableQuestion) {
|
||
await getScenario(Number(scenarioId))
|
||
|
||
question.mode = 'show-editor'
|
||
}
|
||
|
||
async function deleteQuestion(question: EditableQuestion) {
|
||
const res = await client.DeleteScenarioQuestion({
|
||
ID: scenario.value.ID,
|
||
code: question.oldCode,
|
||
})
|
||
if (res.error != '') {
|
||
message.error(res.error!)
|
||
return
|
||
}
|
||
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))
|
||
.filename
|
||
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 updateBackground(remove = false) {
|
||
savingBackground.value = true
|
||
try {
|
||
let background = ''
|
||
if (!remove) {
|
||
const file = backgroundFiles.value[0]?.file
|
||
if (!file) return
|
||
const uploaded = await uploadFile(
|
||
file,
|
||
'scenarios_' + scenario.value.ID + '_background_' + file.name,
|
||
)
|
||
if (!uploaded.filename) return
|
||
if (uploaded.fileType !== 'image') {
|
||
message.error('Фон дела должен быть изображением')
|
||
return
|
||
}
|
||
background = uploaded.filename
|
||
}
|
||
const res = await client.UpdateScenarioBackground({ ID: scenario.value.ID, background })
|
||
if (res.error) {
|
||
message.error(res.error)
|
||
return
|
||
}
|
||
await getScenario(Number(scenarioId))
|
||
backgroundFiles.value = []
|
||
} catch {
|
||
message.error('Не удалось сохранить фон дела')
|
||
} finally {
|
||
savingBackground.value = false
|
||
}
|
||
}
|
||
|
||
async function cancelUpdateIntro() {
|
||
await getScenario(Number(scenarioId))
|
||
}
|
||
|
||
function getRandomComplimentForNastya() {
|
||
const compliments = [
|
||
{ text: 'Настя, ты просто ослепительна сегодня!', nameForm: 'Настя' },
|
||
{ text: 'У Насти невероятный вкус!', nameForm: 'Насти' },
|
||
{ text: 'Настенька, твоя улыбка делает мир ярче.', nameForm: 'Настенька' },
|
||
{ text: 'С Настей всегда интересно общаться.', nameForm: 'Настей' },
|
||
{ text: 'Настя, ты — источник вдохновения для всех вокруг.', nameForm: 'Настя' },
|
||
{ text: 'Мне нравится, как Настя смотрит на мир.', nameForm: 'Настя' },
|
||
{ text: 'Настюха, ты супер!', nameForm: 'Настюха' },
|
||
{ text: 'Талант Насти поражает!', nameForm: 'Насти' },
|
||
{ text: 'Настя, твоя энергия заряжает батарейки!', nameForm: 'Настя' },
|
||
{ text: 'С Настей хочется проводить каждую минуту.', nameForm: 'Настей' },
|
||
{ text: 'Настенька, ты — самое красивое, что случилось в этом мире.', nameForm: 'Настенька' },
|
||
{ text: 'У Насти золотое сердце.', nameForm: 'Насти' },
|
||
]
|
||
|
||
const randomIndex = Math.floor(Math.random() * compliments.length)
|
||
return compliments[randomIndex].text
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div ref="scrollContainer" class="center-block-custom">
|
||
<div class="width700">
|
||
<div class="places-container">
|
||
<n-alert
|
||
v-if="scenario.status == 'public'"
|
||
title="Сценарий опубликован"
|
||
type="warning"
|
||
style="margin-bottom: 20px"
|
||
>
|
||
Не рекомендуется редактировать сценарий когда он опубликован.
|
||
</n-alert>
|
||
|
||
<n-alert
|
||
v-if="authStore.user?.ID == 2"
|
||
:title="getRandomComplimentForNastya()"
|
||
type="info"
|
||
style="margin-bottom: 20px"
|
||
></n-alert>
|
||
|
||
<div class="settings-block">
|
||
Точек: {{ scenario.story?.places?.length }}, Вопросов:
|
||
{{ scenario.story?.questions?.length }}
|
||
<n-button quaternary @click="showSettingsModal = true" class="settings-button">
|
||
<Icon class="settings-icon">
|
||
<Settings />
|
||
</Icon>
|
||
Настройки
|
||
</n-button>
|
||
</div>
|
||
|
||
<n-card title="Фон дела">
|
||
<n-space vertical>
|
||
<img
|
||
v-if="scenario.story?.background"
|
||
:src="scenario.story.background"
|
||
alt="Фон дела"
|
||
class="background-preview"
|
||
/>
|
||
<n-upload
|
||
v-model:file-list="backgroundFiles"
|
||
accept=".jpg,.jpeg,.png,.gif,.webp"
|
||
:max="1"
|
||
:default-upload="false"
|
||
:disabled="savingBackground"
|
||
>
|
||
<n-button :disabled="savingBackground">Выбрать фон</n-button>
|
||
</n-upload>
|
||
<n-flex>
|
||
<n-button
|
||
:loading="savingBackground"
|
||
:disabled="!backgroundFiles.length || savingBackground"
|
||
@click="updateBackground()"
|
||
>Сохранить фон</n-button
|
||
>
|
||
<n-button
|
||
v-if="scenario.story?.background"
|
||
:disabled="savingBackground"
|
||
@click="updateBackground(true)"
|
||
>Удалить фон</n-button
|
||
>
|
||
</n-flex>
|
||
</n-space>
|
||
</n-card>
|
||
|
||
<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>
|
||
|
||
<n-card title="Закрывашка сценария">
|
||
<n-space vertical>
|
||
<n-text depth="3">Разгадка дела для автора. Игрокам не показывается.</n-text>
|
||
<n-input
|
||
v-model:value="conclusion.text"
|
||
type="textarea"
|
||
:autosize="{ minRows: 4, maxRows: 12 }"
|
||
placeholder="Текст закрывашки"
|
||
aria-label="Текст закрывашки"
|
||
:disabled="savingConclusion"
|
||
/>
|
||
<video
|
||
v-if="conclusionVideoURL"
|
||
:src="conclusionVideoURL"
|
||
controls
|
||
preload="none"
|
||
class="conclusion-video"
|
||
aria-label="Видео закрывашки"
|
||
></video>
|
||
<n-button
|
||
v-if="conclusion.video"
|
||
:disabled="savingConclusion"
|
||
@click="removeConclusionVideo()"
|
||
>Убрать видео</n-button
|
||
>
|
||
<n-upload
|
||
v-model:file-list="conclusionFiles"
|
||
accept=".mp4,.webm"
|
||
:max="1"
|
||
:default-upload="false"
|
||
:disabled="savingConclusion"
|
||
>
|
||
<n-button :disabled="savingConclusion">Выбрать видео MP4/WebM</n-button>
|
||
</n-upload>
|
||
<n-flex justify="end">
|
||
<n-button
|
||
v-if="scenario.story?.conclusion?.text || scenario.story?.conclusion?.video"
|
||
:disabled="savingConclusion"
|
||
@click="updateConclusion(true)"
|
||
>Удалить закрывашку</n-button
|
||
>
|
||
<n-button :disabled="savingConclusion" @click="resetConclusion()">Отмена</n-button>
|
||
<n-button
|
||
:loading="savingConclusion"
|
||
:disabled="savingConclusion || !scenario.story"
|
||
@click="updateConclusion()"
|
||
>Сохранить закрывашку</n-button
|
||
>
|
||
</n-flex>
|
||
</n-space>
|
||
</n-card>
|
||
|
||
<div class="questions-header">Вопросы дела</div>
|
||
|
||
<QuestionBlock
|
||
v-model:mode="questionMode"
|
||
v-model:newQuestionCode="newQuestionCode"
|
||
v-model:newQuestionText="newQuestionText"
|
||
v-model:newQuestionAnswer="newQuestionAnswer"
|
||
>
|
||
<n-flex justify="end">
|
||
<n-button @click="cancelAddQuestion()">Отмена</n-button>
|
||
<n-button @click="addQuestion()" :disabled="!newQuestionCode">Добавить вопрос</n-button>
|
||
</n-flex>
|
||
</QuestionBlock>
|
||
|
||
<QuestionBlock
|
||
:question="question"
|
||
v-model:mode="question.mode"
|
||
v-for="question in editorQuestions"
|
||
v-bind:key="question.oldCode ?? question.code ?? ''"
|
||
>
|
||
<n-flex justify="end">
|
||
<n-button @click="deleteQuestion(question)" type="error" ghost>Удалить</n-button>
|
||
<n-button @click="cancelUpdateQuestion(question)">Отмена</n-button>
|
||
<n-button @click="updateQuestion(question)" :disabled="question.code == ''"
|
||
>Сохранить</n-button
|
||
>
|
||
</n-flex>
|
||
</QuestionBlock>
|
||
|
||
<PlaceBlock
|
||
v-model:mode="mode"
|
||
v-model:newPlaceCode="code"
|
||
v-model:newPlaceName="name"
|
||
v-model:newPlaceText="text"
|
||
>
|
||
<n-flex justify="end">
|
||
<n-button @click="cancelAddPlace()">Отмена</n-button>
|
||
<n-button @click="addPlace()" :disabled="!code">Добавить точку</n-button>
|
||
</n-flex>
|
||
</PlaceBlock>
|
||
|
||
<div
|
||
v-for="place in editorPlaces"
|
||
:key="place.oldCode ?? place.code ?? ''"
|
||
:ref="(el) => el ? placeCards.set(place, el as HTMLElement) : placeCards.delete(place)"
|
||
class="place-card"
|
||
tabindex="-1"
|
||
role="group"
|
||
:aria-label="`${place.code}: ${place.name}`"
|
||
>
|
||
<PlaceBlock :place="place" v-model:mode="place.mode" @navigate="navigateToPlace">
|
||
<n-flex justify="end">
|
||
<n-button @click="deletePlace(place)" type="error" ghost>Удалить</n-button>
|
||
<n-button @click="cancelUpdatePlace(place)">Отмена</n-button>
|
||
<n-button @click="updatePlace(place)" :disabled="place.code == ''">Сохранить</n-button>
|
||
</n-flex>
|
||
</PlaceBlock>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Окно настроек -->
|
||
<n-modal v-model:show="showSettingsModal">
|
||
<n-card
|
||
style="width: 600px"
|
||
title="Настройки сценария"
|
||
:bordered="false"
|
||
size="huge"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
>
|
||
<template #header-extra>
|
||
<n-button @click="showSettingsModal = false"> x </n-button>
|
||
</template>
|
||
<n-space vertical>
|
||
<p>Название</p>
|
||
<n-input
|
||
v-model:value="scenario.name"
|
||
type="text"
|
||
placeholder='Дело №0 "Следствие ведут овечки"'
|
||
/>
|
||
|
||
<p class="settings-header">Описание</p>
|
||
<n-input
|
||
v-model:value="scenario.description"
|
||
type="textarea"
|
||
placeholder="Убийство пастуха..."
|
||
/>
|
||
|
||
<p class="settings-header">Картинка</p>
|
||
<n-input
|
||
v-model:value="scenario.image"
|
||
type="text"
|
||
placeholder="default_scenario.png"
|
||
disabled
|
||
/>
|
||
<n-upload directory-dnd :max="1" v-model:file-list="fileList">
|
||
<n-upload-dragger>
|
||
<n-text style="font-size: 16px">
|
||
Щелкните или перетащите файл в эту область для загрузки
|
||
</n-text>
|
||
</n-upload-dragger>
|
||
</n-upload>
|
||
|
||
<n-button @click="updateScenario()" ghost> Сохранить изменения </n-button>
|
||
|
||
<hr class="settings-hr" />
|
||
<p class="settings-header">Архив сценария</p>
|
||
<p class="settings-hint">
|
||
Скачайте сценарий ZIP-архивом (scenario.json + изображения) и импортируйте его в другом
|
||
аккаунте.
|
||
</p>
|
||
<n-button
|
||
:loading="isDownloadingArchive"
|
||
:disabled="!scenario.ID"
|
||
ghost
|
||
@click="downloadArchive()"
|
||
>
|
||
Скачать архив (.zip)
|
||
</n-button>
|
||
|
||
<hr class="settings-hr" />
|
||
<p>Создать тестовую игру</p>
|
||
<n-button @click="addGame(scenario.name || '', scenario.ID!)" ghost>
|
||
Создать игру
|
||
</n-button>
|
||
|
||
<hr class="settings-hr" />
|
||
<p>Публикация</p>
|
||
<p>
|
||
<n-tag
|
||
v-if="scenario.status == 'public'"
|
||
:bordered="false"
|
||
type="success"
|
||
class="status-block"
|
||
>
|
||
Опубликовано
|
||
</n-tag>
|
||
<n-tag
|
||
v-if="scenario.status == 'draft'"
|
||
:bordered="false"
|
||
type="warning"
|
||
class="status-block"
|
||
>
|
||
В разработке
|
||
</n-tag>
|
||
</p>
|
||
<p>
|
||
Введите название сценария <n-text class="name-text" strong>{{ scenario.name }}</n-text>
|
||
</p>
|
||
<n-input
|
||
v-model:value="statusScenarioName"
|
||
type="text"
|
||
placeholder='Дело №0 "Следствие ведут овечки"'
|
||
/>
|
||
<n-button
|
||
v-if="scenario.status != 'public'"
|
||
@click="publicScenario(scenario.ID!)"
|
||
ghost
|
||
:disabled="statusScenarioName != scenario.name"
|
||
>
|
||
Опубликовать сценарий
|
||
</n-button>
|
||
<n-button
|
||
v-if="scenario.status != 'draft'"
|
||
@click="draftScenario(scenario.ID!)"
|
||
ghost
|
||
:disabled="statusScenarioName != scenario.name"
|
||
>
|
||
Снять сценарий с публикации
|
||
</n-button>
|
||
|
||
<hr class="settings-hr" />
|
||
|
||
<p>
|
||
Введите название сценария
|
||
<n-text class="name-text" strong>{{ scenario.name }}</n-text> чтобы удалить его
|
||
</p>
|
||
<n-input
|
||
v-model:value="deletedScenarioName"
|
||
type="text"
|
||
placeholder='Дело №0 "Следствие ведут овечки"'
|
||
/>
|
||
<n-button
|
||
@click="deleteScenario(scenario.ID!)"
|
||
type="error"
|
||
ghost
|
||
:disabled="deletedScenarioName != scenario.name"
|
||
>
|
||
Удалить сценарий
|
||
</n-button>
|
||
</n-space>
|
||
<template #footer> </template>
|
||
</n-card>
|
||
</n-modal>
|
||
|
||
<HeaderMenu active="scenarios" />
|
||
</template>
|
||
|
||
<style scoped>
|
||
.place-card {
|
||
--place-block-margin: 0;
|
||
|
||
border-radius: 10px;
|
||
margin: 10px 0;
|
||
}
|
||
|
||
.place-card:focus {
|
||
outline: 2px solid #63e2b7;
|
||
}
|
||
|
||
.conclusion-video {
|
||
width: 100%;
|
||
}
|
||
|
||
.background-preview {
|
||
max-height: 200px;
|
||
max-width: 100%;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.settings-block {
|
||
height: 40px;
|
||
|
||
/* background-color: brown; */
|
||
}
|
||
|
||
.settings-button {
|
||
float: right;
|
||
}
|
||
|
||
.settings-icon {
|
||
width: 20px;
|
||
}
|
||
|
||
.name-text {
|
||
background-color: rgb(255 255 255 / 10%);
|
||
border-radius: 3px;
|
||
padding: 3px;
|
||
}
|
||
|
||
.settings-header {
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.settings-hint {
|
||
color: #999;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.settings-hr {
|
||
margin: 20px 0;
|
||
}
|
||
|
||
.questions-header {
|
||
color: #aaa;
|
||
font-size: 18px;
|
||
margin: 30px 0 5px;
|
||
}
|
||
|
||
.places-container {
|
||
display: flex;
|
||
flex-flow: column nowrap;
|
||
}
|
||
</style>
|