generated from VLADIMIR/template_frontend
build
This commit is contained in:
+154
-2
@@ -3,13 +3,13 @@
|
||||
import { Flag, Pause, Play, Reset, Settings } from '@vicons/carbon'
|
||||
import { Icon } from '@vicons/utils'
|
||||
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 { ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
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 HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
import { useSimplePolling } from '@/composables/useSimplePolling';
|
||||
@@ -264,6 +264,68 @@ async function finishGame() {
|
||||
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 {
|
||||
// Разбираем ISO строку: "2024-01-01T12:30:00"
|
||||
const [datePart, timePart] = dateStr.split('T');
|
||||
@@ -336,6 +398,9 @@ useSimplePolling(() => {
|
||||
<n-button quaternary class="settings-button" @click="openQR(url, 'Админка')">
|
||||
QR
|
||||
</n-button>
|
||||
<n-button quaternary @click="openAnswersModal()" class="settings-button">
|
||||
Ответы команд
|
||||
</n-button>
|
||||
<n-button quaternary @click="showSettingsModal = true" class="settings-button">
|
||||
<Icon class="settings-icon">
|
||||
<Settings />
|
||||
@@ -560,6 +625,45 @@ useSimplePolling(() => {
|
||||
</n-card>
|
||||
</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" />
|
||||
</template>
|
||||
|
||||
@@ -646,4 +750,52 @@ useSimplePolling(() => {
|
||||
margin: 10px 0 5px;
|
||||
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>
|
||||
|
||||
@@ -751,6 +751,7 @@ const bottomApplications = computed<Application[]>(() => {
|
||||
.message-show-content {
|
||||
font-weight: 500;
|
||||
white-space: pre-wrap;
|
||||
|
||||
/* BFC: плавающий блок улик остаётся внутри текста и не наезжает
|
||||
на следующие секции (двери/действия) при коротком тексте */
|
||||
display: flow-root;
|
||||
|
||||
@@ -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>
|
||||
@@ -12,9 +12,10 @@ 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 QuestionBlock from '@/components/QuestionBlock.vue'
|
||||
import router from '@/router'
|
||||
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 client = getAuthClient()
|
||||
@@ -50,6 +51,11 @@ const editorPlaces = computed<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) {
|
||||
const res = await client.GetFullScenario({ id: id })
|
||||
if (res.error != '') {
|
||||
@@ -62,6 +68,11 @@ async function getScenario(id: number) {
|
||||
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'
|
||||
}
|
||||
@@ -350,6 +361,76 @@ async function deletePlace(place: EditablePlace) {
|
||||
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 {
|
||||
@@ -427,7 +508,7 @@ function getRandomComplimentForNastya() {
|
||||
style="margin-bottom: 20px;"></n-alert>
|
||||
|
||||
<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">
|
||||
<Icon class="settings-icon">
|
||||
<Settings />
|
||||
@@ -443,6 +524,25 @@ function getRandomComplimentForNastya() {
|
||||
</n-flex>
|
||||
</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"
|
||||
v-model:newPlaceText="text">
|
||||
<n-flex justify="end">
|
||||
@@ -580,6 +680,12 @@ function getRandomComplimentForNastya() {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.questions-header {
|
||||
margin: 30px 0 5px;
|
||||
color: #aaa;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.places-container {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<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 { nextTick, ref } from 'vue';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
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 HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
import IntroBlock from '@/components/IntroBlock.vue'
|
||||
@@ -28,7 +28,8 @@ const scrollContainer = ref<HTMLDivElement | null>();
|
||||
|
||||
const story = ref<AdvancedStory>({
|
||||
introduction: undefined,
|
||||
places: []
|
||||
places: [],
|
||||
questions: []
|
||||
})
|
||||
|
||||
const game = ref<Game | undefined>({
|
||||
@@ -82,7 +83,8 @@ async function getStory() {
|
||||
place: item,
|
||||
mode: oldStory?.places[i]?.mode || 'show'
|
||||
}
|
||||
}) || []
|
||||
}) || [],
|
||||
questions: res.story!.questions || []
|
||||
}
|
||||
|
||||
const newCount = story.value.places.length
|
||||
@@ -99,6 +101,7 @@ async function getStory() {
|
||||
}
|
||||
}
|
||||
getStory()
|
||||
getAnswers()
|
||||
|
||||
async function addAction(newCode: string) {
|
||||
const res = await client.AddTeamAction({
|
||||
@@ -120,6 +123,7 @@ async function addAction(newCode: string) {
|
||||
type AdvancedStory = {
|
||||
introduction?: Introduction;
|
||||
places: AdvancedPlace[];
|
||||
questions: Question[];
|
||||
}
|
||||
|
||||
type AdvancedPlace = {
|
||||
@@ -127,6 +131,76 @@ type AdvancedPlace = {
|
||||
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() {
|
||||
const res = await client.DeleteLastTeamAction({
|
||||
id: Number(teamId),
|
||||
@@ -156,6 +230,24 @@ useSimplePolling(() => {
|
||||
:introduction="story.introduction" mode="show">
|
||||
</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"
|
||||
v-for="advancedPlace in story.places" v-bind:key="advancedPlace.place.code" :addAction="addAction">
|
||||
</PlaceBlock>
|
||||
@@ -204,6 +296,62 @@ useSimplePolling(() => {
|
||||
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 {
|
||||
text-align: left;
|
||||
margin: 10px 0;
|
||||
|
||||
Reference in New Issue
Block a user