Files
evening_detective_frontend/src/components/GamePage.vue
T
2026-10-11 01:45:53 +07:00

1616 lines
46 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, Trophy } from '@vicons/carbon'
import { Icon } from '@vicons/utils'
import { NAlert, NButton, NTag } from 'naive-ui'
import {
NCard,
NFlex,
NInput,
NInputNumber,
NModal,
NQrCode,
NRate,
NSelect,
NSpace,
NTabPane,
NTabs,
NText,
} from 'naive-ui'
import { useMessage } from 'naive-ui'
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getAuthClient } from '@/api/auth_client'
import { getBinaryClient } from '@/api/binary_client'
import type {
Application,
Game,
GameReview,
MapItem,
MusicPlaybackState,
MusicState,
Playlist,
Question,
ResultsQuestion,
ScreenCommand,
ScreenMode,
ScreenPlan,
ScreenPlanStep,
ScreenState,
Team,
TeamAnswers,
TeamResult,
Ticket,
} from '@/api/generated/crabs/evening_detective_server'
import GameMapPanel from '@/components/GameMapPanel.vue'
import GameScreensPanel from '@/components/GameScreensPanel.vue'
import GameTeamsPanel from '@/components/GameTeamsPanel.vue'
import HeaderMenu from '@/components/HeaderMenu.vue'
import TicketCheckModal from '@/components/TicketCheckModal.vue'
import UiBlock from '@/components/UiBlock.vue'
import { useSimplePolling } from '@/composables/useSimplePolling'
import { applicationFileType, applicationFileURL } from '@/utils/application'
import { formatGameDateTime, parseDateWithoutTimezone } from '@/utils/datetime'
const client = getAuthClient()
const binaryClient = getBinaryClient()
const isDownloadingQR = ref(false)
const router = useRouter()
const route = useRoute()
const gameId = route.params.id
type GameTab = 'settings' | 'teams' | 'screens' | 'game' | 'payment' | 'answers'
function isGameTab(value: string): value is GameTab {
return ['settings', 'teams', 'screens', 'game', 'payment', 'answers'].includes(value)
}
const queryTab = typeof route.query.tab === 'string' ? route.query.tab : ''
const activeTab = ref<GameTab>(isGameTab(queryTab) ? queryTab : 'game')
async function setActiveTab(value: string) {
if (!isGameTab(value)) return
activeTab.value = value
await router.replace({ query: { ...route.query, tab: value } })
if (value === 'answers') await loadGameAnswers()
}
const message = useMessage()
const settingsDraft = ref({ name: '', description: '', format: 'online', location: '' })
const settingsDraftInitialized = ref(false)
const showGiveApplicationModal = ref(false)
const showAddTeamModal = ref(false)
const showDeleteTeamModal = ref(false)
const showDeleteGameModal = ref(false)
const newTeamName = ref('')
const newTeamEmail = ref('')
const newTeamParticipants = ref(1)
const deletedGameName = ref('')
const showQR = ref(false)
const urlQR = ref('')
const titleQR = ref('')
const showTicketCheckModal = ref(false)
const paymentURL =
'https://www.sberbank.ru/ru/choise_bank?requisiteNumber=79609361994&bankCode=100000000111'
const ticketCount = ref(1)
const issuedTickets = ref<Ticket[]>([])
const isIssuingTickets = ref(false)
const screenBinding = ref(false)
const game = ref<Game>({
ID: undefined,
name: undefined,
description: undefined,
startAt: undefined,
status: undefined,
format: undefined,
location: undefined,
scenario: undefined,
teams: undefined,
export: undefined,
})
const participationOptions = [
{ label: 'Ожидает ответа', value: 'pending' },
{ label: 'Подтверждено', value: 'confirmed' },
{ label: 'Отменено', value: 'cancelled' },
]
const activeTeamCount = computed(
() => (game.value.teams || []).filter((team) => team.participationStatus !== 'cancelled').length,
)
const screens = ref<ScreenState[]>([])
const playlists = ref<Playlist[]>([])
const music = ref<MusicState>()
const screenPlan = ref<ScreenPlan>()
const screenPlanDraft = ref<ScreenPlanStep[]>([])
const screenPlanDirty = ref(false)
const screenPlanSaving = ref(false)
const screenPlanMode = ref<'use' | 'edit'>('use')
let screenPlanRequestVersion = 0
const previewQuestions = ref<ResultsQuestion[]>([])
const previewTeams = ref<TeamResult[]>([])
const mapItems = ref<MapItem[]>([])
const mapSaving = ref(false)
const mapDragging = ref(false)
const screenVolume = ref(100)
const screenPosition = ref(0)
async function getGame(id: number) {
const res = await client.GetGame({ ID: id })
game.value = res.game!
if (!settingsDraftInitialized.value) {
settingsDraft.value = {
name: game.value.name || '',
description: game.value.description || '',
format: game.value.format || 'online',
location: game.value.location || '',
}
settingsDraftInitialized.value = true
}
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 getGameScreens() {
const res = await client.GetGameScreens({ gameID: Number(gameId) })
screens.value = res.screens || []
const previewScreen =
screens.value.find((screen) => screen.status === 'connected') || screens.value[0]
if (previewScreen?.mode === 'SCREEN_MODE_RESULTS') {
const result = await client.GetGameResults({ gameID: Number(gameId) })
previewQuestions.value = result.questions || []
previewTeams.value = result.teams || []
} else {
previewQuestions.value = []
previewTeams.value = []
}
}
getGameScreens()
async function getGameMusic() {
try {
const res = await client.GetGameMusic({ gameID: Number(gameId) })
playlists.value = res.playlists || []
music.value = res.state
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось получить музыку игры')
}
}
getGameMusic()
function isScreenPlanEditing() {
return screenPlanMode.value === 'edit'
}
async function getScreenPlan() {
console.log('getScreenPlan')
if (isScreenPlanEditing() || screenPlanDirty.value) return
console.log('no skip getScreenPlan')
const requestVersion = ++screenPlanRequestVersion
const res = await client.GetScreenPlan({ gameID: Number(gameId) })
if (
requestVersion !== screenPlanRequestVersion ||
isScreenPlanEditing() ||
screenPlanDirty.value
) {
return
}
screenPlan.value = res.plan
screenPlanDraft.value = res.plan?.steps?.map((step) => ({ ...step })) || []
}
getScreenPlan()
function addScreenPlanStep() {
screenPlanDraft.value.push({
mode: 'SCREEN_MODE_TEXT',
text: 'Новый экран',
timerLabel: 'Перерыв',
timerDurationSeconds: 600,
})
screenPlanDirty.value = true
screenPlanMode.value = 'edit'
}
function editScreenPlan() {
if (!screenPlan.value) return
screenPlanDraft.value = screenPlan.value.steps?.map((step) => ({ ...step })) || []
screenPlanMode.value = 'edit'
}
function cancelScreenPlanEdit() {
if (screenPlanDirty.value && !window.confirm('Отменить несохранённые изменения плана?')) return
screenPlanDraft.value = screenPlan.value?.steps?.map((step) => ({ ...step })) || []
screenPlanDirty.value = false
screenPlanMode.value = 'use'
}
function removeScreenPlanStep(index: number) {
screenPlanDraft.value.splice(index, 1)
screenPlanDirty.value = true
}
function moveScreenPlanStep(index: number, offset: number) {
const target = index + offset
if (target < 0 || target >= screenPlanDraft.value.length) return
const [step] = screenPlanDraft.value.splice(index, 1)
screenPlanDraft.value.splice(target, 0, step)
screenPlanDirty.value = true
}
function markScreenPlanDirty() {
screenPlanDirty.value = true
}
function clearScreenPlan() {
screenPlanDraft.value = []
markScreenPlanDirty()
}
async function saveScreenPlan() {
if (screenPlanSaving.value) return
screenPlanRequestVersion += 1
screenPlanSaving.value = true
try {
const currentStep = Math.min(
screenPlan.value?.currentStep || 0,
Math.max(0, screenPlanDraft.value.length - 1),
)
const res = await client.SaveScreenPlan({
gameID: Number(gameId),
steps: screenPlanDraft.value,
currentStep,
})
screenPlan.value = res.plan
screenPlanDraft.value = res.plan?.steps?.map((step) => ({ ...step })) || []
screenPlanDirty.value = false
screenPlanMode.value = 'use'
message.success(res.plan ? 'План экранов сохранён' : 'План экранов удалён')
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось сохранить план экранов')
} finally {
screenPlanSaving.value = false
}
}
async function setScreenPlanStep(index: number) {
if (screenPlanDirty.value || !screenPlan.value) return
try {
const res = await client.SetScreenPlanStep({ gameID: Number(gameId), currentStep: index })
screenPlan.value = res.plan
await getGameScreens()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось перейти на шаг плана')
}
}
async function sendScreenPlanCommand(mode: ScreenMode | undefined, command: ScreenCommand) {
if (
screenPlanDirty.value ||
(mode !== 'SCREEN_MODE_TIMER' &&
mode !== 'SCREEN_MODE_INTRO' &&
mode !== 'SCREEN_MODE_CONCLUSION')
) {
return
}
try {
await client.SendScreenCommand({
gameID: Number(gameId),
command,
positionMs: Math.max(0, Math.round(screenPosition.value * 1000)),
volume: Math.max(0, Math.min(100, Math.round(screenVolume.value))),
})
await getGameScreens()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось управлять экраном')
}
}
async function bindScannedScreen(screenID: string, accessToken: string) {
if (screenBinding.value) return
screenBinding.value = true
try {
await client.BindScreen({ screenID, accessToken, gameID: Number(gameId) })
message.success('Экран привязан к игре')
await getGameScreens()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось привязать экран')
} finally {
screenBinding.value = false
}
}
async function getGameMap() {
if (mapSaving.value || mapDragging.value) return
const res = await client.GetGameMap({ gameID: Number(gameId) })
if (mapSaving.value || mapDragging.value) return
mapItems.value = res.items || []
}
getGameMap()
async function saveMapItem(item: MapItem, hidden = item.hidden) {
mapSaving.value = true
try {
await client.UpdateGameMapItem({
gameID: Number(gameId),
kind: item.kind,
itemID: item.itemID,
x: Math.round(item.x || 0),
y: Math.round(item.y || 0),
hidden,
})
} finally {
mapSaving.value = false
}
}
async function saveMapItemAndReload(item: MapItem, hidden = item.hidden) {
await saveMapItem(item, hidden)
await getGameMap()
}
async function setSoundScreen(screen: ScreenState) {
await client.SetScreenSound({ screenID: screen.ID, soundEnabled: true })
await getGameScreens()
}
async function setGameMusic(change: {
playlistID?: number
trackIndex?: number
positionMs?: number
playback?: MusicPlaybackState
loop?: boolean
volume?: number
}) {
const current = music.value
try {
const res = await client.SetGameMusic({
gameID: Number(gameId),
playlistID: change.playlistID ?? current?.playlist?.ID ?? 0,
trackIndex: change.trackIndex ?? current?.trackIndex ?? 0,
positionMs: change.positionMs ?? current?.positionMs ?? 0,
volume: change.volume ?? current?.volume ?? 100,
loop: change.loop ?? current?.loop ?? false,
playback: change.playback ?? current?.playback ?? 'MUSIC_PLAYBACK_STOPPED',
})
music.value = res.state
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось изменить музыку')
}
}
async function createPlaylist(name: string) {
try {
await client.CreatePlaylist({ name })
await getGameMusic()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось создать плейлист')
}
}
async function deletePlaylist(id: number) {
try {
await client.DeletePlaylist({ ID: id })
await getGameMusic()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось удалить плейлист')
}
}
async function uploadTrack(file: File) {
const playlistID = music.value?.playlist?.ID
if (!playlistID) {
message.warning('Сначала выберите плейлист')
return
}
try {
const data = uint8ToBase64(new Uint8Array(await file.arrayBuffer()))
await client.UploadPlaylistTrack({ playlistID, name: file.name, filename: file.name, data })
await getGameMusic()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось загрузить MP3')
}
}
async function deleteTrack(id: number) {
const playlistID = music.value?.playlist?.ID
if (!playlistID) return
try {
await client.DeletePlaylistTrack({ playlistID, trackID: id })
await getGameMusic()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось удалить трек')
}
}
function uint8ToBase64(data: Uint8Array): string {
let binary = ''
for (let i = 0; i < data.length; i += 0x8000) binary += String.fromCharCode(...data.subarray(i, i + 0x8000))
return btoa(binary)
}
async function unbindScreen(screen: ScreenState) {
await client.UnbindScreen({ screenID: screen.ID })
await getGameScreens()
}
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 downloadTeamQR() {
if (isDownloadingQR.value || !game.value.teams?.length) return
isDownloadingQR.value = true
try {
const { blob, filename } = await binaryClient.downloadTeamQR(game.value.ID!)
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось скачать QR команд')
} finally {
isDownloadingQR.value = false
}
}
function toResults() {
router.push('/games/' + gameId + '/results')
}
async function updateGame() {
await client.UpdateGame({
ID: game.value.ID,
name: settingsDraft.value.name,
description: settingsDraft.value.description,
format: settingsDraft.value.format,
location: settingsDraft.value.location.trim(),
startAt: game.value.startAt,
scenarioID: game.value.scenario?.ID,
})
await getGame(game.value.ID!)
}
const giveApplicationTeam = ref<Team>({
ID: 0,
name: '',
status: '',
password: '',
actionsCount: 0,
applications: [],
captainEmail: undefined,
participantsCount: undefined,
participationStatus: undefined,
participationSource: undefined,
participationEmailError: undefined,
participationEmailSent: undefined,
})
const deletedTeamName = ref('')
const deletedTeam = ref<Team>({
ID: 0,
name: '',
status: '',
password: '',
actionsCount: 0,
applications: [],
captainEmail: undefined,
participantsCount: undefined,
participationStatus: undefined,
participationSource: undefined,
participationEmailError: undefined,
participationEmailSent: undefined,
})
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() {
if (newTeamParticipants.value < 1 || newTeamParticipants.value > 8) {
message.error('Укажите от 1 до 8 игроков')
return
}
await client.AddTeam({
gameID: game.value.ID,
name: newTeamName.value,
captainEmail: newTeamEmail.value.trim(),
participantsCount: newTeamParticipants.value,
})
await getGame(game.value.ID!)
showAddTeamModal.value = false
newTeamEmail.value = ''
newTeamParticipants.value = 1
}
async function confirmGiveApplication() {
await client.GiveTeamApplications({
ID: giveApplicationTeam.value.ID,
name: giveApplicationApplication.value.name,
})
await getGame(game.value.ID!)
showGiveApplicationModal.value = false
}
async function deleteTeam(team: Team) {
deletedTeam.value = team
showDeleteTeamModal.value = true
}
async function confirmDeleteTeam() {
await client.DeleteTeam({
ID: deletedTeam.value.ID,
})
await getGame(game.value.ID!)
showDeleteTeamModal.value = false
}
async function deleteGame() {
await client.DeleteGame({
ID: game.value.ID,
})
message.success('Игра удалена')
router.push('/games')
}
function getTeamStoryURL(id: number, password: string): string {
const url = new URL(`/team-story/${id}`, window.location.origin)
url.searchParams.set('password', password)
return url.toString()
}
function openTeamQR(team: Team) {
openQR(getTeamStoryURL(team.ID!, team.password!), 'Подсмотреть игру')
}
async function openQR(url: string, text: string) {
urlQR.value = url
titleQR.value = text
showQR.value = true
}
async function issueTickets() {
if (isIssuingTickets.value || !game.value.ID) return
isIssuingTickets.value = true
try {
const res = await client.IssueGameTickets({
gameID: game.value.ID,
count: ticketCount.value,
})
issuedTickets.value = res.tickets || []
message.success(`Выпущено билетов: ${issuedTickets.value.length}`)
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось выпустить билеты')
} finally {
isIssuingTickets.value = false
}
}
useSimplePolling(() => {
getGame(Number(gameId))
getGameScreens()
getGameMusic()
getScreenPlan()
getGameMap()
}, 2000)
async function resetGame() {
await client.ResetGame({
ID: game.value.ID,
})
await getGame(game.value.ID!)
}
async function startGame() {
await client.StartGame({
ID: game.value.ID,
})
await getGame(game.value.ID!)
}
async function pauseGame() {
await client.PauseGame({
ID: game.value.ID,
})
await getGame(game.value.ID!)
}
async function playGame() {
await client.PlayGame({
ID: game.value.ID,
})
await getGame(game.value.ID!)
}
async function finishGame() {
await client.FinishGame({
ID: game.value.ID,
})
await getGame(game.value.ID!)
}
// ===== Ответы команд и оценка организатором =====
const showReviewsModal = ref(false)
const gameReviews = ref<GameReview[]>([])
const reviewsAverage = ref(0)
const reviewsCount = ref(0)
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) })
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 openReviewsModal() {
showReviewsModal.value = true
const res = await client.GetGameReviewsForModeration({ gameID: Number(gameId) })
gameReviews.value = res.reviews || []
reviewsAverage.value = res.averageRating || 0
reviewsCount.value = res.reviewsCount || 0
}
async function moderateReview(review: GameReview, status: string) {
await client.ModerateGameReview({ ID: review.ID, status })
await openReviewsModal()
}
// Сохраняет баллы всех команд за все вопросы; пустой балл сбрасывает его.
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)]
await client.SetTeamAnswerScore({
ID: team.team?.ID,
questionCode: question.code,
score: score === undefined || score === null ? undefined : score,
})
}
}
message.success('Баллы сохранены')
await loadGameAnswers()
}
if (activeTab.value === 'answers') {
loadGameAnswers()
}
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">
<UiBlock v-if="game.export?.state === 'preparing' || game.export?.state === 'ready'">
<n-alert
type="info"
:title="game.export.state === 'ready' ? 'Игра в офлайн-режиме' : 'Подготовка офлайн-пакета'"
>
{{
game.export.state === 'ready'
? 'Изменения на онлайн-сервере не синхронизируются с офлайн-площадкой.'
: 'Создаётся пакет для офлайн-площадки.'
}}
</n-alert>
</UiBlock>
<n-tabs :value="activeTab" type="line" animated @update:value="setActiveTab">
<n-tab-pane name="settings" tab="Настройки">
<div class="game-tab-content">
<UiBlock>
<n-flex justify="end">
<n-button quaternary @click="openQR(url, 'Админка')">QR</n-button>
<n-button v-if="game.status == 'finish'" quaternary @click="toResults()">
<Icon class="settings-icon">
<Trophy />
</Icon>
Итоги
</n-button>
<n-button
v-if="(game.teams?.length || 0) == 0"
type="error"
quaternary
@click="showDeleteGameModal = true"
>
Удалить игру
</n-button>
</n-flex>
</UiBlock>
<UiBlock>
<p class="screens-title">Настройки игры</p>
<n-space vertical>
<p>Название</p>
<n-input
v-model:value="settingsDraft.name"
type="text"
placeholder='Дело №0 "Следствие ведут овечки"'
/>
<p class="settings-header">Формат игры</p>
<n-select
v-model:value="settingsDraft.format"
:options="[
{ label: 'Online', value: 'online' },
{ label: 'Offline', value: 'offline' },
]"
aria-label="Формат игры"
/>
<p class="settings-header">Описание</p>
<n-input
v-model:value="settingsDraft.description"
type="textarea"
placeholder="Убийство пастуха..."
/>
<p class="settings-header">Место проведения</p>
<n-input
v-model:value="settingsDraft.location"
type="text"
placeholder="Оставьте пустым для онлайн-игры"
/>
</n-space>
<n-flex justify="end" class="settings-actions">
<n-button @click="updateGame()">Сохранить изменения</n-button>
</n-flex>
</UiBlock>
<UiBlock>
<p class="screens-title">Выпуск билетов</p>
<n-space vertical>
<n-input-number v-model:value="ticketCount" :min="1" :max="100" />
<div v-for="ticket in issuedTickets" :key="ticket.ID" class="issued-ticket">
<div>Билет №{{ ticket.ID }}</div>
<a
:href="ticket.URL"
class="payment-link"
target="_blank"
rel="noreferrer"
>
Открыть страницу билета
</a>
<n-button size="small" @click="openQR(ticket.URL!, `Билет №${ticket.ID}`)">
Показать QR
</n-button>
</div>
</n-space>
<n-flex justify="end" class="settings-actions">
<n-button
:loading="isIssuingTickets"
:disabled="isIssuingTickets"
@click="issueTickets()"
>
Выпустить
</n-button>
</n-flex>
</UiBlock>
</div>
</n-tab-pane>
<n-tab-pane name="teams" tab="Команды">
<div class="game-tab-content">
<n-flex justify="space-between" align="center">
<n-tag :bordered="false" type="info"> Участвуют команд: {{ activeTeamCount }} </n-tag>
<n-button
v-if="activeTeamCount"
quaternary
:loading="isDownloadingQR"
:disabled="isDownloadingQR"
@click="downloadTeamQR()"
>
QR команд
</n-button>
</n-flex>
<GameTeamsPanel
:teams="game.teams || []"
:participation-options="participationOptions"
@add="showAddTeamModal = true"
@open-qr="openTeamQR"
@preview="(id, password) => toTeamStory(id, password, true)"
@delete="deleteTeam"
@give-application="giveApplication"
/>
</div>
</n-tab-pane>
<n-tab-pane name="screens" tab="Экраны">
<div class="game-tab-content">
<GameScreensPanel
:screens="screens"
:screen-binding="screenBinding"
:playlists="playlists"
:music="music"
@bind="bindScannedScreen"
@set-sound="setSoundScreen"
@unbind="unbindScreen"
@set-music="setGameMusic"
@create-playlist="createPlaylist"
@delete-playlist="deletePlaylist"
@upload-track="uploadTrack"
@delete-track="deleteTrack"
/>
</div>
</n-tab-pane>
<n-tab-pane name="game" tab="Игра">
<div class="game-tab-content">
<UiBlock>
<n-flex justify="space-between">
<n-space>
<n-tag type="info">
{{ timeString }}
</n-tag>
<n-tag v-if="formatGameDateTime(game.startAt)" type="info">
Начало: {{ formatGameDateTime(game.startAt) }}
</n-tag>
</n-space>
<n-flex justify="end">
<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-flex>
</n-flex>
</UiBlock>
<GameMapPanel
:map-items="mapItems"
:teams="game.teams || []"
:screens="screens"
:preview-questions="previewQuestions"
:preview-teams="previewTeams"
:screen-plan="screenPlan"
:screen-plan-draft="screenPlanDraft"
:screen-plan-dirty="screenPlanDirty"
:screen-plan-saving="screenPlanSaving"
:screen-plan-mode="screenPlanMode"
:screen-volume="screenVolume"
:screen-position="screenPosition"
@save-item="saveMapItemAndReload"
@dragging="mapDragging = $event"
@give-application="giveApplication"
@add-step="addScreenPlanStep"
@edit="editScreenPlan"
@cancel="cancelScreenPlanEdit"
@remove-step="removeScreenPlanStep"
@move-step="moveScreenPlanStep"
@dirty="markScreenPlanDirty"
@clear-plan="clearScreenPlan"
@save="saveScreenPlan"
@set-step="setScreenPlanStep"
@command="sendScreenPlanCommand"
@update:screen-volume="screenVolume = $event ?? 0"
@update:screen-position="screenPosition = $event ?? 0"
/>
</div>
</n-tab-pane>
<n-tab-pane name="payment" tab="Оплата">
<div class="game-tab-content">
<UiBlock>
<n-flex align="center" justify="space-between">
<p class="screens-title">Билеты</p>
<n-button size="small" @click="showTicketCheckModal = true">
Проверить билет
</n-button>
</n-flex>
</UiBlock>
<UiBlock>
<p class="screens-title">Оплата игры</p>
<div class="qr-block">
<n-qr-code :value="paymentURL" :size="300" :padding="0" />
</div>
<p>
<a
:href="paymentURL"
class="payment-link"
target="_blank"
rel="noreferrer"
>
Открыть ссылку оплаты
</a>
</p>
</UiBlock>
</div>
</n-tab-pane>
<n-tab-pane name="answers" tab="Проверка ответов">
<div class="game-tab-content">
<div v-if="answersQuestions.length == 0" class="answers-empty">
Вопросы дела не заданы или команды ещё не отвечали.
</div>
<UiBlock v-for="question in answersQuestions" :key="question.code">
<p class="answers-question-title">{{ question.code }}: {{ question.text }}</p>
<p class="answers-question-answer">
Правильный ответ:<br /><span class="correct-answer">{{ question.answer || '—' }}</span>
</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>
</UiBlock>
<n-flex justify="end">
<n-button @click="saveAllScores()" :disabled="answersQuestions.length == 0">
Сохранить баллы
</n-button>
</n-flex>
</div>
</n-tab-pane>
</n-tabs>
</div>
</div>
<TicketCheckModal v-model:show="showTicketCheckModal" />
<!-- Окно выдачи улик -->
<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-space vertical>
<n-input v-model:value="newTeamName" type="text" placeholder="Три дебила" />
<n-input
v-model:value="newTeamEmail"
:input-props="{ type: 'email', autocomplete: 'email' }"
placeholder="Почта капитана (необязательно)"
aria-label="Почта капитана (необязательно)"
/>
<n-input-number
v-model:value="newTeamParticipants"
:min="1"
:max="8"
:precision="0"
placeholder="Количество игроков"
aria-label="Количество игроков"
/>
</n-space>
<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>
<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="showReviewsModal">
<n-card
style="width: min(94vw, 800px)"
title="Отзывы игры"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
:content-style="{ maxHeight: '70vh', overflow: 'auto' }"
>
<template #header-extra>
<n-button @click="showReviewsModal = false"> x </n-button>
</template>
<n-flex align="center" class="reviews-summary">
<n-rate :value="reviewsAverage" readonly allow-half />
<span>{{
reviewsCount ? `${reviewsAverage.toFixed(1)} · ${reviewsCount}` : 'Пока нет отзывов'
}}</span>
</n-flex>
<div v-if="gameReviews.length == 0" class="answers-empty">Отзывов пока нет.</div>
<div v-for="review in gameReviews" :key="review.ID" class="review-row">
<n-flex justify="space-between" align="center">
<strong>{{ review.teamName }}</strong>
<n-tag>{{ review.status }}</n-tag>
</n-flex>
<n-rate :value="review.rating" readonly size="small" />
<div>Сложность: {{ ['', 'лёгкая', 'средняя', 'тяжёлая'][review.difficulty || 0] }}</div>
<p>{{ review.text }}</p>
<n-flex v-if="review.status == 'pending'" justify="end">
<n-button type="success" @click="moderateReview(review, 'approved')">Одобрить</n-button>
<n-button type="error" ghost @click="moderateReview(review, 'rejected')"
>Отклонить</n-button
>
</n-flex>
<n-flex v-else-if="review.status == 'approved'" justify="end">
<n-button type="error" ghost @click="moderateReview(review, 'hidden')">Скрыть</n-button>
</n-flex>
</div>
</n-card>
</n-modal>
<HeaderMenu active="games" />
</template>
<style scoped>
.reviews-summary {
margin-bottom: 16px;
}
.game-tab-content {
padding-top: 0;
}
.review-row {
border-top: 1px solid #777;
padding: 16px 0;
}
.review-row p {
white-space: pre-wrap;
}
.team-block-hover:hover {
border: 1px solid #63e2b7;
color: #63e2b7;
cursor: pointer;
}
.team-content-block {
padding: 20px;
}
.team-name-block {
font-size: 20px;
font-weight: 600;
}
.settings-icon {
width: 20px;
}
.settings-actions {
margin-top: 16px;
}
.link-button {
margin-top: 16px;
width: 100%;
}
.place-image-plus {
align-items: center;
display: flex;
font-size: 30px;
justify-content: center;
}
.name-text {
background-color: rgb(255 255 255 / 10%);
border-radius: 3px;
padding: 3px;
}
.qr-block {
background-color: white;
display: inline-block;
line-height: 0;
margin: 0 auto;
padding: 16px;
}
.payment-link {
color: #63e2b7;
}
.payment-link:focus,
.payment-link:hover {
color: #8ff0cc;
}
.give-application-image-block {
background-image: url('@/assets/images/paper_white.jpg');
background-size: cover;
box-shadow: 0 3px 15px rgb(98 98 98);
margin: 8px 0;
max-width: 320px;
padding: 8px;
}
.give-application-image {
width: 100%;
}
.give-application-audio {
margin: 8px 0;
width: 100%;
}
.give-application-pdf-link {
display: inline-block;
margin: 8px 0;
text-decoration: underline;
}
.answers-empty {
color: #777;
}
.answers-question-title {
font-size: 17px;
font-weight: 600;
margin: 0 0 8px;
}
.answers-question-answer {
color: #aaa;
margin: 0 0 16px;
}
.correct-answer {
color: gold;
display: inline-block;
font-weight: 500;
margin-top: 8px;
white-space: pre-wrap;
}
.answers-team-row {
align-items: center;
background-color: rgb(255 255 255 / 5%);
border-radius: 6px;
display: flex;
flex-flow: row wrap;
gap: 8px;
margin: 8px 0;
padding: 8px;
}
.answers-team-name {
font-weight: 600;
width: 180px;
}
.answers-team-answer {
color: #aaa;
flex: 1 1 200px;
white-space: pre-wrap;
}
.answers-score-input {
width: 110px;
}
.screens-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.screen-row {
align-items: center;
border-top: 1px solid rgb(255 255 255 / 10%);
display: flex;
gap: 8px;
justify-content: space-between;
padding: 16px 0;
}
.screen-status {
color: #aaa;
font-size: 13px;
}
.screen-empty {
color: #aaa;
margin: 16px 0 0;
}
.screen-error {
color: #f2a2a2;
font-size: 13px;
}
.map-preview-layout {
display: grid;
gap: 16px;
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
}
.screen-preview-placeholder {
align-items: center;
aspect-ratio: 16 / 9;
background-color: #111;
color: #aaa;
display: flex;
justify-content: center;
text-align: center;
}
.map-field {
background-color: #151515;
border: 1px dashed rgb(255 255 255 / 20%);
cursor: grab;
height: 420px;
min-height: 200px;
overflow: hidden;
position: relative;
resize: vertical;
touch-action: none;
}
.map-field:active {
cursor: grabbing;
}
.map-canvas {
background-image:
linear-gradient(rgb(255 255 255 / 8%) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 8%) 1px, transparent 1px);
background-size: 20px 20px;
height: 1000px;
position: absolute;
transform-origin: 0 0;
width: 2000px;
}
.map-item {
border-radius: 6px;
cursor: grab;
min-width: 90px;
padding: 8px 24px 8px 8px;
position: absolute;
user-select: none;
}
.map-item:active {
cursor: grabbing;
}
.map-screen {
background-color: #355b75;
}
.map-organizer-desk {
background-color: #48633a;
}
.map-team {
background-color: #222;
}
.map-team,
.map-team:active {
cursor: default;
}
.map-team-handle {
background-color: #48633a;
border-radius: 6px 6px 0 0;
cursor: grab;
margin: -8px -24px 8px -8px;
padding: 8px 24px 8px 10px;
}
.map-team-handle:active {
cursor: grabbing;
}
.map-team-applications {
margin-top: 8px;
}
.map-team-needs-applications {
outline: 2px solid gold;
}
.map-item small {
display: block;
opacity: 0.75;
}
.map-item-hide {
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
font-size: 16px;
position: absolute;
right: 5px;
top: 5px;
}
.map-team .map-item-hide {
line-height: 16px;
padding: 0 2px;
top: 0;
}
.map-hidden-items {
margin-top: 16px;
}
@media (width < 1024px) {
.map-preview-layout {
grid-template-columns: 1fr;
}
}
.issued-ticket {
align-items: center;
border-top: 1px solid #777;
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-top: 8px;
}
</style>