This commit is contained in:
2026-09-01 01:47:14 +07:00
parent cfda264b02
commit 051c7eb79b
11 changed files with 3021 additions and 2444 deletions
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
+2437
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-D7Q2J__k.js"></script> <script type="module" crossorigin src="/assets/index-DiCkfjcL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BsEbL24Y.css"> <link rel="stylesheet" crossorigin href="/assets/index-BYrJ5ELh.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+154 -2
View File
@@ -3,13 +3,13 @@
import { Flag, Pause, Play, Reset, Settings } from '@vicons/carbon' import { Flag, Pause, Play, Reset, Settings } from '@vicons/carbon'
import { Icon } from '@vicons/utils' import { Icon } from '@vicons/utils'
import { NButton, NTag } from 'naive-ui' import { NButton, NTag } from 'naive-ui'
import { NCard, NFlex, NInput, NModal, NQrCode, NSpace, NText } from 'naive-ui' import { NCard, NFlex, NInput, NInputNumber, NModal, NQrCode, NSpace, NText } from 'naive-ui'
import { useMessage } from 'naive-ui'; import { useMessage } from 'naive-ui';
import { ref } from 'vue'; import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { getAuthClient } from '@/api/auth_client'; import { getAuthClient } from '@/api/auth_client';
import type { Application, Game, Team } from '@/api/generated/crabs/evening_detective_server'; import type { Application, Game, Question, Team, TeamAnswers } from '@/api/generated/crabs/evening_detective_server';
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';
@@ -264,6 +264,68 @@ async function finishGame() {
await getGame(game.value.id!) await getGame(game.value.id!)
} }
// ===== Ответы команд и оценка организатором =====
const showAnswersModal = ref(false)
const answersQuestions = ref<Question[]>([])
const answersTeams = ref<TeamAnswers[]>([])
// Баллы по ключу "teamId:questionCode" (undefined — балл не выставлен)
const scores = ref<Record<string, number | undefined>>({})
function scoreKey(teamId: number | undefined, questionCode: string | undefined): string {
return (teamId ?? '') + ':' + (questionCode ?? '')
}
function teamAnswer(team: TeamAnswers, questionCode: string | undefined): string {
return (team.answers || []).find((item) => item.questionCode == questionCode)?.answer || ''
}
function onScoreUpdate(key: string, value: number | null) {
scores.value[key] = value === null ? undefined : value
}
async function loadGameAnswers() {
const res = await client.GetGameAnswers({ gameId: Number(gameId) })
if (res.error != '') {
message.error(res.error!)
return
}
answersQuestions.value = res.questions || []
answersTeams.value = res.teams || []
const next: Record<string, number | undefined> = {}
for (const team of answersTeams.value) {
for (const answer of team.answers || []) {
next[scoreKey(team.team?.id, answer.questionCode)] = answer.score
}
}
scores.value = next
}
async function openAnswersModal() {
showAnswersModal.value = true
await loadGameAnswers()
}
// Сохраняет баллы всех команд за все вопросы; пустой балл сбрасывает его.
async function saveAllScores() {
for (const question of answersQuestions.value) {
for (const team of answersTeams.value) {
const score = scores.value[scoreKey(team.team?.id, question.code)]
const res = await client.SetTeamAnswerScore({
id: team.team?.id,
questionCode: question.code,
score: score === undefined || score === null ? undefined : score,
})
if (res.error != '') {
message.error(res.error!)
return
}
}
}
message.success('Баллы сохранены')
await loadGameAnswers()
}
function parseDateWithoutTimezone(dateStr: string): Date { function parseDateWithoutTimezone(dateStr: string): Date {
// Разбираем ISO строку: "2024-01-01T12:30:00" // Разбираем ISO строку: "2024-01-01T12:30:00"
const [datePart, timePart] = dateStr.split('T'); const [datePart, timePart] = dateStr.split('T');
@@ -336,6 +398,9 @@ useSimplePolling(() => {
<n-button quaternary class="settings-button" @click="openQR(url, 'Админка')"> <n-button quaternary class="settings-button" @click="openQR(url, 'Админка')">
QR QR
</n-button> </n-button>
<n-button quaternary @click="openAnswersModal()" class="settings-button">
Ответы команд
</n-button>
<n-button quaternary @click="showSettingsModal = true" class="settings-button"> <n-button quaternary @click="showSettingsModal = true" class="settings-button">
<Icon class="settings-icon"> <Icon class="settings-icon">
<Settings /> <Settings />
@@ -560,6 +625,45 @@ useSimplePolling(() => {
</n-card> </n-card>
</n-modal> </n-modal>
<!-- Окно ответов команд и оценки -->
<n-modal v-model:show="showAnswersModal">
<n-card style="width: min(94vw, 900px)" title="Ответы команд" :bordered="false" size="huge" role="dialog"
aria-modal="true" :content-style="{ maxHeight: '70vh', overflow: 'auto' }">
<template #header-extra>
<n-button @click="showAnswersModal = false"> x </n-button>
</template>
<div v-if="answersQuestions.length == 0" class="answers-empty">
Вопросы дела не заданы или команды ещё не отвечали.
</div>
<div v-for="question in answersQuestions" :key="question.code" class="answers-question-block">
<p class="answers-question-title">
{{ question.code }}: {{ question.text }}
</p>
<p class="answers-question-answer">
Правильный ответ: {{ question.answer || '—' }}
</p>
<div v-for="team in answersTeams" :key="team.team?.id" class="answers-team-row">
<div class="answers-team-name">
{{ team.team?.name }}
</div>
<div class="answers-team-answer">
{{ teamAnswer(team, question.code) || '—' }}
</div>
<n-input-number :value="scores[scoreKey(team.team?.id, question.code)]"
@update:value="onScoreUpdate(scoreKey(team.team?.id, question.code), $event)" placeholder="Балл"
class="answers-score-input" />
</div>
</div>
<template #footer>
<n-flex justify="end">
<n-button @click="saveAllScores()" :disabled="answersQuestions.length == 0">
Сохранить баллы
</n-button>
</n-flex>
</template>
</n-card>
</n-modal>
<HeaderMenu active="games" /> <HeaderMenu active="games" />
</template> </template>
@@ -646,4 +750,52 @@ useSimplePolling(() => {
margin: 10px 0 5px; margin: 10px 0 5px;
text-decoration: underline; text-decoration: underline;
} }
.answers-empty {
color: #777;
}
.answers-question-block {
margin: 15px 0;
padding: 12px;
border-radius: 8px;
background-color: #222;
}
.answers-question-title {
margin: 0 0 4px;
font-size: 17px;
font-weight: 600;
}
.answers-question-answer {
margin: 0 0 10px;
color: #aaa;
}
.answers-team-row {
display: flex;
flex-flow: row wrap;
gap: 10px;
align-items: center;
margin: 8px 0;
padding: 8px 10px;
border-radius: 6px;
background-color: rgb(255 255 255 / 5%);
}
.answers-team-name {
width: 180px;
font-weight: 600;
}
.answers-team-answer {
flex: 1 1 200px;
color: #aaa;
white-space: pre-wrap;
}
.answers-score-input {
width: 110px;
}
</style> </style>
+1
View File
@@ -751,6 +751,7 @@ const bottomApplications = computed<Application[]>(() => {
.message-show-content { .message-show-content {
font-weight: 500; font-weight: 500;
white-space: pre-wrap; white-space: pre-wrap;
/* BFC: плавающий блок улик остаётся внутри текста и не наезжает /* BFC: плавающий блок улик остаётся внутри текста и не наезжает
на следующие секции (двери/действия) при коротком тексте */ на следующие секции (двери/действия) при коротком тексте */
display: flow-root; display: flow-root;
+156
View File
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { NInput, NInputGroup, NSpace, NText } from 'naive-ui'
import { type PropType } from 'vue'
import type { EditableQuestion } from '@/types/editor'
const props = defineProps({
newQuestionCode: {
type: String,
},
newQuestionText: {
type: String,
},
newQuestionAnswer: {
type: String,
},
question: {
type: Object as PropType<EditableQuestion>,
},
mode: {
type: String,
required: true,
},
})
const emit = defineEmits<{
'update:newQuestionCode': [value: string]
'update:newQuestionText': [value: string]
'update:newQuestionAnswer': [value: string]
'update:mode': [value: string]
}>()
const updateCode = (code: string) => {
emit('update:newQuestionCode', code)
}
const updateText = (text: string) => {
emit('update:newQuestionText', text)
}
const updateAnswer = (answer: string) => {
emit('update:newQuestionAnswer', answer)
}
const updateMode = (mode: string) => {
emit('update:mode', mode)
}
</script>
<template>
<div v-if="mode == 'mini-add'" class="place-block-hover show-editor-block" @click="updateMode('add')">
<p class="place-image-plus">+</p>
</div>
<div v-if="mode == 'add'" class="place-block">
<n-space vertical>
<n-input-group>
<n-input :style="{ width: '25%' }" :value="newQuestionCode" @update:value="updateCode" type="text"
placeholder="В-1" />
<n-input :style="{ width: '75%' }" :value="newQuestionText" @update:value="updateText" type="text"
placeholder="Кто украл пастуха?" />
</n-input-group>
<n-input :value="newQuestionAnswer" @update:value="updateAnswer" type="textarea" :autosize="{ minRows: 2 }"
placeholder="Правильный ответ (виден только автору и организаторам)" />
<slot></slot>
</n-space>
</div>
<div v-if="mode == 'show-editor'" class="place-block-hover show-editor-block" @click="updateMode('edit')">
<div class="message-header">
{{ props.question?.code }}: {{ props.question?.text }}
</div>
<hr class="hr" />
<div class="message-content">
<span v-if="props.question?.answer">Ответ: {{ props.question.answer }}</span>
<span v-else class="intro-empty">Ответ не задан</span>
</div>
</div>
<div v-if="mode == 'edit'" class="place-block">
<n-space vertical>
<n-input-group>
<n-input :style="{ width: '25%' }" v-model:value="props.question!.code" type="text" placeholder="В-1" />
<n-input :style="{ width: '75%' }" v-model:value="props.question!.text" type="text"
placeholder="Кто украл пастуха?" />
</n-input-group>
<p class="settings-header">Правильный ответ</p>
<n-input v-model:value="props.question!.answer" type="textarea" :autosize="{ minRows: 2 }"
placeholder="Правильный ответ (виден только автору и организаторам)" />
<n-text class="hint" depth="3">
Смена кода вопроса не переносит ответы команд, отправленные под старым кодом.
</n-text>
<div class="slot-block">
<slot></slot>
</div>
</n-space>
</div>
</template>
<style scoped>
.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;
}
.place-image-plus {
font-size: 30px;
display: flex;
justify-content: center;
align-items: center;
}
.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;
}
.hint {
margin-top: 5px;
}
.slot-block {
margin-top: 10px;
}
</style>
+108 -2
View File
@@ -12,9 +12,10 @@ import type { Scenario } from '@/api/generated/crabs/evening_detective_server'
import HeaderMenu from '@/components/HeaderMenu.vue' import HeaderMenu from '@/components/HeaderMenu.vue'
import IntroBlock from '@/components/IntroBlock.vue' import IntroBlock from '@/components/IntroBlock.vue'
import PlaceBlock from '@/components/PlaceBlock.vue' import PlaceBlock from '@/components/PlaceBlock.vue'
import QuestionBlock from '@/components/QuestionBlock.vue'
import router from '@/router' import router from '@/router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import type { EditableApplication, EditableIntroduction, EditablePlace } from '@/types/editor' import type { EditableApplication, EditableIntroduction, EditablePlace, EditableQuestion } from '@/types/editor'
const authStore = useAuthStore() const authStore = useAuthStore()
const client = getAuthClient() const client = getAuthClient()
@@ -50,6 +51,11 @@ const editorPlaces = computed<EditablePlace[]>(() => {
return (scenario.value.story?.places || []) as EditablePlace[] return (scenario.value.story?.places || []) as EditablePlace[]
}) })
// Вопросы дела со служебными полями редактора (oldCode, mode)
const editorQuestions = computed<EditableQuestion[]>(() => {
return (scenario.value.story?.questions || []) as EditableQuestion[]
})
async function getScenario(id: number) { async function getScenario(id: number) {
const res = await client.GetFullScenario({ id: id }) const res = await client.GetFullScenario({ id: id })
if (res.error != '') { if (res.error != '') {
@@ -62,6 +68,11 @@ async function getScenario(id: number) {
editable.oldCode = editable.code editable.oldCode = editable.code
editable.mode = 'show-editor' 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: '' } intro.value = scenario.value.story?.introduction || { text: '', audio: '' }
introMode.value = 'show-editor' introMode.value = 'show-editor'
} }
@@ -350,6 +361,76 @@ async function deletePlace(place: EditablePlace) {
await getScenario(Number(scenarioId)) 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 // Бэкенд хранит аудио вступления как имя файла, а отдаёт полным URL
// (домен + имя). При сохранении отправляем только имя файла. // (домен + имя). При сохранении отправляем только имя файла.
function toRawFilename(value: string | undefined): string { function toRawFilename(value: string | undefined): string {
@@ -427,7 +508,7 @@ function getRandomComplimentForNastya() {
style="margin-bottom: 20px;"></n-alert> style="margin-bottom: 20px;"></n-alert>
<div class="settings-block"> <div class="settings-block">
Точек: {{ scenario.story?.places?.length }} Точек: {{ scenario.story?.places?.length }}, Вопросов: {{ scenario.story?.questions?.length }}
<n-button quaternary @click="showSettingsModal = true" class="settings-button"> <n-button quaternary @click="showSettingsModal = true" class="settings-button">
<Icon class="settings-icon"> <Icon class="settings-icon">
<Settings /> <Settings />
@@ -443,6 +524,25 @@ function getRandomComplimentForNastya() {
</n-flex> </n-flex>
</IntroBlock> </IntroBlock>
<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" <PlaceBlock v-model:mode="mode" v-model:newPlaceCode="code" v-model:newPlaceName="name"
v-model:newPlaceText="text"> v-model:newPlaceText="text">
<n-flex justify="end"> <n-flex justify="end">
@@ -580,6 +680,12 @@ function getRandomComplimentForNastya() {
margin: 20px 0; margin: 20px 0;
} }
.questions-header {
margin: 30px 0 5px;
color: #aaa;
font-size: 18px;
}
.places-container { .places-container {
display: flex; display: flex;
flex-flow: column nowrap; flex-flow: column nowrap;
+153 -5
View File
@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton, NFlex, NQrCode } from 'naive-ui' import { NButton, NConfigProvider, NFlex, NInput, NQrCode } from 'naive-ui'
import { useMessage } from 'naive-ui'; import { useMessage } from 'naive-ui';
import { nextTick, ref } from 'vue'; import { computed, nextTick, ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { getAuthClient } from '@/api/auth_client'; import { getAuthClient } from '@/api/auth_client';
import type { Game, Introduction, Place } from '@/api/generated/crabs/evening_detective_server'; import type { Game, Introduction, Place, Question } from '@/api/generated/crabs/evening_detective_server';
import GameInputForm from '@/components/GameInputForm.vue' import GameInputForm from '@/components/GameInputForm.vue'
import HeaderMenu from '@/components/HeaderMenu.vue' import HeaderMenu from '@/components/HeaderMenu.vue'
import IntroBlock from '@/components/IntroBlock.vue' import IntroBlock from '@/components/IntroBlock.vue'
@@ -28,7 +28,8 @@ const scrollContainer = ref<HTMLDivElement | null>();
const story = ref<AdvancedStory>({ const story = ref<AdvancedStory>({
introduction: undefined, introduction: undefined,
places: [] places: [],
questions: []
}) })
const game = ref<Game | undefined>({ const game = ref<Game | undefined>({
@@ -82,7 +83,8 @@ async function getStory() {
place: item, place: item,
mode: oldStory?.places[i]?.mode || 'show' mode: oldStory?.places[i]?.mode || 'show'
} }
}) || [] }) || [],
questions: res.story!.questions || []
} }
const newCount = story.value.places.length const newCount = story.value.places.length
@@ -99,6 +101,7 @@ async function getStory() {
} }
} }
getStory() getStory()
getAnswers()
async function addAction(newCode: string) { async function addAction(newCode: string) {
const res = await client.AddTeamAction({ const res = await client.AddTeamAction({
@@ -120,6 +123,7 @@ async function addAction(newCode: string) {
type AdvancedStory = { type AdvancedStory = {
introduction?: Introduction; introduction?: Introduction;
places: AdvancedPlace[]; places: AdvancedPlace[];
questions: Question[];
} }
type AdvancedPlace = { type AdvancedPlace = {
@@ -127,6 +131,76 @@ type AdvancedPlace = {
mode: string; mode: string;
} }
// Ответы команды на вопросы дела: код вопроса -> текст ответа
const answers = ref<Record<string, string>>({})
async function getAnswers() {
const res = await client.GetTeamAnswers({
id: Number(teamId),
password: password as string
})
if (res.error != '') {
message.error(res.error!)
return
}
const next: Record<string, string> = {}
for (const answer of res.answers || []) {
next[answer.questionCode || ''] = answer.answer || ''
}
answers.value = next
}
async function submitAnswers() {
const res = await client.SubmitTeamAnswers({
id: Number(teamId),
password: password as string,
answers: (story.value.questions || []).map((question) => ({
questionCode: question.code,
answer: answers.value[question.code || ''] || ''
}))
})
if (res.error != '') {
message.error(res.error!)
return
}
message.success('Ответы сохранены')
await getAnswers()
}
// Отвечать можно только пока игра идёт (run/pause); после завершения
// ответы фиксируются для оценки организатором.
const canAnswer = computed(() => {
return game.value?.status == 'run' || game.value?.status == 'pause'
})
const hasQuestions = computed(() => {
return (story.value.questions?.length || 0) > 0
})
// Поля ввода и кнопка на светлом бумажном листе: тёмная тема naive-ui
// рисует их полупрозрачно-светлыми. Чтобы контролы читались на бумаге
// и вписывались в её стиль, поля делаем прозрачными с пунктирной
// «чернильной» рамкой (бумага просвечивает), шрифт наследуется
// печатной машинки от родителя.
const paperControlsOverrides = {
Input: {
border: '1px dashed black',
borderDisabled: '1px dashed black',
borderFocus: '1px solid black',
borderHover: '1px dashed black',
boxShadowFocus: '0 0 0 2px rgb(99 226 183 / 30%)',
caretColor: '#63e2b7',
color: 'transparent',
colorDisabled: 'transparent',
colorFocus: 'transparent',
fontSizeMedium: '18px',
placeholderColor: '#777',
placeholderColorDisabled: '#777',
textColor: 'black',
textColorDisabled: '#777',
},
}
async function deleteLast() { async function deleteLast() {
const res = await client.DeleteLastTeamAction({ const res = await client.DeleteLastTeamAction({
id: Number(teamId), id: Number(teamId),
@@ -156,6 +230,24 @@ useSimplePolling(() => {
:introduction="story.introduction" mode="show"> :introduction="story.introduction" mode="show">
</IntroBlock> </IntroBlock>
<MessagePaper v-if="hasQuestions && game?.status && game.status != 'draft'" class="questions-block">
<div class="questions-title font">Вопросы дела</div>
<hr class="questions-hr" />
<p class="questions-hint font">
Ответьте на вопросы дела. Ответы можно менять, пока игра идёт.
</p>
<n-config-provider :theme-overrides="paperControlsOverrides">
<div v-for="question in story.questions" :key="question.code" class="question-item font">
<div class="question-text">{{ question.code }}: {{ question.text }}</div>
<n-input v-model:value="answers[question.code || '']" type="textarea"
:autosize="{ minRows: 2, maxRows: 6 }" :disabled="!canAnswer" placeholder="Ваш ответ..." />
</div>
<n-flex v-if="canAnswer" justify="end" class="questions-actions">
<button type="button" class="paper-button" @click="submitAnswers()">Сохранить ответы</button>
</n-flex>
</n-config-provider>
</MessagePaper>
<PlaceBlock :place="advancedPlace.place" v-model:mode="advancedPlace.mode" <PlaceBlock :place="advancedPlace.place" v-model:mode="advancedPlace.mode"
v-for="advancedPlace in story.places" v-bind:key="advancedPlace.place.code" :addAction="addAction"> v-for="advancedPlace in story.places" v-bind:key="advancedPlace.place.code" :addAction="addAction">
</PlaceBlock> </PlaceBlock>
@@ -204,6 +296,62 @@ useSimplePolling(() => {
align-items: center; align-items: center;
} }
.questions-title {
text-align: center;
font-size: 26px;
letter-spacing: 3px;
color: var(--second-color);
}
.questions-hr {
margin: 10px 0;
border: dashed 1px;
border-color: black;
}
.questions-hint {
color: #777;
}
.question-item {
margin: 15px 0;
}
.question-text {
margin-bottom: 6px;
font-weight: 600;
}
.questions-actions {
margin-top: 10px;
}
/* Кнопка действия на бумажном листе — как «двери» в точках:
текстура ремня, бронзовый текст печатной машинки */
.paper-button {
display: inline-block;
margin-top: 5px;
padding: 5px 12px;
border: 0;
border-radius: 5px;
background-image: url('@/assets/images/belt.png');
background-size: cover;
color: #bfa07d;
font-family: 'font_old_typer', serif;
font-size: 18px;
font-weight: 600;
cursor: pointer;
}
.paper-button:hover {
color: #63e2b7;
}
.font {
font-family: 'font_old_typer', serif;
font-size: 18px;
}
.qr-main-block-text { .qr-main-block-text {
text-align: left; text-align: left;
margin: 10px 0; margin: 10px 0;
+9 -1
View File
@@ -1,6 +1,6 @@
import type { UploadFileInfo } from 'naive-ui' import type { UploadFileInfo } from 'naive-ui'
import type { Application, Introduction, Place } from '@/api/generated/crabs/evening_detective_server' import type { Application, Introduction, Place, Question } from '@/api/generated/crabs/evening_detective_server'
// Служебные поля интерфейса, которых нет в сгенерированной схеме API // Служебные поля интерфейса, которых нет в сгенерированной схеме API
// (типы из генератора — type-алиасы, их нельзя расширить через declare module). // (типы из генератора — type-алиасы, их нельзя расширить через declare module).
@@ -27,3 +27,11 @@ export type EditableApplication = Application & {
/** Выбранный файл улики для загрузки */ /** Выбранный файл улики для загрузки */
fileList?: UploadFileInfo[] fileList?: UploadFileInfo[]
} }
/** Вопрос дела со служебными полями редактора */
export type EditableQuestion = Question & {
/** Код вопроса до редактирования (нужен для UpdateScenarioQuestion) */
oldCode?: string
/** Режим отображения блока вопроса в редакторе (заполняется в getScenario) */
mode: string
}