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
+154 -2
View File
@@ -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>
+1
View File
@@ -751,6 +751,7 @@ const bottomApplications = computed<Application[]>(() => {
.message-show-content {
font-weight: 500;
white-space: pre-wrap;
/* BFC: плавающий блок улик остаётся внутри текста и не наезжает
на следующие секции (двери/действия) при коротком тексте */
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 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;
+153 -5
View File
@@ -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;