This commit is contained in:
2026-09-02 00:59:46 +07:00
parent 690efbbbf5
commit 30ffe8ea08
45 changed files with 2138 additions and 1643 deletions
+256 -222
View File
@@ -1,19 +1,24 @@
<script setup lang="ts">
import { NButton, NConfigProvider, NFlex, NInput, NQrCode } from 'naive-ui'
import { useMessage } from 'naive-ui';
import { computed, nextTick, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useMessage } from 'naive-ui'
import { computed, nextTick, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getAuthClient } from '@/api/auth_client';
import type { Game, Introduction, Place, Question } from '@/api/generated/crabs/evening_detective_server';
import { getAuthClient } from '@/api/auth_client'
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'
import PlaceBlock from '@/components/PlaceBlock.vue'
import { useSimplePolling } from '@/composables/useSimplePolling';
import { useAuthStore } from '@/stores/auth';
import { useSimplePolling } from '@/composables/useSimplePolling'
import { useAuthStore } from '@/stores/auth'
import MessagePaper from './MessagePaper.vue';
import MessagePaper from './MessagePaper.vue'
const authStore = useAuthStore()
const client = getAuthClient()
@@ -24,22 +29,22 @@ const password = route.query.password
const newPlacesCount = ref(0)
const scrollContainer = ref<HTMLDivElement | null>();
const scrollContainer = ref<HTMLDivElement | null>()
const story = ref<AdvancedStory>({
introduction: undefined,
places: [],
questions: []
introduction: undefined,
places: [],
questions: [],
})
const game = ref<Game | undefined>({
id: undefined,
name: undefined,
description: undefined,
startAt: undefined,
status: undefined,
scenario: undefined,
teams: undefined
id: undefined,
name: undefined,
description: undefined,
startAt: undefined,
status: undefined,
scenario: undefined,
teams: undefined,
})
const code = ref('')
@@ -47,134 +52,135 @@ const code = ref('')
const url = window.location.href
const isAtBottom = () => {
const container = scrollContainer.value;
if (!container) return false;
const container = scrollContainer.value
if (!container) return false
return container.scrollHeight - container.scrollTop - container.clientHeight < 1;
};
return container.scrollHeight - container.scrollTop - container.clientHeight < 1
}
async function getStory() {
const oldStory = story.value
const oldStory = story.value
const res = await client.GetTeamStory({
id: Number(teamId),
password: password as string
})
if (res.error != '') {
message.error(res.error!)
return
}
const res = await client.GetTeamStory({
id: Number(teamId),
password: password as string,
})
if (res.error != '') {
message.error(res.error!)
return
}
game.value = res.game
game.value = res.game
const wasAtBottom = isAtBottom();
const wasAtBottom = isAtBottom()
if (wasAtBottom) {
newPlacesCount.value = 0
}
if (wasAtBottom) {
newPlacesCount.value = 0
}
const oldCount = story.value.places.length
const oldCount = story.value.places.length
story.value = {
introduction: res.story!.introduction,
places: res.story!.places?.map((item, i) => {
console.log(oldStory?.places[i]?.mode)
return {
place: item,
mode: oldStory?.places[i]?.mode || 'show'
}
}) || [],
questions: res.story!.questions || []
}
story.value = {
introduction: res.story!.introduction,
places:
res.story!.places?.map((item, i) => {
console.log(oldStory?.places[i]?.mode)
return {
place: item,
mode: oldStory?.places[i]?.mode || 'show',
}
}) || [],
questions: res.story!.questions || [],
}
const newCount = story.value.places.length
const newCount = story.value.places.length
if (newCount > oldCount && oldCount != 0) {
newPlacesCount.value += newCount - oldCount
}
if (newCount > oldCount && oldCount != 0) {
newPlacesCount.value += newCount - oldCount
}
await nextTick();
if (wasAtBottom && oldCount != 0) {
const container = scrollContainer.value;
if (!container) return false;
container.scrollTop = container.scrollHeight;
}
await nextTick()
if (wasAtBottom && oldCount != 0) {
const container = scrollContainer.value
if (!container) return false
container.scrollTop = container.scrollHeight
}
}
getStory()
getAnswers()
async function addAction(newCode: string) {
const res = await client.AddTeamAction({
id: Number(teamId),
password: password as string,
code: newCode
})
if (res.error != '') {
message.error(res.error!)
return
}
await getStory()
code.value = ''
const res = await client.AddTeamAction({
id: Number(teamId),
password: password as string,
code: newCode,
})
if (res.error != '') {
message.error(res.error!)
return
}
await getStory()
code.value = ''
await nextTick();
newPlacesCount.value = 0
await nextTick()
newPlacesCount.value = 0
}
type AdvancedStory = {
introduction?: Introduction;
places: AdvancedPlace[];
questions: Question[];
introduction?: Introduction
places: AdvancedPlace[]
questions: Question[]
}
type AdvancedPlace = {
place: Place;
mode: string;
place: Place
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
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()
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'
return game.value?.status == 'run' || game.value?.status == 'pause'
})
const hasQuestions = computed(() => {
return (story.value.questions?.length || 0) > 0
return (story.value.questions?.length || 0) > 0
})
// Поля ввода и кнопка на светлом бумажном листе: тёмная тема naive-ui
@@ -183,188 +189,216 @@ const hasQuestions = computed(() => {
// «чернильной» рамкой (бумага просвечивает), шрифт наследуется
// печатной машинки от родителя.
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: 'black',
placeholderColorDisabled: 'black',
textColor: 'black',
textColorDisabled: 'black',
},
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: 'black',
placeholderColorDisabled: 'black',
textColor: 'black',
textColorDisabled: 'black',
},
}
async function deleteLast() {
const res = await client.DeleteLastTeamAction({
id: Number(teamId),
})
if (res.error != '') {
message.error(res.error!)
return
}
await getStory()
code.value = ''
const res = await client.DeleteLastTeamAction({
id: Number(teamId),
})
if (res.error != '') {
message.error(res.error!)
return
}
await getStory()
code.value = ''
}
useSimplePolling(() => {
getStory()
}, 2000);
getStory()
}, 2000)
</script>
<template>
<div class="center-block-custom bottom-margin smooth-scroll" ref="scrollContainer">
<div class="width700">
<GameInputForm :addAction="addAction" :allPlacesCount="story.places.length" :newPlacesCount="newPlacesCount"
:gameStatus="game?.status || ''"></GameInputForm>
<div class="center-block-custom bottom-margin smooth-scroll" ref="scrollContainer">
<div class="width700">
<GameInputForm
:addAction="addAction"
:allPlacesCount="story.places.length"
:newPlacesCount="newPlacesCount"
:gameStatus="game?.status || ''"
></GameInputForm>
<IntroBlock
v-if="game?.status && game.status != 'draft' && story.introduction && (story.introduction.text || story.introduction.audio)"
:introduction="story.introduction" mode="show">
</IntroBlock>
<IntroBlock
v-if="
game?.status &&
game.status != 'draft' &&
story.introduction &&
(story.introduction.text || story.introduction.audio)
"
:introduction="story.introduction"
mode="show"
>
</IntroBlock>
<MessagePaper v-if="hasQuestions && game?.status && game.status != 'draft'" class="questions-block">
<div class="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>
<MessagePaper
v-if="hasQuestions && game?.status && game.status != 'draft'"
class="questions-block"
>
<div class="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>
<PlaceBlock
:place="advancedPlace.place"
v-model:mode="advancedPlace.mode"
v-for="advancedPlace in story.places"
v-bind:key="advancedPlace.place.code"
:addAction="addAction"
>
</PlaceBlock>
<MessagePaper class="qr-main-block" v-if="story.places.length == 0 && game?.status == 'draft'">
<n-space vertical>
<h2 class="qr-main-block-text">Это код этого дела</h2>
<p class="qr-main-block-text">Поделись с командой</p>
<div class="qr-block">
<n-qr-code :value="url" :size="300" :padding="0" />
</div>
<p class="qr-main-block-text">Игра скоро начнется</p>
</n-space>
</MessagePaper>
<MessagePaper
class="qr-main-block"
v-if="story.places.length == 0 && game?.status == 'draft'"
>
<n-space vertical>
<h2 class="qr-main-block-text">Это код этого дела</h2>
<p class="qr-main-block-text">Поделись с командой</p>
<div class="qr-block">
<n-qr-code :value="url" :size="300" :padding="0" />
</div>
<p class="qr-main-block-text">Игра скоро начнется</p>
</n-space>
</MessagePaper>
<div v-if="authStore.hasRole('author') && story.places.length > 0" class="edit-panel">
<n-flex justify="end">
<n-button @click="deleteLast">Удалить последний ход</n-button>
</n-flex>
</div>
</div>
<div v-if="authStore.hasRole('author') && story.places.length > 0" class="edit-panel">
<n-flex justify="end">
<n-button @click="deleteLast">Удалить последний ход</n-button>
</n-flex>
</div>
</div>
</div>
<HeaderMenu active="games" />
<HeaderMenu active="games" />
</template>
<style scoped>
.bottom-margin {
height: calc(100dvh - 70px - 76px);
height: calc(100dvh - 70px - 76px);
}
.smooth-scroll {
scroll-behavior: smooth;
scroll-behavior: smooth;
}
.edit-panel {
margin: 30px 0;
padding: 20px;
background-color: #222;
border-radius: 10px;
background-color: #222;
border-radius: 10px;
margin: 30px 0;
padding: 20px;
}
.qr-main-block {
display: flex;
justify-content: center;
align-items: center;
align-items: center;
display: flex;
justify-content: center;
}
.questions-hr {
margin: 10px 0;
border: dashed 1px;
border-color: black;
border: dashed 1px;
border-color: black;
margin: 10px 0;
}
.questions-hint {
color: black;
color: black;
}
.question-item {
margin: 15px 0;
margin: 15px 0;
}
.question-text {
margin-bottom: 6px;
font-weight: 500;
font-weight: 500;
margin-bottom: 6px;
}
.questions-actions {
margin-top: 10px;
margin-top: 10px;
}
/* Кнопка действия на бумажном листе — как «двери» в точках:
текстура ремня, бронзовый текст печатной машинки */
.paper-button {
display: inline-block;
margin-top: 10px;
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: 16px;
font-weight: 600;
cursor: pointer;
background-image: url('@/assets/images/belt.png');
background-size: cover;
border: 0;
border-radius: 5px;
color: #bfa07d;
cursor: pointer;
display: inline-block;
font-family: 'font_old_typer', serif;
font-size: 16px;
font-weight: 600;
margin-top: 10px;
padding: 5px 12px;
}
.paper-button:hover {
color: #63e2b7;
color: #63e2b7;
}
.font {
font-family: 'font_old_typer', serif;
font-size: 18px;
font-family: 'font_old_typer', serif;
font-size: 18px;
}
.qr-main-block-text {
font-family: 'font_old_typer', serif;
font-size: 18px;
margin: 10px 0;
text-align: left;
font-family: 'font_old_typer', serif;
font-size: 18px;
margin: 10px 0;
text-align: left;
}
p.qr-main-block-text {
font-weight: 500;
font-weight: 500;
}
.qr-block {
display: inline-block;
background-color: white;
padding: 12px;
background-color: white;
display: inline-block;
padding: 12px;
}
@media (width >=1024px) {
.bottom-margin {
height: calc(100dvh - 70px - 76px);
}
.bottom-margin {
height: calc(100dvh - 70px - 76px);
}
}
</style>