Files
evening_detective_frontend/src/components/GamePage.vue
T
2026-09-16 04:28:47 +07:00

1473 lines
41 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,
NSelect,
NSpace,
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,
MapItem,
Question,
ScreenCommand,
ScreenMode,
ScreenState,
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'
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
const message = useMessage()
const showSettingsModal = ref(false)
const settingsDraft = ref({ name: '', description: '', format: 'online' })
const showGiveApplicationModal = ref(false)
const showAddTeamModal = ref(false)
const showDeleteTeamModal = ref(false)
const showDeleteGameModal = ref(false)
const newTeamName = ref('')
const newTeamEmail = 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,
format: 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 teamsNeedingApplications = computed(
() =>
new Map(
(game.value.teams || [])
.filter((team) => team.applications?.length)
.map((team) => [team.id, team]),
),
)
const screens = ref<ScreenState[]>([])
const mapItems = ref<MapItem[]>([])
const mapSaving = ref(false)
const screenVolume = ref(100)
const screenPosition = ref(0)
const currentScreenMode = computed<ScreenMode | undefined>(() => {
const modes = screens.value.map((screen) => screen.mode).filter(Boolean)
if (!modes.length || modes.some((mode) => mode !== modes[0])) return undefined
return modes[0] as ScreenMode
})
const mapScale = ref(1)
const mapOffset = ref({ x: 0, y: 0 })
let dragState:
{ item: MapItem; startX: number; startY: number; itemX: number; itemY: number } | undefined
let panState: { startX: number; startY: number; offsetX: number; offsetY: number } | 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 getGameScreens() {
const res = await client.GetGameScreens({ gameId: Number(gameId) })
if (res.error != '') {
return
}
screens.value = res.screens || []
}
getGameScreens()
async function getGameMap() {
if (dragState || mapSaving.value) return
const res = await client.GetGameMap({ gameId: Number(gameId) })
if (res.error == '') mapItems.value = res.items || []
}
getGameMap()
async function saveMapItem(item: MapItem, hidden = item.hidden) {
mapSaving.value = true
try {
const res = 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,
})
if (res.error != '') message.error(res.error || 'Не удалось сохранить координаты')
} finally {
mapSaving.value = false
}
}
function startMapDrag(item: MapItem, event: PointerEvent) {
dragState = {
item,
startX: event.clientX,
startY: event.clientY,
itemX: item.x || 0,
itemY: item.y || 0,
}
window.addEventListener('pointermove', moveMapDrag)
window.addEventListener('pointerup', endMapDrag, { once: true })
}
function moveMapDrag(event: PointerEvent) {
if (!dragState) return
const step = dragState.item.kind === 'team' ? 20 : 1
dragState.item.x =
Math.round((dragState.itemX + (event.clientX - dragState.startX) / mapScale.value) / step) *
step
dragState.item.y =
Math.round((dragState.itemY + (event.clientY - dragState.startY) / mapScale.value) / step) *
step
}
async function endMapDrag() {
if (!dragState) return
const item = dragState.item
dragState = undefined
window.removeEventListener('pointermove', moveMapDrag)
await saveMapItem(item)
await getGameMap()
}
async function toggleMapItem(item: MapItem) {
item.hidden = !item.hidden
await saveMapItem(item)
}
function changeMapScale(delta: number) {
mapScale.value = Math.min(2, Math.max(0.5, Number((mapScale.value + delta).toFixed(2))))
}
function resetMapView() {
mapScale.value = 1
mapOffset.value = { x: 0, y: 0 }
}
function startMapPan(event: PointerEvent) {
if (event.button !== 0) return
panState = {
startX: event.clientX,
startY: event.clientY,
offsetX: mapOffset.value.x,
offsetY: mapOffset.value.y,
}
window.addEventListener('pointermove', moveMapPan)
window.addEventListener('pointerup', endMapPan, { once: true })
}
function moveMapPan(event: PointerEvent) {
if (!panState) return
mapOffset.value = {
x: panState.offsetX + event.clientX - panState.startX,
y: panState.offsetY + event.clientY - panState.startY,
}
}
function endMapPan() {
panState = undefined
window.removeEventListener('pointermove', moveMapPan)
}
function zoomMap(event: WheelEvent) {
event.preventDefault()
changeMapScale(event.deltaY < 0 ? 0.1 : -0.1)
}
async function setSoundScreen(screen: ScreenState) {
const res = await client.SetScreenSound({ screenId: screen.id, soundEnabled: true })
if (res.error != '') {
message.error(res.error!)
return
}
await getGameScreens()
}
async function unbindScreen(screen: ScreenState) {
const res = await client.UnbindScreen({ screenId: screen.id })
if (res.error != '') {
message.error(res.error!)
return
}
await getGameScreens()
}
async function setScreenMode(mode: ScreenMode) {
const res = await client.SetScreenMode({ gameId: Number(gameId), mode })
if (res.error != '') message.error(res.error || 'Не удалось изменить режим экранов')
}
async function sendScreenCommand(command: ScreenCommand) {
if (command === 'SCREEN_COMMAND_PLAY' || command === 'SCREEN_COMMAND_REPLAY') {
if (!window.confirm('Запустить медиа на всех экранах?')) return
}
const res = 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))),
})
if (res.error != '') message.error(res.error || 'Не удалось выполнить команду экранов')
}
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',
}
showSettingsModal.value = true
}
function closeSettings() {
showSettingsModal.value = false
}
async function updateGame() {
const res = await client.UpdateGame({
id: game.value.id,
name: settingsDraft.value.name,
description: settingsDraft.value.description,
format: settingsDraft.value.format,
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: [],
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() {
const res = await client.AddTeam({
gameId: game.value.id,
name: newTeamName.value,
captainEmail: newTeamEmail.value.trim(),
})
if (res.error != '') {
message.error(res.error!)
return
}
await getGame(game.value.id!)
showAddTeamModal.value = false
newTeamEmail.value = ''
}
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))
getGameScreens()
getGameMap()
}, 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 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"> Участвуют команд: {{ 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="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 v-if="screens.length > 0" class="screens-block">
<p class="screens-title">Экраны</p>
<div v-for="screen in screens" :key="screen.id" class="screen-row">
<div>
<div>Экран {{ screen.id }}</div>
<div class="screen-status">{{ screen.status }}</div>
<div v-if="screen.error" class="screen-error">{{ screen.error }}</div>
</div>
<n-flex justify="end">
<n-tag v-if="screen.soundEnabled" type="success">Со звуком</n-tag>
<n-button v-else size="small" @click="setSoundScreen(screen)">Назначить звук</n-button>
<n-button size="small" type="error" ghost @click="unbindScreen(screen)"
>Отвязать</n-button
>
</n-flex>
</div>
<n-space class="screen-mode-controls">
<n-button
size="small"
:type="currentScreenMode === 'SCREEN_MODE_COVER' ? 'primary' : 'default'"
@click="setScreenMode('SCREEN_MODE_COVER')"
>Обложка</n-button
>
<n-button
size="small"
:type="currentScreenMode === 'SCREEN_MODE_INTRO' ? 'primary' : 'default'"
@click="setScreenMode('SCREEN_MODE_INTRO')"
>Введение</n-button
>
<n-button
size="small"
:type="currentScreenMode === 'SCREEN_MODE_INTRO_DONE' ? 'primary' : 'default'"
@click="setScreenMode('SCREEN_MODE_INTRO_DONE')"
>Успехов</n-button
>
<n-button
size="small"
:type="currentScreenMode === 'SCREEN_MODE_CONCLUSION' ? 'primary' : 'default'"
@click="setScreenMode('SCREEN_MODE_CONCLUSION')"
>Закрывашка</n-button
>
<n-button
size="small"
:type="currentScreenMode === 'SCREEN_MODE_CONCLUSION_DONE' ? 'primary' : 'default'"
@click="setScreenMode('SCREEN_MODE_CONCLUSION_DONE')"
>Спасибо за игру!</n-button
>
<n-button
v-if="game.status === 'finish'"
size="small"
:type="currentScreenMode === 'SCREEN_MODE_RESULTS' ? 'primary' : 'default'"
@click="setScreenMode('SCREEN_MODE_RESULTS')"
>Результаты</n-button
>
</n-space>
<n-space class="screen-mode-controls">
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_PLAY')">Запуск</n-button>
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_PAUSE')">Пауза</n-button>
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_RESUME')"
>Продолжить</n-button
>
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_STOP')">Стоп</n-button>
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_REPLAY')"
>Повтор</n-button
>
</n-space>
<n-space class="screen-mode-controls" align="center">
<n-text>Громкость</n-text>
<n-input-number v-model:value="screenVolume" :min="0" :max="100" :step="5" size="small" />
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_VOLUME')"
>Применить</n-button
>
<n-text>Позиция, сек.</n-text>
<n-input-number v-model:value="screenPosition" :min="0" :step="5" size="small" />
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_SEEK')"
>Перемотать</n-button
>
</n-space>
</div>
<div class="map-block">
<n-flex align="center" justify="space-between">
<p class="screens-title">Карта помещения</p>
<n-space>
<n-button size="small" @click="changeMapScale(-0.1)">−</n-button>
<n-text>{{ Math.round(mapScale * 100) }}%</n-text>
<n-button size="small" @click="changeMapScale(0.1)">+</n-button>
<n-button size="small" @click="resetMapView">Сбросить</n-button>
</n-space>
</n-flex>
<div class="map-field" @pointerdown="startMapPan" @wheel="zoomMap">
<div
class="map-canvas"
:style="{
transform: `translate(${mapOffset.x}px, ${mapOffset.y}px) scale(${mapScale})`,
}"
>
<div
v-for="item in mapItems"
v-show="!item.hidden"
:key="`${item.kind}-${item.itemId}`"
class="map-item"
:class="[
item.kind === 'screen' ? 'map-screen' : 'map-team',
{
'map-team-needs-applications':
item.kind === 'team' && teamsNeedingApplications.has(item.itemId),
},
]"
:style="{ left: `${item.x || 0}px`, top: `${item.y || 0}px` }"
@pointerdown.stop="item.kind === 'screen' && startMapDrag(item, $event)"
>
<div
v-if="item.kind === 'team'"
class="map-team-handle"
@pointerdown.stop="startMapDrag(item, $event)"
/>
<span>{{ item.label }}</span>
<n-space
v-if="item.kind === 'team' && teamsNeedingApplications.has(item.itemId)"
class="map-team-applications"
vertical
>
<n-button
v-for="application in teamsNeedingApplications.get(item.itemId)?.applications"
:key="application.name"
size="small"
@click="giveApplication(teamsNeedingApplications.get(item.itemId)!, application)"
>
Выдать: {{ application.name }}
</n-button>
</n-space>
<small v-if="item.kind === 'screen'"
>{{ item.status }}{{ item.soundEnabled ? ' · звук' : '' }}</small
>
<button
class="map-item-hide"
type="button"
@pointerdown.stop
@click.stop="toggleMapItem(item)"
>
×
</button>
</div>
</div>
</div>
<n-space v-if="mapItems.some((item) => item.hidden)" class="map-hidden-items">
<n-button
v-for="item in mapItems.filter((item) => item.hidden)"
:key="`hidden-${item.kind}-${item.itemId}`"
size="small"
@click="toggleMapItem(item)"
>
Добавить {{ item.label }}
</n-button>
</n-space>
</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 }}
<small v-if="team.captainEmail"> — {{ team.captainEmail }}</small>
<small v-if="team.participantsCount">
· Участников: {{ team.participantsCount }}</small
>
</span>
<div v-if="team.captainEmail || team.participationStatus !== 'pending'">
{{
participationOptions.find((option) => option.value === team.participationStatus)
?.label
}}<span v-if="team.participationSource === 'automatic'"> · автоматически</span>
</div>
<div v-if="!team.captainEmail">Почта капитана не указана</div>
<div v-else-if="team.participationEmailError">
Ошибка отправки: {{ team.participationEmailError }}
</div>
<div v-else-if="team.participationEmailSent">Письмо отправлено</div>
<div v-if="team.participationStatus !== 'cancelled'">
Поездки: {{ team.actionsCount }}
</div>
</div>
<n-flex justify="end">
<n-button
v-if="team.participationStatus !== 'cancelled'"
@click="openQR(getTeamStoryURL(team.id!, team.password!), 'Подсмотреть игру')"
>
QR
</n-button>
<n-button
v-if="team.participationStatus !== 'cancelled'"
@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="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="Убийство пастуха..."
/>
</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-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="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">
Правильный ответ:<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>
</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 {
background-color: #222;
border: 1px solid #222;
border-radius: 10px;
margin: 10px 0;
}
.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: 20px;
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: 12px;
}
.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: 10px 0 5px;
max-width: 320px;
padding: 7px;
}
.give-application-image {
width: 100%;
}
.give-application-audio {
margin: 10px 0 5px;
width: 100%;
}
.give-application-pdf-link {
display: inline-block;
margin: 10px 0 5px;
text-decoration: underline;
}
.answers-empty {
color: #777;
}
.answers-question-block {
background-color: #222;
border-radius: 8px;
margin: 15px 0;
padding: 12px;
}
.answers-question-title {
font-size: 17px;
font-weight: 600;
margin: 0 0 4px;
}
.answers-question-answer {
color: #aaa;
margin: 0 0 10px;
}
.correct-answer {
color: gold;
display: inline-block;
font-weight: 500;
margin-top: 4px;
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: 10px;
margin: 8px 0;
padding: 8px 10px;
}
.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-block {
background-color: #222;
border-radius: 10px;
margin: 12px 0;
padding: 16px;
}
.screens-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
}
.screen-row {
align-items: center;
border-top: 1px solid rgb(255 255 255 / 10%);
display: flex;
gap: 12px;
justify-content: space-between;
padding: 12px 0;
}
.screen-status {
color: #aaa;
font-size: 13px;
}
.screen-error {
color: #f2a2a2;
font-size: 13px;
}
.screen-mode-controls {
margin-top: 12px;
}
.map-block {
background-color: #222;
border-radius: 10px;
margin: 12px 0;
padding: 16px;
}
.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 10px;
position: absolute;
user-select: none;
}
.map-item:active {
cursor: grabbing;
}
.map-screen {
background-color: #355b75;
}
.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 -10px;
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: 10px;
}
</style>