This commit is contained in:
2026-10-11 14:16:54 +07:00
parent e12ef3bde0
commit f668eb66bc
5 changed files with 400 additions and 28 deletions
+109 -26
View File
@@ -19,7 +19,7 @@ import {
type UploadFileInfo,
} from 'naive-ui'
import { useMessage } from 'naive-ui'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getAuthClient } from '@/api/auth_client'
@@ -46,11 +46,13 @@ import type {
} from '@/api/generated/crabs/evening_detective_server'
import GameMapPanel from '@/components/GameMapPanel.vue'
import GameScreensPanel from '@/components/GameScreensPanel.vue'
import GameStatePanel from '@/components/GameStatePanel.vue'
import GameTeamsPanel from '@/components/GameTeamsPanel.vue'
import HeaderMenu from '@/components/HeaderMenu.vue'
import TicketCheckModal from '@/components/TicketCheckModal.vue'
import UiBlock from '@/components/UiBlock.vue'
import { useDeviceContext } from '@/composables/useDeviceContext'
import { useGameState } from '@/composables/useGameState'
import { useSimplePolling } from '@/composables/useSimplePolling'
import { useAuthStore } from '@/stores/auth'
import { useUIStore } from '@/stores/ui'
@@ -65,23 +67,30 @@ const isDownloadingQR = ref(false)
const router = useRouter()
const route = useRoute()
const gameId = route.params.id
useDeviceContext(() => ({ organizerGameID: Number(gameId) }))
type GameTab = 'settings' | 'teams' | 'screens' | 'game' | 'payment' | 'answers'
type GameTab = 'settings' | 'teams' | 'screens' | 'game' | 'payment' | 'answers' | 'state'
function isGameTab(value: string): value is GameTab {
return ['settings', 'teams', 'screens', 'game', 'payment', 'answers'].includes(value)
return ['settings', 'teams', 'screens', 'game', 'payment', 'answers', 'state'].includes(value)
}
const queryTab = typeof route.query.tab === 'string' ? route.query.tab : ''
const activeTab = ref<GameTab>(isGameTab(queryTab) ? queryTab : 'game')
async function setActiveTab(value: string) {
if (!isGameTab(value)) return
if (!isGameTab(value) || (value === 'state' ? !canViewState.value : !canManageGame.value)) return
activeTab.value = value
await router.replace({ query: { ...route.query, tab: value } })
if (value === 'answers') await loadGameAnswers()
}
function onStateTabKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
void setActiveTab('state')
}
}
const message = useMessage()
const settingsDraft = ref({ name: '', description: '', format: 'online', location: '' })
@@ -119,6 +128,55 @@ const game = ref<Game>({
})
const authStore = useAuthStore()
const uiStore = useUIStore()
const canManageGame = computed(() => authStore.hasAnyRole(['admin', 'organizer', 'author']))
const canViewState = computed(
() =>
uiStore.environment === 'venue' &&
authStore.hasAnyRole(['admin', 'organizer', 'developer']),
)
const isStateOnly = computed(() => canViewState.value && !canManageGame.value)
useDeviceContext(() => (canManageGame.value ? { organizerGameID: Number(gameId) } : {}))
const { devices, serverTime, isLoading, hasError, summary, reload } = useGameState(
() => Number(route.params.id),
() => canViewState.value && activeTab.value === 'state',
)
watch(
[() => route.query.tab, canViewState, canManageGame],
() => {
const tab = typeof route.query.tab === 'string' ? route.query.tab : ''
activeTab.value = isStateOnly.value
? 'state'
: isGameTab(tab) && (tab !== 'state' || canViewState.value)
? tab
: 'game'
if (isStateOnly.value && tab !== 'state') {
void router.replace({ query: { ...route.query, tab: 'state' } })
}
},
{ immediate: true },
)
watch(
isStateOnly,
(value) => {
if (value) {
game.value = {
ID: undefined,
name: undefined,
description: undefined,
startAt: undefined,
status: undefined,
format: undefined,
location: undefined,
scenario: undefined,
teams: undefined,
export: undefined,
}
void getGame(Number(gameId))
}
},
{ immediate: true },
)
const canExport = computed(
() => uiStore.environment === 'prod' && authStore.hasAnyRole(['admin', 'organizer', 'developer']),
)
@@ -161,7 +219,9 @@ const screenVolume = ref(100)
const screenPosition = ref(0)
async function getGame(id: number) {
const wasManaging = canManageGame.value
const res = await client.GetGame({ ID: id })
if (wasManaging !== canManageGame.value) return
game.value = res.game!
if (!settingsDraftInitialized.value) {
settingsDraft.value = {
@@ -199,7 +259,7 @@ async function getGame(id: number) {
return 0
})
}
getGame(Number(gameId))
if (canManageGame.value) getGame(Number(gameId))
async function getGameScreens() {
const res = await client.GetGameScreens({ gameID: Number(gameId) })
@@ -215,7 +275,7 @@ async function getGameScreens() {
previewTeams.value = []
}
}
getGameScreens()
if (canManageGame.value) getGameScreens()
async function getGameMusic() {
try {
@@ -226,7 +286,7 @@ async function getGameMusic() {
message.error(err instanceof Error ? err.message : 'Не удалось получить музыку игры')
}
}
getGameMusic()
if (canManageGame.value) getGameMusic()
function isScreenPlanEditing() {
return screenPlanMode.value === 'edit'
@@ -248,7 +308,7 @@ async function getScreenPlan() {
screenPlan.value = res.plan
screenPlanDraft.value = res.plan?.steps?.map((step) => ({ ...step })) || []
}
getScreenPlan()
if (canManageGame.value) getScreenPlan()
function addScreenPlanStep() {
screenPlanDraft.value.push({
@@ -375,7 +435,7 @@ async function getGameMap() {
if (mapSaving.value || mapDragging.value) return
mapItems.value = res.items || []
}
getGameMap()
if (canManageGame.value) getGameMap()
async function saveMapItem(item: MapItem, hidden = item.hidden) {
mapSaving.value = true
@@ -749,7 +809,7 @@ async function issueTickets() {
}
useSimplePolling(() => {
if (isApplyingResults.value) return
if (!canManageGame.value || isApplyingResults.value) return
getGame(Number(gameId))
getGameScreens()
getGameMusic()
@@ -857,7 +917,7 @@ async function saveAllScores() {
await loadGameAnswers()
}
if (activeTab.value === 'answers') {
if (canManageGame.value && activeTab.value === 'answers') {
loadGameAnswers()
}
@@ -904,7 +964,9 @@ useSimplePolling(() => {
<template>
<div class="center-block-custom">
<div class="width1200">
<UiBlock v-if="game.export?.state === 'preparing' || game.export?.state === 'ready'">
<UiBlock
v-if="canManageGame && (game.export?.state === 'preparing' || game.export?.state === 'ready')"
>
<n-alert
type="info"
:title="game.export.state === 'ready' ? 'Игра в офлайн-режиме' : 'Подготовка офлайн-пакета'"
@@ -916,8 +978,9 @@ useSimplePolling(() => {
}}
</n-alert>
</UiBlock>
<UiBlock v-if="isStateOnly"><h2>{{ game.name }}</h2></UiBlock>
<n-tabs :value="activeTab" type="line" animated @update:value="setActiveTab">
<n-tab-pane name="settings" tab="Настройки">
<n-tab-pane v-if="canManageGame" name="settings" tab="Настройки">
<div class="game-tab-content">
<UiBlock>
<n-flex justify="end">
@@ -1079,7 +1142,7 @@ useSimplePolling(() => {
</UiBlock>
</div>
</n-tab-pane>
<n-tab-pane name="teams" tab="Команды">
<n-tab-pane v-if="canManageGame" name="teams" tab="Команды">
<div class="game-tab-content">
<n-flex justify="space-between" align="center">
<n-tag :bordered="false" type="info"> Участвуют команд: {{ activeTeamCount }} </n-tag>
@@ -1104,7 +1167,7 @@ useSimplePolling(() => {
/>
</div>
</n-tab-pane>
<n-tab-pane name="screens" tab="Экраны">
<n-tab-pane v-if="canManageGame" name="screens" tab="Экраны">
<div class="game-tab-content">
<GameScreensPanel
:screens="screens"
@@ -1122,7 +1185,7 @@ useSimplePolling(() => {
/>
</div>
</n-tab-pane>
<n-tab-pane name="game" tab="Игра">
<n-tab-pane v-if="canManageGame" name="game" tab="Игра">
<div class="game-tab-content">
<UiBlock>
<n-flex justify="space-between">
@@ -1224,7 +1287,7 @@ useSimplePolling(() => {
/>
</div>
</n-tab-pane>
<n-tab-pane name="payment" tab="Оплата">
<n-tab-pane v-if="canManageGame" name="payment" tab="Оплата">
<div class="game-tab-content">
<UiBlock>
<n-flex align="center" justify="space-between">
@@ -1252,7 +1315,7 @@ useSimplePolling(() => {
</UiBlock>
</div>
</n-tab-pane>
<n-tab-pane name="answers" tab="Проверка ответов">
<n-tab-pane v-if="canManageGame" name="answers" tab="Проверка ответов">
<div class="game-tab-content">
<div v-if="answersQuestions.length == 0" class="answers-empty">
Вопросы дела не заданы или команды ещё не отвечали.
@@ -1284,14 +1347,34 @@ useSimplePolling(() => {
</n-flex>
</div>
</n-tab-pane>
<n-tab-pane
v-if="canViewState"
name="state"
tab="Состояние"
:tab-props="{
tabindex: 0,
role: 'tab',
'aria-selected': activeTab === 'state',
onKeydown: onStateTabKeydown,
}"
>
<GameStatePanel
:devices="devices"
:server-time="serverTime"
:is-loading="isLoading"
:has-error="hasError"
:summary="summary"
@reload="reload"
/>
</n-tab-pane>
</n-tabs>
</div>
</div>
<TicketCheckModal v-model:show="showTicketCheckModal" />
<TicketCheckModal v-if="canManageGame" v-model:show="showTicketCheckModal" />
<!-- Окно выдачи улик -->
<n-modal v-model:show="showGiveApplicationModal">
<n-modal v-if="canManageGame" v-model:show="showGiveApplicationModal">
<n-card
style="width: 600px"
title="Настройки игры"
@@ -1354,7 +1437,7 @@ useSimplePolling(() => {
</n-modal>
<!-- Окно создания команды -->
<n-modal v-model:show="showAddTeamModal">
<n-modal v-if="canManageGame" v-model:show="showAddTeamModal">
<n-card
style="width: 600px"
title="Создание команды"
@@ -1392,7 +1475,7 @@ useSimplePolling(() => {
</n-modal>
<!-- Окно создания команды -->
<n-modal v-model:show="showDeleteTeamModal">
<n-modal v-if="canManageGame" v-model:show="showDeleteTeamModal">
<n-card
style="width: 600px"
title="Удаление команды"
@@ -1423,7 +1506,7 @@ useSimplePolling(() => {
</n-modal>
<!-- Окно удаления игры -->
<n-modal v-model:show="showDeleteGameModal">
<n-modal v-if="canManageGame" v-model:show="showDeleteGameModal">
<n-card
style="width: 600px"
title="Удаление игры"
@@ -1457,7 +1540,7 @@ useSimplePolling(() => {
</n-card>
</n-modal>
<n-modal v-model:show="showQR">
<n-modal v-if="canManageGame" v-model:show="showQR">
<n-card
style="width: 600px"
:title="titleQR"
@@ -1479,7 +1562,7 @@ useSimplePolling(() => {
</n-card>
</n-modal>
<n-modal v-model:show="showReviewsModal">
<n-modal v-if="canManageGame" v-model:show="showReviewsModal">
<n-card
style="width: min(94vw, 800px)"
title="Отзывы игры"
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { NAlert, NButton, NCollapse, NCollapseItem, NFlex, NSpin, NTag } from 'naive-ui'
import type { VenueDevice } from '@/api/generated/crabs/evening_detective_server'
import UiBlock from '@/components/UiBlock.vue'
import { formatGameDateTime } from '@/utils/datetime'
defineProps<{
devices: VenueDevice[]
serverTime?: string
isLoading: boolean
hasError: boolean
summary: { connected: number; disconnected: number; connections: number }
}>()
defineEmits<{ reload: [] }>()
const kinds: Record<string, string> = {
browser: 'Браузер',
organizer: 'Организатор',
team: 'Команда',
screen: 'Экран',
}
</script>
<template>
<UiBlock>
<n-flex>
<n-tag type="success">Доступны: {{ summary.connected }}</n-tag>
<n-tag type="warning">Нет связи: {{ summary.disconnected }}</n-tag>
<n-tag>Подключения: {{ summary.connections }}</n-tag>
</n-flex>
<p v-if="serverTime">Последнее обновление: {{ formatGameDateTime(serverTime) }}</p>
<p>«Нет связи» означает отсутствие heartbeat сайта. UUID обозначает браузерный профиль.</p>
<n-alert v-if="hasError" type="error" title="Не удалось обновить состояние">
<n-button :loading="isLoading" @click="$emit('reload')">Повторить</n-button>
</n-alert>
<n-spin v-if="isLoading && !serverTime" />
<p v-else-if="!hasError && !serverTime && !isLoading">Загружаем состояние…</p>
<p v-else-if="serverTime && !devices.length">Устройства этой игры пока не зарегистрированы</p>
</UiBlock>
<UiBlock v-for="device in devices" :key="device.ID">
<div class="device-details">
<strong>{{ device.ID }}</strong>
<p>
<n-tag :type="device.connected ? 'success' : 'warning'">{{
device.connected ? 'Доступно' : 'Нет связи'
}}</n-tag>
</p>
<p>Последнее обращение: {{ formatGameDateTime(device.lastSeenAt) || '—' }}</p>
<n-collapse>
<n-collapse-item title="Подключения" :name="device.ID || ''">
<div v-for="connection in device.connections" :key="connection.ID" class="connection">
<strong>{{ kinds[connection.kind || ''] || connection.kind }}</strong>
<p v-if="connection.teamID">ID команды: {{ connection.teamID }}</p>
<p v-if="connection.screenID">ID экрана: {{ connection.screenID }}</p>
<p>{{ connection.connected ? 'Доступно' : 'Нет связи' }}</p>
<p>Последнее обращение: {{ formatGameDateTime(connection.lastSeenAt) || '—' }}</p>
<p>IP: {{ connection.IP || '—' }}</p>
<p>User-Agent: {{ connection.userAgent || '—' }}</p>
</div>
</n-collapse-item>
</n-collapse>
</div>
</UiBlock>
</template>
<style scoped>
.device-details {
overflow-wrap: anywhere;
}
.connection {
margin-top: 16px;
}
</style>
+3 -2
View File
@@ -34,6 +34,7 @@ const games = ref<Game[]>([])
const uiStore = useUIStore()
const authStore = useAuthStore()
const binaryClient = getBinaryClient()
const canManageGame = computed(() => authStore.hasAnyRole(['admin', 'organizer', 'author']))
const canImport = computed(
() =>
uiStore.environment === 'venue' && authStore.hasAnyRole(['admin', 'organizer', 'developer']),
@@ -202,7 +203,7 @@ async function createGame() {
<div class="center-block-custom">
<div class="width1200">
<n-button v-if="canImport" @click="openImport">Импорт игры</n-button>
<div class="game-block game-block-hover" @click="openCreateGame()">
<div v-if="canManageGame" class="game-block game-block-hover" @click="openCreateGame()">
<div class="game-create-content">
<span class="game-create-plus">+</span>
<span>Создать игру</span>
@@ -226,7 +227,7 @@ async function createGame() {
Начало: {{ formatGameDateTime(game.startAt) }}
</div>
<div v-if="game.location" class="game-date">Место: {{ game.location }}</div>
<div v-if="game.status == 'finish'" class="game-actions">
<div v-if="canManageGame && game.status == 'finish'" class="game-actions">
<n-button size="small" ghost @click.stop="toResults(game.ID!)">Результаты</n-button>
</div>
</div>