This commit is contained in:
2026-10-05 00:25:46 +07:00
parent 196dbb3b7b
commit e2bc1522e7
2 changed files with 198 additions and 260 deletions
+197 -259
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Flag, Pause, Play, Reset, Settings, Trophy } from '@vicons/carbon'
import { Flag, Pause, Play, Reset, Trophy } from '@vicons/carbon'
import { Icon } from '@vicons/utils'
import { NButton, NTag } from 'naive-ui'
import {
@@ -55,25 +55,26 @@ const isDownloadingQR = ref(false)
const router = useRouter()
const route = useRoute()
const gameId = route.params.id
type GameTab = 'game' | 'teams' | 'screens-map'
type GameTab = 'game' | 'answers' | 'teams' | 'screens' | 'screens-map'
function isGameTab(value: string): value is GameTab {
return ['game', 'teams', 'screens-map'].includes(value)
return ['game', 'answers', 'teams', 'screens', 'screens-map'].includes(value)
}
const queryTab = typeof route.query.tab === 'string' ? route.query.tab : ''
const activeTab = ref<GameTab>(isGameTab(queryTab) ? queryTab : 'game')
const activeTab = ref<GameTab>(isGameTab(queryTab) ? queryTab : 'screens-map')
async function setActiveTab(value: string) {
if (!isGameTab(value)) return
activeTab.value = value
await router.replace({ query: { ...route.query, tab: value } })
if (value === 'answers') await loadGameAnswers()
}
const message = useMessage()
const showSettingsModal = ref(false)
const settingsDraft = ref({ name: '', description: '', format: 'online', location: '' })
const settingsDraftInitialized = ref(false)
const showGiveApplicationModal = ref(false)
const showAddTeamModal = ref(false)
const showDeleteTeamModal = ref(false)
@@ -85,7 +86,6 @@ const deletedGameName = ref('')
const showQR = ref(false)
const urlQR = ref('')
const titleQR = ref('')
const showIssueTicketsModal = ref(false)
const ticketCount = ref(1)
const issuedTickets = ref<Ticket[]>([])
const isIssuingTickets = ref(false)
@@ -127,6 +127,15 @@ const screenPosition = ref(0)
async function getGame(id: number) {
const res = await client.GetGame({ ID: id })
game.value = res.game!
if (!settingsDraftInitialized.value) {
settingsDraft.value = {
name: game.value.name || '',
description: game.value.description || '',
format: game.value.format || 'online',
location: game.value.location || '',
}
settingsDraftInitialized.value = true
}
game.value.teams = game.value.teams?.sort((a, b) => {
let aLen = 0
if ((a.applications?.length || 0) > 0) {
@@ -384,20 +393,6 @@ function toResults() {
router.push('/games/' + gameId + '/results')
}
function openSettings() {
settingsDraft.value = {
name: game.value.name || '',
description: game.value.description || '',
format: game.value.format || 'online',
location: game.value.location || '',
}
showSettingsModal.value = true
}
function closeSettings() {
showSettingsModal.value = false
}
async function updateGame() {
await client.UpdateGame({
ID: game.value.ID,
@@ -409,7 +404,6 @@ async function updateGame() {
scenarioID: game.value.scenario?.ID,
})
await getGame(game.value.ID!)
showSettingsModal.value = false
}
const giveApplicationTeam = ref<Team>({
@@ -518,12 +512,6 @@ async function openQR(url: string, text: string) {
showQR.value = true
}
function openIssueTickets() {
ticketCount.value = 1
issuedTickets.value = []
showIssueTicketsModal.value = true
}
async function issueTickets() {
if (isIssuingTickets.value || !game.value.ID) return
isIssuingTickets.value = true
@@ -584,7 +572,6 @@ async function finishGame() {
}
// ===== Ответы команд и оценка организатором =====
const showAnswersModal = ref(false)
const showReviewsModal = ref(false)
const gameReviews = ref<GameReview[]>([])
const reviewsAverage = ref(0)
@@ -620,11 +607,6 @@ async function loadGameAnswers() {
scores.value = next
}
async function openAnswersModal() {
showAnswersModal.value = true
await loadGameAnswers()
}
async function openReviewsModal() {
showReviewsModal.value = true
const res = await client.GetGameReviewsForModeration({ gameID: Number(gameId) })
@@ -654,6 +636,10 @@ async function saveAllScores() {
await loadGameAnswers()
}
if (activeTab.value === 'answers') {
loadGameAnswers()
}
function getCurrentUTC(): Date {
const now = new Date()
return new Date(now.getTime() - now.getTimezoneOffset() * 60000)
@@ -698,96 +684,103 @@ useSimplePolling(() => {
<div class="center-block-custom">
<div class="width1200">
<n-tabs :value="activeTab" type="line" animated @update:value="setActiveTab">
<n-tab-pane name="game" tab="Игра">
<n-tab-pane name="game" tab="Настройки">
<div class="game-tab-content">
<div class="settings-block">
<n-flex justify="space-between">
<n-space>
<n-tag :bordered="false" type="info">
Участвуют команд: {{ activeTeamCount }}
</n-tag>
<n-tag type="info">
{{ timeString }}
</n-tag>
<n-tag v-if="formatGameDateTime(game.startAt)" type="info">
Начало: {{ formatGameDateTime(game.startAt) }}
</n-tag>
</n-space>
<n-flex justify="end">
<n-button quaternary @click="openQR(url, 'Админка')"> QR </n-button>
<n-button quaternary @click="openIssueTickets()"> Выпустить билеты </n-button>
<n-button quaternary @click="openAnswersModal()"> Ответы команд </n-button>
<n-button v-if="game.status == 'finish'" quaternary @click="toResults()">
<Icon class="settings-icon">
<Trophy />
</Icon>
Итоги
</n-button>
<n-button
v-if="activeTeamCount"
quaternary
:loading="isDownloadingQR"
:disabled="isDownloadingQR"
@click="downloadTeamQR()"
>
QR команд
</n-button>
<n-button quaternary @click="openSettings()">
<Icon class="settings-icon">
<Settings />
</Icon>
Настройки
</n-button>
<n-button
v-if="(game.teams?.length || 0) == 0"
type="error"
quaternary
@click="showDeleteGameModal = true"
>
Удалить игру
</n-button>
<n-space>
<n-button type="info" v-if="game.status != 'draft'" dashed @click="resetGame()">
<Icon>
<Reset />
</Icon>
</n-button>
<n-button type="info" v-if="game.status == 'draft'" dashed @click="startGame()">
<Icon>
<Play />
</Icon>
</n-button>
<n-button type="info" v-if="game.status == 'pause'" dashed @click="playGame()">
<Icon>
<Play />
</Icon>
</n-button>
<n-button type="info" v-if="game.status == 'run'" dashed @click="pauseGame()">
<Icon>
<Pause />
</Icon>
</n-button>
<n-button
type="info"
v-if="game.status != 'finish'"
dashed
@click="finishGame()"
>
<Icon>
<Flag />
</Icon>
</n-button>
</n-space>
</n-flex>
<UiBlock>
<n-flex justify="end">
<n-button quaternary @click="openQR(url, 'Админка')">QR</n-button>
<n-button v-if="game.status == 'finish'" quaternary @click="toResults()">
<Icon class="settings-icon">
<Trophy />
</Icon>
Итоги
</n-button>
<n-button
v-if="(game.teams?.length || 0) == 0"
type="error"
quaternary
@click="showDeleteGameModal = true"
>
Удалить игру
</n-button>
</n-flex>
</div>
</UiBlock>
<UiBlock>
<p class="screens-title">Настройки игры</p>
<n-space vertical>
<p>Название</p>
<n-input
v-model:value="settingsDraft.name"
type="text"
placeholder='Дело №0 "Следствие ведут овечки"'
/>
<p class="settings-header">Формат игры</p>
<n-select
v-model:value="settingsDraft.format"
:options="[
{ label: 'Online', value: 'online' },
{ label: 'Offline', value: 'offline' },
]"
aria-label="Формат игры"
/>
<p class="settings-header">Описание</p>
<n-input
v-model:value="settingsDraft.description"
type="textarea"
placeholder="Убийство пастуха..."
/>
<p class="settings-header">Место проведения</p>
<n-input
v-model:value="settingsDraft.location"
type="text"
placeholder="Оставьте пустым для онлайн-игры"
/>
</n-space>
<n-flex justify="end" class="settings-actions">
<n-button @click="updateGame()">Сохранить изменения</n-button>
</n-flex>
</UiBlock>
<UiBlock>
<p class="screens-title">Выпуск билетов</p>
<n-space vertical>
<n-input-number v-model:value="ticketCount" :min="1" :max="100" />
<div v-for="ticket in issuedTickets" :key="ticket.ID" class="issued-ticket">
<div>Билет №{{ ticket.ID }}</div>
<a :href="ticket.URL" target="_blank" rel="noreferrer">Открыть страницу билета</a>
<n-button size="small" @click="openQR(ticket.URL!, `Билет №${ticket.ID}`)">
Показать QR
</n-button>
</div>
</n-space>
<n-flex justify="end" class="settings-actions">
<n-button
:loading="isIssuingTickets"
:disabled="isIssuingTickets"
@click="issueTickets()"
>
Выпустить
</n-button>
</n-flex>
</UiBlock>
</div>
</n-tab-pane>
<n-tab-pane name="teams" tab="Команды">
<div class="game-tab-content">
<n-tag :bordered="false" type="info"> Участвуют команд: {{ activeTeamCount }} </n-tag>
<n-flex justify="space-between" align="center">
<n-tag :bordered="false" type="info"> Участвуют команд: {{ activeTeamCount }} </n-tag>
<n-button
v-if="activeTeamCount"
quaternary
:loading="isDownloadingQR"
:disabled="isDownloadingQR"
@click="downloadTeamQR()"
>
QR команд
</n-button>
</n-flex>
<GameTeamsPanel
:teams="game.teams || []"
:participation-options="participationOptions"
@@ -799,8 +792,58 @@ useSimplePolling(() => {
/>
</div>
</n-tab-pane>
<n-tab-pane name="screens-map" tab="Экраны и карта">
<n-tab-pane name="screens" tab="Экраны">
<div class="game-tab-content">
<GameScreensPanel
:screens="screens"
:screen-binding="screenBinding"
@bind="bindScannedScreen"
@set-sound="setSoundScreen"
@unbind="unbindScreen"
/>
</div>
</n-tab-pane>
<n-tab-pane name="screens-map" tab="Игра">
<div class="game-tab-content">
<UiBlock>
<n-flex justify="space-between">
<n-space>
<n-tag type="info">
{{ timeString }}
</n-tag>
<n-tag v-if="formatGameDateTime(game.startAt)" type="info">
Начало: {{ formatGameDateTime(game.startAt) }}
</n-tag>
</n-space>
<n-flex justify="end">
<n-button type="info" v-if="game.status != 'draft'" dashed @click="resetGame()">
<Icon>
<Reset />
</Icon>
</n-button>
<n-button type="info" v-if="game.status == 'draft'" dashed @click="startGame()">
<Icon>
<Play />
</Icon>
</n-button>
<n-button type="info" v-if="game.status == 'pause'" dashed @click="playGame()">
<Icon>
<Play />
</Icon>
</n-button>
<n-button type="info" v-if="game.status == 'run'" dashed @click="pauseGame()">
<Icon>
<Pause />
</Icon>
</n-button>
<n-button type="info" v-if="game.status != 'finish'" dashed @click="finishGame()">
<Icon>
<Flag />
</Icon>
</n-button>
</n-flex>
</n-flex>
</UiBlock>
<GameMapPanel
:map-items="mapItems"
:teams="game.teams || []"
@@ -829,72 +872,44 @@ useSimplePolling(() => {
@update:screen-volume="screenVolume = $event ?? 0"
@update:screen-position="screenPosition = $event ?? 0"
/>
<GameScreensPanel
:screens="screens"
:screen-binding="screenBinding"
@bind="bindScannedScreen"
@set-sound="setSoundScreen"
@unbind="unbindScreen"
/>
</div>
</n-tab-pane>
<n-tab-pane name="answers" tab="Проверка ответов">
<div class="game-tab-content">
<div v-if="answersQuestions.length == 0" class="answers-empty">
Вопросы дела не заданы или команды ещё не отвечали.
</div>
<UiBlock v-for="question in answersQuestions" :key="question.code">
<p class="answers-question-title">{{ question.code }}: {{ question.text }}</p>
<p class="answers-question-answer">
Правильный ответ:<br /><span class="correct-answer">{{ question.answer || '—' }}</span>
</p>
<div v-for="team in answersTeams" :key="team.team?.ID" class="answers-team-row">
<div class="answers-team-name">
{{ team.team?.name }}
</div>
<div class="answers-team-answer">
{{ teamAnswer(team, question.code) || '—' }}
</div>
<n-input-number
:value="scores[scoreKey(team.team?.ID, question.code)]"
@update:value="onScoreUpdate(scoreKey(team.team?.ID, question.code), $event)"
placeholder="Балл"
class="answers-score-input"
/>
</div>
</UiBlock>
<n-flex justify="end">
<n-button @click="saveAllScores()" :disabled="answersQuestions.length == 0">
Сохранить баллы
</n-button>
</n-flex>
</div>
</n-tab-pane>
</n-tabs>
</div>
</div>
<!-- Окно настроек -->
<n-modal v-model:show="showSettingsModal">
<n-card
style="width: 600px"
title="Настройки игры"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<template #header-extra>
<n-button @click="closeSettings()"> x </n-button>
</template>
<n-space vertical>
<p>Название</p>
<n-input
v-model:value="settingsDraft.name"
type="text"
placeholder='Дело №0 "Следствие ведут овечки"'
/>
<p class="settings-header">Формат игры</p>
<n-select
v-model:value="settingsDraft.format"
:options="[
{ label: 'Online', value: 'online' },
{ label: 'Offline', value: 'offline' },
]"
aria-label="Формат игры"
/>
<p class="settings-header">Описание</p>
<n-input
v-model:value="settingsDraft.description"
type="textarea"
placeholder="Убийство пастуха..."
/>
<p class="settings-header">Место проведения</p>
<n-input
v-model:value="settingsDraft.location"
type="text"
placeholder="Оставьте пустым для онлайн-игры"
/>
</n-space>
<template #footer>
<n-flex justify="end">
<n-button @click="updateGame()" ghost> Сохранить изменения </n-button>
</n-flex>
</template>
</n-card>
</n-modal>
<!-- Окно выдачи улик -->
<n-modal v-model:show="showGiveApplicationModal">
<n-card
@@ -1062,40 +1077,6 @@ useSimplePolling(() => {
</n-card>
</n-modal>
<!-- Окно qr -->
<n-modal v-model:show="showIssueTicketsModal">
<n-card
style="width: min(94vw, 700px)"
title="Выпуск билетов"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<template #header-extra>
<n-button @click="showIssueTicketsModal = false"> x </n-button>
</template>
<n-space vertical>
<n-input-number v-model:value="ticketCount" :min="1" :max="100" />
<n-button
type="primary"
:loading="isIssuingTickets"
:disabled="isIssuingTickets"
@click="issueTickets()"
>
Выпустить
</n-button>
<div v-for="ticket in issuedTickets" :key="ticket.ID" class="issued-ticket">
<div>Билет №{{ ticket.ID }}</div>
<a :href="ticket.URL" target="_blank" rel="noreferrer">Открыть страницу билета</a>
<n-button size="small" @click="openQR(ticket.URL!, `Билет №${ticket.ID}`)">
Показать QR
</n-button>
</div>
</n-space>
</n-card>
</n-modal>
<n-modal v-model:show="showQR">
<n-card
style="width: 600px"
@@ -1118,53 +1099,6 @@ useSimplePolling(() => {
</n-card>
</n-modal>
<!-- Окно ответов команд и оценки -->
<n-modal v-model:show="showAnswersModal">
<n-card
style="width: min(94vw, 900px)"
title="Ответы команд"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
:content-style="{ maxHeight: '70vh', overflow: 'auto' }"
>
<template #header-extra>
<n-button @click="showAnswersModal = false"> x </n-button>
</template>
<div v-if="answersQuestions.length == 0" class="answers-empty">
Вопросы дела не заданы или команды ещё не отвечали.
</div>
<UiBlock v-for="question in answersQuestions" :key="question.code">
<p class="answers-question-title">{{ question.code }}: {{ question.text }}</p>
<p class="answers-question-answer">
Правильный ответ:<br /><span class="correct-answer">{{ question.answer || '—' }}</span>
</p>
<div v-for="team in answersTeams" :key="team.team?.ID" class="answers-team-row">
<div class="answers-team-name">
{{ team.team?.name }}
</div>
<div class="answers-team-answer">
{{ teamAnswer(team, question.code) || '—' }}
</div>
<n-input-number
:value="scores[scoreKey(team.team?.ID, question.code)]"
@update:value="onScoreUpdate(scoreKey(team.team?.ID, question.code), $event)"
placeholder="Балл"
class="answers-score-input"
/>
</div>
</UiBlock>
<template #footer>
<n-flex justify="end">
<n-button @click="saveAllScores()" :disabled="answersQuestions.length == 0">
Сохранить баллы
</n-button>
</n-flex>
</template>
</n-card>
</n-modal>
<n-modal v-model:show="showReviewsModal">
<n-card
style="width: min(94vw, 800px)"
@@ -1246,6 +1180,10 @@ useSimplePolling(() => {
width: 20px;
}
.settings-actions {
margin-top: 16px;
}
.link-button {
margin-top: 16px;
width: 100%;