generated from VLADIMIR/template_frontend
496 lines
13 KiB
Vue
496 lines
13 KiB
Vue
<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,
|
||
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 2px rgb(99 226 183 / 30%)',
|
||
caretColor: '#63e2b7',
|
||
color: 'transparent',
|
||
colorDisabled: 'transparent',
|
||
colorFocus: 'transparent',
|
||
fontSizeMedium: '18px',
|
||
placeholderColor: 'black',
|
||
placeholderColorDisabled: 'black',
|
||
textColor: 'black',
|
||
textColorDisabled: 'black',
|
||
},
|
||
Rate: {
|
||
itemColor: 'black',
|
||
itemColorHover: 'black',
|
||
itemColorActive: 'black',
|
||
},
|
||
}
|
||
|
||
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" />
|
||
<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-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>
|