generated from VLADIMIR/template_frontend
build
This commit is contained in:
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-2431
File diff suppressed because one or more lines are too long
Vendored
+2437
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 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;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user