Files
evening_detective_frontend/src/components/GamePage.vue
T
2026-09-01 01:47:14 +07:00

802 lines
25 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 { Flag, Pause, Play, Reset, Settings } from '@vicons/carbon'
import { Icon } from '@vicons/utils'
import { NButton, NTag } 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, 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';
import {
applicationFileType,
applicationFileURL,
} from '@/utils/application';
const client = getAuthClient()
const router = useRouter()
const route = useRoute()
const gameId = route.params.id
const message = useMessage()
const showSettingsModal = ref(false)
const showGiveApplicationModal = ref(false)
const showAddTeamModal = ref(false)
const showDeleteTeamModal = ref(false)
const showDeleteGameModal = ref(false)
const newTeamName = ref('')
const deletedGameName = ref('')
const showQR = ref(false)
const urlQR = ref('')
const titleQR = ref('')
const game = ref<Game>({
id: undefined,
name: undefined,
description: undefined,
startAt: undefined,
status: undefined,
scenario: undefined,
teams: undefined,
})
async function getGame(id: number) {
const res = await client.GetGame({ id: id })
if (res.error != '') {
message.error(res.error!)
return
}
game.value = res.game!
game.value.teams = game.value.teams?.sort((a, b) => {
let aLen = 0
if ((a.applications?.length || 0) > 0) {
aLen = 1
}
let bLen = 0
if ((b.applications?.length || 0) > 0) {
bLen = 1
}
if (aLen > bLen) {
return -1
}
if (aLen < bLen) {
return 1
}
const aName = a.name || ''
const bName = b.name || ''
if (aName > bName) {
return 1
}
if (aName < bName) {
return -1
}
return 0
})
}
getGame(Number(gameId))
async function toTeamStory(id: number, password: string, newTab: boolean = false) {
const uri = '/team-story/' + id + '?password=' + password
if (newTab) {
window.open(uri, '_blank');
return
}
router.push(uri)
}
async function updateGame() {
const res = await client.UpdateGame({
id: game.value.id,
name: game.value.name,
description: game.value.description,
startAt: game.value.startAt,
scenarioId: game.value.scenario?.id,
})
if (res.error != '') {
message.error(res.error!)
return
}
await getGame(game.value.id!)
showSettingsModal.value = false
}
const giveApplicationTeam = ref<Team>({
id: 0,
name: '',
status: '',
password: '',
actionsCount: 0,
applications: []
})
const deletedTeamName = ref('')
const deletedTeam = ref<Team>({
id: 0,
name: '',
status: '',
password: '',
actionsCount: 0,
applications: []
})
const giveApplicationApplication = ref<Application>({
name: '',
image: '',
file_type: ''
})
const url = window.location.href
function giveApplication(team: Team, application: Application) {
giveApplicationTeam.value = team
giveApplicationApplication.value = application
showGiveApplicationModal.value = true
}
async function addTeam() {
const res = await client.AddTeam({
gameId: game.value.id,
name: newTeamName.value,
})
if (res.error != '') {
message.error(res.error!)
return
}
await getGame(game.value.id!)
showAddTeamModal.value = false
}
async function confirmGiveApplication() {
const res = await client.GiveTeamApplications({
id: giveApplicationTeam.value.id,
name: giveApplicationApplication.value.name,
})
if (res.error != '') {
message.error(res.error!)
return
}
await getGame(game.value.id!)
showGiveApplicationModal.value = false
}
async function deleteTeam(team: Team) {
deletedTeam.value = team
showDeleteTeamModal.value = true
}
async function confirmDeleteTeam() {
const res = await client.DeleteTeam({
id: deletedTeam.value.id,
})
if (res.error != '') {
message.error(res.error!)
return
}
await getGame(game.value.id!)
showDeleteTeamModal.value = false
}
async function deleteGame() {
const res = await client.DeleteGame({
id: game.value.id,
})
if (res.error != '') {
message.error(res.error!)
return
}
message.success('Игра удалена')
router.push('/games')
}
function getTeamStoryURL(id: number, password: string): string {
return getURL() + '/team-story/' + id + '?password=' + password
}
async function openQR(url: string, text: string) {
urlQR.value = url
titleQR.value = text
showQR.value = true
}
useSimplePolling(() => {
getGame(Number(gameId))
}, 2000);
async function resetGame() {
const res = await client.ResetGame({
id: game.value.id
})
if (res.error != '') {
message.error(res.error!)
return
}
await getGame(game.value.id!)
}
async function startGame() {
const res = await client.StartGame({
id: game.value.id
})
if (res.error != '') {
message.error(res.error!)
return
}
await getGame(game.value.id!)
}
async function pauseGame() {
const res = await client.PauseGame({
id: game.value.id
})
if (res.error != '') {
message.error(res.error!)
return
}
await getGame(game.value.id!)
}
async function playGame() {
const res = await client.PlayGame({
id: game.value.id
})
if (res.error != '') {
message.error(res.error!)
return
}
await getGame(game.value.id!)
}
async function finishGame() {
const res = await client.FinishGame({
id: game.value.id
})
if (res.error != '') {
message.error(res.error!)
return
}
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');
const [year, month, day] = datePart.split('-').map(Number);
const correctTimePart = timePart.split('.')[0]
const [hours, minutes, seconds] = (correctTimePart || '00:00:00').split(':').map(Number);
// Создаем дату в UTC, чтобы избежать сдвига
return new Date(Date.UTC(year, month - 1, day, hours, minutes, seconds || 0));
}
function getCurrentUTC(): Date {
const now = new Date();
return new Date(now.getTime() - now.getTimezoneOffset() * 60000);
}
function getTimeDiff(startStr: string, endStr: string): string {
if (startStr == '') {
return 'Игра ещё не началась'
}
const start = parseDateWithoutTimezone(startStr)
let endTime = getCurrentUTC();
if (endStr != '') {
endTime = parseDateWithoutTimezone(endStr);
}
const diffMs = endTime.getTime() - start.getTime();
if (diffMs < 0) return 'Игра ещё не началась';
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const months = Math.floor(days / 30);
const years = Math.floor(days / 365);
if (years > 0) return `${years}г ${days % 365}д`;
if (months > 0) return `${months}мес ${days % 30}д`;
if (days > 0) return `${days}д ${hours % 24}ч`;
if (hours > 0) return `${hours}ч ${minutes % 60}м`;
if (minutes > 0) return `${minutes}м ${seconds % 60}с`;
return `${seconds}с`;
}
const timeString = ref('')
useSimplePolling(() => {
timeString.value = getTimeDiff(game.value.startedAt || '', game.value.endedAt || '')
}, 1000);
</script>
<template>
<div class="center-block-custom">
<div class="width1200">
<div class="settings-block">
<n-flex justify="space-between">
<n-space>
<n-tag :bordered="false" type="info">
Команд: {{ game.teams?.length }}
</n-tag>
<n-tag type="info">
{{ timeString }}
</n-tag>
</n-space>
<div>
<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 />
</Icon>
Настройки
</n-button>
<n-button v-if="(game.teams?.length || 0) == 0" type="error" quaternary
@click="showDeleteGameModal = true" class="settings-button">
Удалить игру
</n-button>
<n-space>
<n-button type="info" v-if="game.status != 'draft'" dashed @click="resetGame()">
<Icon>
<Reset />
</Icon>
</n-button>
<n-button type="info" v-if="game.status == 'draft'" dashed @click="startGame()">
<Icon>
<Play />
</Icon>
</n-button>
<n-button type="info" v-if="game.status == 'pause'" dashed @click="playGame()">
<Icon>
<Play />
</Icon>
</n-button>
<n-button type="info" v-if="game.status == 'run'" dashed @click="pauseGame()">
<Icon>
<Pause />
</Icon>
</n-button>
<n-button type="info" v-if="game.status != 'finish'" dashed @click="finishGame()">
<Icon>
<Flag />
</Icon>
</n-button>
</n-space>
</div>
</n-flex>
</div>
<div class="team-block team-block-hover" @click="showAddTeamModal = true">
<p class="place-image-plus">+</p>
</div>
<div v-for="team in game.teams" :key="team.id" class="team-block">
<div class="team-content-block">
<n-flex justify="space-between">
<div class="title-block">
<span class="team-name-block">
{{ team.name }}
</span>
<div>
Поездки: {{ team.actionsCount }}
</div>
</div>
<n-flex justify="end">
<n-button @click="openQR(getTeamStoryURL(team.id!, team.password!), 'Подсмотреть игру')">
QR
</n-button>
<n-button @click="toTeamStory(team.id!, team.password!, true)">
Подсмотреть игру
</n-button>
<n-button type="error" ghost @click="deleteTeam(team)">
Удалить
</n-button>
</n-flex>
</n-flex>
<n-button v-for="application in team.applications" :key="application.name" class="link-button"
@click="giveApplication(team, application)">
Выдать: {{ application.name }}
</n-button>
</div>
</div>
</div>
</div>
<!-- Окно настроек -->
<n-modal v-model:show="showSettingsModal">
<n-card style="width: 600px" title="Настройки игры" :bordered="false" size="huge" role="dialog"
aria-modal="true">
<template #header-extra>
<n-button @click="showSettingsModal = false"> x </n-button>
</template>
<n-space vertical>
<p>Название</p>
<n-input v-model:value="game.name" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
<p class="settings-header">Описание</p>
<n-input v-model:value="game.description" type="textarea" placeholder="Убийство пастуха..." />
</n-space>
<template #footer>
<n-flex justify="end">
<n-button @click="updateGame()" ghost> Сохранить изменения </n-button>
</n-flex>
</template>
</n-card>
</n-modal>
<!-- Окно выдачи улик -->
<n-modal v-model:show="showGiveApplicationModal">
<n-card style="width: 600px" title="Настройки игры" :bordered="false" size="huge" role="dialog"
aria-modal="true">
<template #header-extra>
<n-button @click="showGiveApplicationModal = false"> x </n-button>
</template>
<n-space vertical>
<p>Команда</p>
<h3>{{ giveApplicationTeam.name }}</h3>
<p>Улика</p>
<h3>{{ giveApplicationApplication.name }}</h3>
<div v-if="applicationFileType(giveApplicationApplication) == 'image' && giveApplicationApplication.image"
class="give-application-image-block">
<img :src="applicationFileURL(giveApplicationApplication.image)" class="give-application-image" />
</div>
<audio v-else-if="applicationFileType(giveApplicationApplication) == 'audio' && giveApplicationApplication.image"
:src="applicationFileURL(giveApplicationApplication.image)" controls preload="none"
class="give-application-audio"></audio>
<a v-else-if="applicationFileType(giveApplicationApplication) == 'pdf' && giveApplicationApplication.image"
:href="applicationFileURL(giveApplicationApplication.image)" target="_blank" rel="noopener"
class="give-application-pdf-link">
PDF
</a>
</n-space>
<template #footer>
<n-flex justify="end">
<n-button @click="confirmGiveApplication()" ghost> Выдать </n-button>
</n-flex>
</template>
</n-card>
</n-modal>
<!-- Окно создания команды -->
<n-modal v-model:show="showAddTeamModal">
<n-card style="width: 600px" title="Создание команды" :bordered="false" size="huge" role="dialog"
aria-modal="true">
<template #header-extra>
<n-button @click="showAddTeamModal = false"> x </n-button>
</template>
<n-input v-model:value="newTeamName" type="text" placeholder='Три дебила' />
<template #footer>
<n-flex justify="end">
<n-button @click="addTeam()" :disabled="newTeamName.length == 0">
Создать
</n-button>
</n-flex>
</template>
</n-card>
</n-modal>
<!-- Окно создания команды -->
<n-modal v-model:show="showDeleteTeamModal">
<n-card style="width: 600px" title="Удаление команды" :bordered="false" size="huge" role="dialog"
aria-modal="true">
<template #header-extra>
<n-button @click="showDeleteTeamModal = false"> x </n-button>
</template>
<n-space vertical>
<p>
Введите название команды
<n-text class="name-text" strong>{{ deletedTeam.name }}</n-text> чтобы удалить его
</p>
<n-input v-model:value="deletedTeamName" type="text" placeholder='Три дебила' />
</n-space>
<template #footer>
<n-flex justify="end">
<n-button @click="confirmDeleteTeam()" :disabled="deletedTeamName != deletedTeam.name">
Удалить
</n-button>
</n-flex>
</template>
</n-card>
</n-modal>
<!-- Окно удаления игры -->
<n-modal v-model:show="showDeleteGameModal">
<n-card style="width: 600px" title="Удаление игры" :bordered="false" size="huge" role="dialog"
aria-modal="true">
<template #header-extra>
<n-button @click="showDeleteGameModal = false"> x </n-button>
</template>
<n-space vertical>
<p>
Введите название игры
<n-text class="name-text" strong>{{ game.name }}</n-text> чтобы удалить её
</p>
<n-input v-model:value="deletedGameName" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
</n-space>
<template #footer>
<n-flex justify="end">
<n-button @click="deleteGame()" :disabled="deletedGameName != game.name">
Удалить
</n-button>
</n-flex>
</template>
</n-card>
</n-modal>
<!-- Окно qr -->
<n-modal v-model:show="showQR">
<n-card style="width: 600px" :title="titleQR" :bordered="false" size="huge" role="dialog" aria-modal="true">
<template #header-extra>
<n-button @click="showQR = false"> x </n-button>
</template>
<n-space vertical>
<div class="qr-block">
<n-qr-code :value="urlQR" :size="300" :padding="0" />
</div>
</n-space>
<template #footer></template>
</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>
<style scoped>
.team-block {
margin: 10px 0;
border-radius: 10px;
background-color: #222;
border: 1px solid #222;
}
.team-block-hover:hover {
color: #63e2b7;
cursor: pointer;
border: 1px solid #63e2b7;
}
.team-content-block {
padding: 20px;
}
.team-name-block {
font-weight: 600;
font-size: 20px;
}
.settings-block {}
.settings-button {
float: right;
margin-left: 10px;
}
.settings-icon {
width: 20px;
}
.title-block {}
.link-button {
margin-top: 20px;
width: 100%;
}
.place-image-plus {
font-size: 30px;
display: flex;
justify-content: center;
align-items: center;
}
.name-text {
padding: 3px;
border-radius: 3px;
background-color: rgb(255 255 255 / 10%);
}
.qr-block {
display: inline-block;
background-color: white;
padding: 12px;
}
.give-application-image-block {
margin: 10px 0 5px;
padding: 7px;
max-width: 320px;
background-image: url("@/assets/images/paper_white.jpg");
background-size: cover;
box-shadow: 0 3px 15px rgb(98 98 98);
}
.give-application-image {
width: 100%;
}
.give-application-audio {
width: 100%;
margin: 10px 0 5px;
}
.give-application-pdf-link {
display: inline-block;
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>