Files
evening_detective_frontend/src/components/TeamStoryPage.vue
T
2026-09-18 16:43:29 +07:00

507 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { NButton, NConfigProvider, NFlex, NInput, NQrCode, NRate } from 'naive-ui'
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,
TeamAction,
} from '@/api/generated/crabs/evening_detective_server'
import GameInputForm from '@/components/GameInputForm.vue'
import IntroBlock from '@/components/IntroBlock.vue'
import PlaceBlock from '@/components/PlaceBlock.vue'
import { useSimplePolling } from '@/composables/useSimplePolling'
import { useAuthStore } from '@/stores/auth'
import MessagePaper from './MessagePaper.vue'
const authStore = useAuthStore()
const client = getAuthClient()
const message = useMessage()
const route = useRoute()
const teamId = route.params.id
const password = route.query.password
const newPlacesCount = ref(0)
const actions = ref<TeamAction[]>([])
const scrollContainer = ref<HTMLDivElement | null>()
const story = ref<AdvancedStory>({
introduction: undefined,
places: [],
questions: [],
})
const game = ref<Game | undefined>({
id: undefined,
name: undefined,
description: undefined,
startAt: undefined,
status: undefined,
format: undefined,
location: undefined,
scenario: undefined,
teams: undefined,
})
const code = ref('')
const reviewKey = `game-review-${teamId}`
const reviewSubmitted = ref(localStorage.getItem(reviewKey) === '1')
const reviewRating = ref(0)
const reviewText = ref('')
const reviewLoading = ref(false)
const url = window.location.href
const isAtBottom = () => {
const container = scrollContainer.value
if (!container) return false
return container.scrollHeight - container.scrollTop - container.clientHeight < 1
}
async function getStory() {
const oldStory = story.value
const res = await client.GetTeamStory({
id: Number(teamId),
password: password as string,
})
if (res.error != '') {
message.error(res.error!)
return
}
game.value = res.game
const wasAtBottom = isAtBottom()
if (wasAtBottom) {
newPlacesCount.value = 0
}
const oldCount = story.value.places.length
story.value = {
background: res.story!.background,
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 || [],
}
actions.value = res.actions || []
const newCount = story.value.places.length
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
}
}
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 = ''
await nextTick()
newPlacesCount.value = 0
}
type AdvancedStory = {
background?: string
introduction?: Introduction
places: AdvancedPlace[]
questions: Question[]
}
type AdvancedPlace = {
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
}
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?.format !== 'offline' && (game.value?.status == 'run' || game.value?.status == 'pause')
})
const hasQuestions = computed(() => {
return game.value?.format !== 'offline' && (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 1px black',
caretColor: 'black',
color: 'transparent',
colorDisabled: 'transparent',
colorFocus: 'transparent',
fontSizeMedium: '18px',
placeholderColor: 'black',
placeholderColorDisabled: 'black',
textColor: 'black',
textColorDisabled: 'black',
},
Rate: {
itemColor: 'black',
itemColorHover: '#b8860b',
itemColorActive: '#b8860b',
},
}
async function submitReview() {
if (!reviewRating.value || reviewText.value.trim() === '') {
message.error('Поставьте оценку и напишите отзыв')
return
}
reviewLoading.value = true
const res = await client.CreateGameReview({
gameId: Number(game.value?.id),
teamId: Number(teamId),
password: password as string,
rating: reviewRating.value,
text: reviewText.value.trim(),
})
reviewLoading.value = false
if (res.error != '') {
message.error(res.error!)
return
}
reviewSubmitted.value = true
localStorage.setItem(reviewKey, '1')
message.success('Спасибо за отзыв')
}
async function deleteLast() {
const res = await client.DeleteLastTeamAction({
id: Number(teamId),
})
if (res.error != '') {
message.error(res.error!)
return
}
await getStory()
code.value = ''
}
useSimplePolling(() => {
getStory()
}, 2000)
</script>
<template>
<div
v-if="story.background"
class="case-background"
:style="{ backgroundImage: `url(${JSON.stringify(story.background)})` }"
aria-hidden="true"
/>
<div
class="center-block-custom bottom-margin smooth-scroll"
:class="{ 'game-finished': game?.status === 'finish' }"
ref="scrollContainer"
>
<div v-if="game?.status === 'finish'" class="width700">
<MessagePaper role="status">
<div class="font">Игра завершена. Спасибо за участие!</div>
</MessagePaper>
<MessagePaper v-if="!reviewSubmitted" class="review-block">
<div class="font">Оцените игру</div>
<hr class="questions-hr" />
<n-config-provider :theme-overrides="paperControlsOverrides">
<div class="review-rating font">Оценка</div>
<n-rate v-model:value="reviewRating" :count="5" class="review-rate" />
<n-input
v-model:value="reviewText"
class="review-input"
type="textarea"
:autosize="{ minRows: 4, maxRows: 8 }"
placeholder="Расскажите, как прошла игра"
/>
<n-flex justify="end" class="questions-actions">
<button type="button" class="paper-button" :disabled="reviewLoading" @click="submitReview">
Отправить отзыв
</button>
</n-flex>
</n-config-provider>
</MessagePaper>
</div>
<div v-else class="width700">
<GameInputForm
:addAction="addAction"
:allPlacesCount="story.places.length"
:newPlacesCount="newPlacesCount"
:gameStatus="game?.status || ''"
></GameInputForm>
<IntroBlock
v-if="
game?.format !== 'offline' &&
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>
<PlaceBlock
:place="advancedPlace.place"
v-model:mode="advancedPlace.mode"
v-for="(advancedPlace, index) in story.places"
v-bind:key="`${game?.format}:${advancedPlace.place.code}`"
:mcp="authStore.hasRole('author') && actions[index]?.mcp"
: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>
<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>
</template>
<style scoped>
.case-background {
background-position: center;
background-size: cover;
inset: 0;
pointer-events: none;
position: fixed;
z-index: -1;
}
.bottom-margin {
height: calc(100dvh - 76px);
margin-top: 0;
}
.game-finished {
height: 100dvh;
}
.review-block {
margin-top: 20px;
}
.review-rating {
margin-bottom: 6px;
margin-top: 20px;
}
.review-rate {
margin-bottom: 8px;
margin-top: 4px;
}
.review-input {
margin-top: 14px;
}
.smooth-scroll {
scroll-behavior: smooth;
}
.edit-panel {
background-color: #222;
border-radius: 10px;
margin: 30px 0;
padding: 20px;
}
.qr-main-block {
align-items: center;
display: flex;
justify-content: center;
}
.questions-hr {
border: dashed 1px;
border-color: black;
margin: 10px 0;
}
.questions-hint {
color: black;
}
.question-item {
margin: 15px 0;
}
.question-text {
font-weight: 500;
margin-bottom: 6px;
}
.questions-actions {
margin-top: 10px;
}
/* Кнопка действия на бумажном листе — как «двери» в точках:
текстура ремня, бронзовый текст печатной машинки */
.paper-button {
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;
}
.font {
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;
}
p.qr-main-block-text {
font-weight: 500;
}
.qr-block {
background-color: white;
display: inline-block;
padding: 12px;
}
</style>