Files
evening_detective_frontend/src/components/GamePage.vue
T
2026-10-04 23:46:57 +07:00

1512 lines
42 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, Trophy } from '@vicons/carbon'
import { Icon } from '@vicons/utils'
import { 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,
Question,
ResultsQuestion,
ScreenCommand,
ScreenMode,
ScreenPlan,
ScreenPlanStep,
ScreenState,
Team,
TeamAnswers,
TeamResult,
Ticket,
} from '@/api/generated/crabs/evening_detective_server'
import { getURL } from '@/api/generated/crabs/evening_detective_server/client'
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 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 = 'game' | 'teams' | 'screens-map'
function isGameTab(value: string): value is GameTab {
return ['game', 'teams', 'screens-map'].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 } })
}
const message = useMessage()
const showSettingsModal = ref(false)
const settingsDraft = ref({ name: '', description: '', format: 'online', location: '' })
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 showIssueTicketsModal = ref(false)
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,
})
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 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 screenVolume = ref(100)
const screenPosition = ref(0)
async function getGame(id: number) {
const res = await client.GetGame({ ID: id })
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 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()
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) return
const res = await client.GetGameMap({ gameID: Number(gameId) })
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 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')
}
function openSettings() {
settingsDraft.value = {
name: game.value.name || '',
description: game.value.description || '',
format: game.value.format || 'online',
location: game.value.location || '',
}
showSettingsModal.value = true
}
function closeSettings() {
showSettingsModal.value = false
}
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!)
showSettingsModal.value = false
}
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 {
return getURL() + '/team-story/' + id + '?password=' + password
}
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
}
function openIssueTickets() {
ticketCount.value = 1
issuedTickets.value = []
showIssueTicketsModal.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()
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 showAnswersModal = ref(false)
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 openAnswersModal() {
showAnswersModal.value = true
await loadGameAnswers()
}
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()
}
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">
<n-tabs :value="activeTab" type="line" animated @update:value="setActiveTab">
<n-tab-pane name="game" tab="Игра">
<div class="game-tab-content">
<div class="settings-block">
<n-flex justify="space-between">
<n-space>
<n-tag :bordered="false" type="info">
Участвуют команд: {{ activeTeamCount }}
</n-tag>
<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 quaternary @click="openQR(url, 'Админка')"> QR </n-button>
<n-button quaternary @click="openIssueTickets()"> Выпустить билеты </n-button>
<n-button quaternary @click="openAnswersModal()"> Ответы команд </n-button>
<n-button v-if="game.status == 'finish'" quaternary @click="toResults()">
<Icon class="settings-icon">
<Trophy />
</Icon>
Итоги
</n-button>
<n-button
v-if="activeTeamCount"
quaternary
:loading="isDownloadingQR"
:disabled="isDownloadingQR"
@click="downloadTeamQR()"
>
QR команд
</n-button>
<n-button quaternary @click="openSettings()">
<Icon class="settings-icon">
<Settings />
</Icon>
Настройки
</n-button>
<n-button
v-if="(game.teams?.length || 0) == 0"
type="error"
quaternary
@click="showDeleteGameModal = true"
>
Удалить игру
</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>
</n-flex>
</n-flex>
</div>
</div>
</n-tab-pane>
<n-tab-pane name="teams" tab="Команды">
<div class="game-tab-content">
<n-tag :bordered="false" type="info"> Участвуют команд: {{ activeTeamCount }} </n-tag>
<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-map" tab="Экраны и карта">
<div class="game-tab-content">
<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"
@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"
/>
<GameScreensPanel
:screens="screens"
:screen-binding="screenBinding"
@bind="bindScannedScreen"
@set-sound="setSoundScreen"
@unbind="unbindScreen"
/>
</div>
</n-tab-pane>
</n-tabs>
</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="closeSettings()"> x </n-button>
</template>
<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>
<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-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>
<!-- Окно qr -->
<n-modal v-model:show="showIssueTicketsModal">
<n-card
style="width: min(94vw, 700px)"
title="Выпуск билетов"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<template #header-extra>
<n-button @click="showIssueTicketsModal = false"> x </n-button>
</template>
<n-space vertical>
<n-input-number v-model:value="ticketCount" :min="1" :max="100" />
<n-button
type="primary"
:loading="isIssuingTickets"
:disabled="isIssuingTickets"
@click="issueTickets()"
>
Выпустить
</n-button>
<div v-for="ticket in issuedTickets" :key="ticket.ID" class="issued-ticket">
<div>Билет №{{ ticket.ID }}</div>
<a :href="ticket.URL" target="_blank" rel="noreferrer">Открыть страницу билета</a>
<n-button size="small" @click="openQR(ticket.URL!, `Билет №${ticket.ID}`)">
Показать QR
</n-button>
</div>
</n-space>
</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="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>
<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>
<template #footer>
<n-flex justify="end">
<n-button @click="saveAllScores()" :disabled="answersQuestions.length == 0">
Сохранить баллы
</n-button>
</n-flex>
</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;
}
.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;
}
.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>