Files
evening_detective_frontend/src/components/GamePage.vue
T
2026-09-13 01:02:52 +07:00

1158 lines
35 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 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 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
dragState.item.x = Math.round(dragState.itemX + event.clientX - dragState.startX)
dragState.item.y = Math.round(dragState.itemY + event.clientY - dragState.startY)
}
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: [],
})
const deletedTeamName = ref('')
const deletedTeam = ref<Team>({
id: 0,
name: '',
status: '',
password: '',
actionsCount: 0,
applications: [],
})
const giveApplicationApplication = ref<Application>({
name: '',
image: '',
file_type: '',
})
const url = window.location.href
function giveApplication(team: Team, application: Application) {
giveApplicationTeam.value = team
giveApplicationApplication.value = application
showGiveApplicationModal.value = true
}
async function addTeam() {
const res = await client.AddTeam({
gameId: game.value.id,
name: newTeamName.value,
})
if (res.error != '') {
message.error(res.error!)
return
}
await getGame(game.value.id!)
showAddTeamModal.value = false
}
async function confirmGiveApplication() {
const res = await client.GiveTeamApplications({
id: giveApplicationTeam.value.id,
name: giveApplicationApplication.value.name,
})
if (res.error != '') {
message.error(res.error!)
return
}
await getGame(game.value.id!)
showGiveApplicationModal.value = false
}
async function deleteTeam(team: Team) {
deletedTeam.value = team
showDeleteTeamModal.value = true
}
async function confirmDeleteTeam() {
const res = await client.DeleteTeam({
id: deletedTeam.value.id,
})
if (res.error != '') {
message.error(res.error!)
return
}
await getGame(game.value.id!)
showDeleteTeamModal.value = false
}
async function deleteGame() {
const res = await client.DeleteGame({
id: game.value.id,
})
if (res.error != '') {
message.error(res.error!)
return
}
message.success('Игра удалена')
router.push('/games')
}
function getTeamStoryURL(id: number, password: string): string {
return getURL() + '/team-story/' + id + '?password=' + password
}
async function openQR(url: string, text: string) {
urlQR.value = url
titleQR.value = text
showQR.value = true
}
useSimplePolling(() => {
getGame(Number(gameId))
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"> Команд: {{ game.teams?.length }} </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="game.teams?.length" 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'"
:style="{ left: `${item.x || 0}px`, top: `${item.y || 0}px` }"
@pointerdown.stop="startMapDrag(item, $event)">
<span>{{ item.label }}</span>
<small v-if="item.kind === 'screen'">{{ item.status }}{{ item.soundEnabled ? ' · звук' : '' }}</small>
<button 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 }}
</span>
<div>Поездки: {{ team.actionsCount }}</div>
</div>
<n-flex justify="end">
<n-button @click="openQR(getTeamStoryURL(team.id!, team.password!), 'Подсмотреть игру')">
QR
</n-button>
<n-button @click="toTeamStory(team.id!, team.password!, true)">
Подсмотреть игру
</n-button>
<n-button type="error" ghost @click="deleteTeam(team)"> Удалить </n-button>
</n-flex>
</n-flex>
<n-button v-for="application in team.applications" :key="application.name" class="link-button"
@click="giveApplication(team, application)">
Выдать: {{ application.name }}
</n-button>
</div>
</div>
</div>
</div>
<!-- Окно настроек -->
<n-modal v-model:show="showSettingsModal">
<n-card style="width: 600px" title="Настройки игры" :bordered="false" size="huge" role="dialog" aria-modal="true">
<template #header-extra>
<n-button @click="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-input v-model:value="newTeamName" type="text" placeholder="Три дебила" />
<template #footer>
<n-flex justify="end">
<n-button @click="addTeam()" :disabled="newTeamName.length == 0"> Создать </n-button>
</n-flex>
</template>
</n-card>
</n-modal>
<!-- Окно создания команды -->
<n-modal v-model:show="showDeleteTeamModal">
<n-card style="width: 600px" title="Удаление команды" :bordered="false" size="huge" role="dialog" aria-modal="true">
<template #header-extra>
<n-button @click="showDeleteTeamModal = false"> x </n-button>
</template>
<n-space vertical>
<p>
Введите название команды
<n-text class="name-text" strong>{{ deletedTeam.name }}</n-text> чтобы удалить его
</p>
<n-input v-model:value="deletedTeamName" type="text" placeholder="Три дебила" />
</n-space>
<template #footer>
<n-flex justify="end">
<n-button @click="confirmDeleteTeam()" :disabled="deletedTeamName != deletedTeam.name">
Удалить
</n-button>
</n-flex>
</template>
</n-card>
</n-modal>
<!-- Окно удаления игры -->
<n-modal v-model:show="showDeleteGameModal">
<n-card style="width: 600px" title="Удаление игры" :bordered="false" size="huge" role="dialog" aria-modal="true">
<template #header-extra>
<n-button @click="showDeleteGameModal = false"> x </n-button>
</template>
<n-space vertical>
<p>
Введите название игры
<n-text class="name-text" strong>{{ game.name }}</n-text> чтобы удалить её
</p>
<n-input v-model:value="deletedGameName" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
</n-space>
<template #footer>
<n-flex justify="end">
<n-button @click="deleteGame()" :disabled="deletedGameName != game.name">
Удалить
</n-button>
</n-flex>
</template>
</n-card>
</n-modal>
<!-- Окно qr -->
<n-modal v-model:show="showQR">
<n-card style="width: 600px" :title="titleQR" :bordered="false" size="huge" role="dialog" aria-modal="true">
<template #header-extra>
<n-button @click="showQR = false"> x </n-button>
</template>
<n-space vertical>
<div class="qr-block">
<n-qr-code :value="urlQR" :size="300" :padding="0" />
</div>
</n-space>
<template #footer></template>
</n-card>
</n-modal>
<!-- Окно ответов команд и оценки -->
<n-modal v-model:show="showAnswersModal">
<n-card style="width: min(94vw, 900px)" title="Ответы команд" :bordered="false" size="huge" role="dialog"
aria-modal="true" :content-style="{ maxHeight: '70vh', overflow: 'auto' }">
<template #header-extra>
<n-button @click="showAnswersModal = false"> x </n-button>
</template>
<div v-if="answersQuestions.length == 0" class="answers-empty">
Вопросы дела не заданы или команды ещё не отвечали.
</div>
<div v-for="question in answersQuestions" :key="question.code" class="answers-question-block">
<p class="answers-question-title">{{ question.code }}: {{ question.text }}</p>
<p class="answers-question-answer">
Правильный ответ:<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;
overflow: hidden;
position: relative;
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: #48633a;
}
.map-item small {
display: block;
opacity: .75;
}
.map-item button {
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
font-size: 16px;
position: absolute;
right: 5px;
top: 5px;
}
.map-hidden-items { margin-top: 10px; }
</style>