generated from VLADIMIR/template_frontend
1094 lines
31 KiB
Vue
1094 lines
31 KiB
Vue
<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, NSpace, NText } from 'naive-ui'
|
||
import { useMessage } from 'naive-ui'
|
||
import { ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
|
||
import { getAuthClient } from '@/api/auth_client'
|
||
import { getBinaryClient } from '@/api/binary_client'
|
||
import type {
|
||
Application,
|
||
Game,
|
||
MapItem,
|
||
Question,
|
||
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: '' })
|
||
const showGiveApplicationModal = ref(false)
|
||
const showAddTeamModal = ref(false)
|
||
const showDeleteTeamModal = ref(false)
|
||
const showDeleteGameModal = ref(false)
|
||
const newTeamName = ref('')
|
||
const deletedGameName = ref('')
|
||
const showQR = ref(false)
|
||
const urlQR = ref('')
|
||
const titleQR = ref('')
|
||
|
||
const game = ref<Game>({
|
||
id: undefined,
|
||
name: undefined,
|
||
description: undefined,
|
||
startAt: undefined,
|
||
status: undefined,
|
||
scenario: undefined,
|
||
teams: undefined,
|
||
})
|
||
const screens = ref<ScreenState[]>([])
|
||
const mapItems = ref<MapItem[]>([])
|
||
const mapSaving = ref(false)
|
||
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 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 || '',
|
||
}
|
||
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,
|
||
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>
|
||
</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-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;
|
||
}
|
||
|
||
.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>
|