generated from VLADIMIR/template_frontend
up
This commit is contained in:
+109
-26
@@ -19,7 +19,7 @@ import {
|
|||||||
type UploadFileInfo,
|
type UploadFileInfo,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { useMessage } 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 { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { getAuthClient } from '@/api/auth_client'
|
import { getAuthClient } from '@/api/auth_client'
|
||||||
@@ -46,11 +46,13 @@ import type {
|
|||||||
} from '@/api/generated/crabs/evening_detective_server'
|
} from '@/api/generated/crabs/evening_detective_server'
|
||||||
import GameMapPanel from '@/components/GameMapPanel.vue'
|
import GameMapPanel from '@/components/GameMapPanel.vue'
|
||||||
import GameScreensPanel from '@/components/GameScreensPanel.vue'
|
import GameScreensPanel from '@/components/GameScreensPanel.vue'
|
||||||
|
import GameStatePanel from '@/components/GameStatePanel.vue'
|
||||||
import GameTeamsPanel from '@/components/GameTeamsPanel.vue'
|
import GameTeamsPanel from '@/components/GameTeamsPanel.vue'
|
||||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
import TicketCheckModal from '@/components/TicketCheckModal.vue'
|
import TicketCheckModal from '@/components/TicketCheckModal.vue'
|
||||||
import UiBlock from '@/components/UiBlock.vue'
|
import UiBlock from '@/components/UiBlock.vue'
|
||||||
import { useDeviceContext } from '@/composables/useDeviceContext'
|
import { useDeviceContext } from '@/composables/useDeviceContext'
|
||||||
|
import { useGameState } from '@/composables/useGameState'
|
||||||
import { useSimplePolling } from '@/composables/useSimplePolling'
|
import { useSimplePolling } from '@/composables/useSimplePolling'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUIStore } from '@/stores/ui'
|
import { useUIStore } from '@/stores/ui'
|
||||||
@@ -65,23 +67,30 @@ const isDownloadingQR = ref(false)
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const gameId = route.params.id
|
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 {
|
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 queryTab = typeof route.query.tab === 'string' ? route.query.tab : ''
|
||||||
const activeTab = ref<GameTab>(isGameTab(queryTab) ? queryTab : 'game')
|
const activeTab = ref<GameTab>(isGameTab(queryTab) ? queryTab : 'game')
|
||||||
|
|
||||||
async function setActiveTab(value: string) {
|
async function setActiveTab(value: string) {
|
||||||
if (!isGameTab(value)) return
|
if (!isGameTab(value) || (value === 'state' ? !canViewState.value : !canManageGame.value)) return
|
||||||
activeTab.value = value
|
activeTab.value = value
|
||||||
await router.replace({ query: { ...route.query, tab: value } })
|
await router.replace({ query: { ...route.query, tab: value } })
|
||||||
if (value === 'answers') await loadGameAnswers()
|
if (value === 'answers') await loadGameAnswers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onStateTabKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
void setActiveTab('state')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
const settingsDraft = ref({ name: '', description: '', format: 'online', location: '' })
|
const settingsDraft = ref({ name: '', description: '', format: 'online', location: '' })
|
||||||
@@ -119,6 +128,55 @@ const game = ref<Game>({
|
|||||||
})
|
})
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const uiStore = useUIStore()
|
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(
|
const canExport = computed(
|
||||||
() => uiStore.environment === 'prod' && authStore.hasAnyRole(['admin', 'organizer', 'developer']),
|
() => uiStore.environment === 'prod' && authStore.hasAnyRole(['admin', 'organizer', 'developer']),
|
||||||
)
|
)
|
||||||
@@ -161,7 +219,9 @@ const screenVolume = ref(100)
|
|||||||
const screenPosition = ref(0)
|
const screenPosition = ref(0)
|
||||||
|
|
||||||
async function getGame(id: number) {
|
async function getGame(id: number) {
|
||||||
|
const wasManaging = canManageGame.value
|
||||||
const res = await client.GetGame({ ID: id })
|
const res = await client.GetGame({ ID: id })
|
||||||
|
if (wasManaging !== canManageGame.value) return
|
||||||
game.value = res.game!
|
game.value = res.game!
|
||||||
if (!settingsDraftInitialized.value) {
|
if (!settingsDraftInitialized.value) {
|
||||||
settingsDraft.value = {
|
settingsDraft.value = {
|
||||||
@@ -199,7 +259,7 @@ async function getGame(id: number) {
|
|||||||
return 0
|
return 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
getGame(Number(gameId))
|
if (canManageGame.value) getGame(Number(gameId))
|
||||||
|
|
||||||
async function getGameScreens() {
|
async function getGameScreens() {
|
||||||
const res = await client.GetGameScreens({ gameID: Number(gameId) })
|
const res = await client.GetGameScreens({ gameID: Number(gameId) })
|
||||||
@@ -215,7 +275,7 @@ async function getGameScreens() {
|
|||||||
previewTeams.value = []
|
previewTeams.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
getGameScreens()
|
if (canManageGame.value) getGameScreens()
|
||||||
|
|
||||||
async function getGameMusic() {
|
async function getGameMusic() {
|
||||||
try {
|
try {
|
||||||
@@ -226,7 +286,7 @@ async function getGameMusic() {
|
|||||||
message.error(err instanceof Error ? err.message : 'Не удалось получить музыку игры')
|
message.error(err instanceof Error ? err.message : 'Не удалось получить музыку игры')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
getGameMusic()
|
if (canManageGame.value) getGameMusic()
|
||||||
|
|
||||||
function isScreenPlanEditing() {
|
function isScreenPlanEditing() {
|
||||||
return screenPlanMode.value === 'edit'
|
return screenPlanMode.value === 'edit'
|
||||||
@@ -248,7 +308,7 @@ async function getScreenPlan() {
|
|||||||
screenPlan.value = res.plan
|
screenPlan.value = res.plan
|
||||||
screenPlanDraft.value = res.plan?.steps?.map((step) => ({ ...step })) || []
|
screenPlanDraft.value = res.plan?.steps?.map((step) => ({ ...step })) || []
|
||||||
}
|
}
|
||||||
getScreenPlan()
|
if (canManageGame.value) getScreenPlan()
|
||||||
|
|
||||||
function addScreenPlanStep() {
|
function addScreenPlanStep() {
|
||||||
screenPlanDraft.value.push({
|
screenPlanDraft.value.push({
|
||||||
@@ -375,7 +435,7 @@ async function getGameMap() {
|
|||||||
if (mapSaving.value || mapDragging.value) return
|
if (mapSaving.value || mapDragging.value) return
|
||||||
mapItems.value = res.items || []
|
mapItems.value = res.items || []
|
||||||
}
|
}
|
||||||
getGameMap()
|
if (canManageGame.value) getGameMap()
|
||||||
|
|
||||||
async function saveMapItem(item: MapItem, hidden = item.hidden) {
|
async function saveMapItem(item: MapItem, hidden = item.hidden) {
|
||||||
mapSaving.value = true
|
mapSaving.value = true
|
||||||
@@ -749,7 +809,7 @@ async function issueTickets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useSimplePolling(() => {
|
useSimplePolling(() => {
|
||||||
if (isApplyingResults.value) return
|
if (!canManageGame.value || isApplyingResults.value) return
|
||||||
getGame(Number(gameId))
|
getGame(Number(gameId))
|
||||||
getGameScreens()
|
getGameScreens()
|
||||||
getGameMusic()
|
getGameMusic()
|
||||||
@@ -857,7 +917,7 @@ async function saveAllScores() {
|
|||||||
await loadGameAnswers()
|
await loadGameAnswers()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTab.value === 'answers') {
|
if (canManageGame.value && activeTab.value === 'answers') {
|
||||||
loadGameAnswers()
|
loadGameAnswers()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,7 +964,9 @@ useSimplePolling(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="center-block-custom">
|
<div class="center-block-custom">
|
||||||
<div class="width1200">
|
<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
|
<n-alert
|
||||||
type="info"
|
type="info"
|
||||||
:title="game.export.state === 'ready' ? 'Игра в офлайн-режиме' : 'Подготовка офлайн-пакета'"
|
:title="game.export.state === 'ready' ? 'Игра в офлайн-режиме' : 'Подготовка офлайн-пакета'"
|
||||||
@@ -916,8 +978,9 @@ useSimplePolling(() => {
|
|||||||
}}
|
}}
|
||||||
</n-alert>
|
</n-alert>
|
||||||
</UiBlock>
|
</UiBlock>
|
||||||
|
<UiBlock v-if="isStateOnly"><h2>{{ game.name }}</h2></UiBlock>
|
||||||
<n-tabs :value="activeTab" type="line" animated @update:value="setActiveTab">
|
<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">
|
<div class="game-tab-content">
|
||||||
<UiBlock>
|
<UiBlock>
|
||||||
<n-flex justify="end">
|
<n-flex justify="end">
|
||||||
@@ -1079,7 +1142,7 @@ useSimplePolling(() => {
|
|||||||
</UiBlock>
|
</UiBlock>
|
||||||
</div>
|
</div>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="teams" tab="Команды">
|
<n-tab-pane v-if="canManageGame" name="teams" tab="Команды">
|
||||||
<div class="game-tab-content">
|
<div class="game-tab-content">
|
||||||
<n-flex justify="space-between" align="center">
|
<n-flex justify="space-between" align="center">
|
||||||
<n-tag :bordered="false" type="info"> Участвуют команд: {{ activeTeamCount }} </n-tag>
|
<n-tag :bordered="false" type="info"> Участвуют команд: {{ activeTeamCount }} </n-tag>
|
||||||
@@ -1104,7 +1167,7 @@ useSimplePolling(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="screens" tab="Экраны">
|
<n-tab-pane v-if="canManageGame" name="screens" tab="Экраны">
|
||||||
<div class="game-tab-content">
|
<div class="game-tab-content">
|
||||||
<GameScreensPanel
|
<GameScreensPanel
|
||||||
:screens="screens"
|
:screens="screens"
|
||||||
@@ -1122,7 +1185,7 @@ useSimplePolling(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="game" tab="Игра">
|
<n-tab-pane v-if="canManageGame" name="game" tab="Игра">
|
||||||
<div class="game-tab-content">
|
<div class="game-tab-content">
|
||||||
<UiBlock>
|
<UiBlock>
|
||||||
<n-flex justify="space-between">
|
<n-flex justify="space-between">
|
||||||
@@ -1224,7 +1287,7 @@ useSimplePolling(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="payment" tab="Оплата">
|
<n-tab-pane v-if="canManageGame" name="payment" tab="Оплата">
|
||||||
<div class="game-tab-content">
|
<div class="game-tab-content">
|
||||||
<UiBlock>
|
<UiBlock>
|
||||||
<n-flex align="center" justify="space-between">
|
<n-flex align="center" justify="space-between">
|
||||||
@@ -1252,7 +1315,7 @@ useSimplePolling(() => {
|
|||||||
</UiBlock>
|
</UiBlock>
|
||||||
</div>
|
</div>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="answers" tab="Проверка ответов">
|
<n-tab-pane v-if="canManageGame" name="answers" tab="Проверка ответов">
|
||||||
<div class="game-tab-content">
|
<div class="game-tab-content">
|
||||||
<div v-if="answersQuestions.length == 0" class="answers-empty">
|
<div v-if="answersQuestions.length == 0" class="answers-empty">
|
||||||
Вопросы дела не заданы или команды ещё не отвечали.
|
Вопросы дела не заданы или команды ещё не отвечали.
|
||||||
@@ -1284,14 +1347,34 @@ useSimplePolling(() => {
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
</div>
|
</div>
|
||||||
</n-tab-pane>
|
</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>
|
</n-tabs>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<n-card
|
||||||
style="width: 600px"
|
style="width: 600px"
|
||||||
title="Настройки игры"
|
title="Настройки игры"
|
||||||
@@ -1354,7 +1437,7 @@ useSimplePolling(() => {
|
|||||||
</n-modal>
|
</n-modal>
|
||||||
|
|
||||||
<!-- Окно создания команды -->
|
<!-- Окно создания команды -->
|
||||||
<n-modal v-model:show="showAddTeamModal">
|
<n-modal v-if="canManageGame" v-model:show="showAddTeamModal">
|
||||||
<n-card
|
<n-card
|
||||||
style="width: 600px"
|
style="width: 600px"
|
||||||
title="Создание команды"
|
title="Создание команды"
|
||||||
@@ -1392,7 +1475,7 @@ useSimplePolling(() => {
|
|||||||
</n-modal>
|
</n-modal>
|
||||||
|
|
||||||
<!-- Окно создания команды -->
|
<!-- Окно создания команды -->
|
||||||
<n-modal v-model:show="showDeleteTeamModal">
|
<n-modal v-if="canManageGame" v-model:show="showDeleteTeamModal">
|
||||||
<n-card
|
<n-card
|
||||||
style="width: 600px"
|
style="width: 600px"
|
||||||
title="Удаление команды"
|
title="Удаление команды"
|
||||||
@@ -1423,7 +1506,7 @@ useSimplePolling(() => {
|
|||||||
</n-modal>
|
</n-modal>
|
||||||
|
|
||||||
<!-- Окно удаления игры -->
|
<!-- Окно удаления игры -->
|
||||||
<n-modal v-model:show="showDeleteGameModal">
|
<n-modal v-if="canManageGame" v-model:show="showDeleteGameModal">
|
||||||
<n-card
|
<n-card
|
||||||
style="width: 600px"
|
style="width: 600px"
|
||||||
title="Удаление игры"
|
title="Удаление игры"
|
||||||
@@ -1457,7 +1540,7 @@ useSimplePolling(() => {
|
|||||||
</n-card>
|
</n-card>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|
||||||
<n-modal v-model:show="showQR">
|
<n-modal v-if="canManageGame" v-model:show="showQR">
|
||||||
<n-card
|
<n-card
|
||||||
style="width: 600px"
|
style="width: 600px"
|
||||||
:title="titleQR"
|
:title="titleQR"
|
||||||
@@ -1479,7 +1562,7 @@ useSimplePolling(() => {
|
|||||||
</n-card>
|
</n-card>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|
||||||
<n-modal v-model:show="showReviewsModal">
|
<n-modal v-if="canManageGame" v-model:show="showReviewsModal">
|
||||||
<n-card
|
<n-card
|
||||||
style="width: min(94vw, 800px)"
|
style="width: min(94vw, 800px)"
|
||||||
title="Отзывы игры"
|
title="Отзывы игры"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -34,6 +34,7 @@ const games = ref<Game[]>([])
|
|||||||
const uiStore = useUIStore()
|
const uiStore = useUIStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const binaryClient = getBinaryClient()
|
const binaryClient = getBinaryClient()
|
||||||
|
const canManageGame = computed(() => authStore.hasAnyRole(['admin', 'organizer', 'author']))
|
||||||
const canImport = computed(
|
const canImport = computed(
|
||||||
() =>
|
() =>
|
||||||
uiStore.environment === 'venue' && authStore.hasAnyRole(['admin', 'organizer', 'developer']),
|
uiStore.environment === 'venue' && authStore.hasAnyRole(['admin', 'organizer', 'developer']),
|
||||||
@@ -202,7 +203,7 @@ async function createGame() {
|
|||||||
<div class="center-block-custom">
|
<div class="center-block-custom">
|
||||||
<div class="width1200">
|
<div class="width1200">
|
||||||
<n-button v-if="canImport" @click="openImport">Импорт игры</n-button>
|
<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">
|
<div class="game-create-content">
|
||||||
<span class="game-create-plus">+</span>
|
<span class="game-create-plus">+</span>
|
||||||
<span>Создать игру</span>
|
<span>Создать игру</span>
|
||||||
@@ -226,7 +227,7 @@ async function createGame() {
|
|||||||
Начало: {{ formatGameDateTime(game.startAt) }}
|
Начало: {{ formatGameDateTime(game.startAt) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="game.location" class="game-date">Место: {{ game.location }}</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>
|
<n-button size="small" ghost @click.stop="toResults(game.ID!)">Результаты</n-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { after, test } from 'node:test'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import { createServer } from 'vite'
|
||||||
|
import { createRenderer, nextTick, ref } from 'vue'
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
root: fileURLToPath(new URL('../../..', import.meta.url)),
|
||||||
|
resolve: { alias: { '@': fileURLToPath(new URL('../..', import.meta.url)) } },
|
||||||
|
server: { middlewareMode: true, hmr: false, ws: false },
|
||||||
|
})
|
||||||
|
const { useGameState } = await server.ssrLoadModule('/src/composables/useGameState.ts')
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||||
|
after(async () => {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow)
|
||||||
|
else Reflect.deleteProperty(globalThis, 'window')
|
||||||
|
await server.close()
|
||||||
|
})
|
||||||
|
const renderer = createRenderer({
|
||||||
|
patchProp() {},
|
||||||
|
insert() {},
|
||||||
|
remove() {},
|
||||||
|
createElement: () => ({}),
|
||||||
|
createText: () => ({}),
|
||||||
|
createComment: () => ({}),
|
||||||
|
setText() {},
|
||||||
|
setElementText() {},
|
||||||
|
parentNode: () => null,
|
||||||
|
nextSibling: () => null,
|
||||||
|
})
|
||||||
|
const settle = async () => {
|
||||||
|
await nextTick()
|
||||||
|
await new Promise(setImmediate)
|
||||||
|
}
|
||||||
|
const response = (devices: unknown[] = []) =>
|
||||||
|
new Response(JSON.stringify({ devices, serverTime: '2026-10-11T10:00:00Z' }), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
test('counts, sorting, polling, late responses and recovery', async () => {
|
||||||
|
const active = ref(false)
|
||||||
|
const id = ref(1)
|
||||||
|
let state!: ReturnType<typeof useGameState>
|
||||||
|
let tick!: () => void
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
setInterval(callback: () => void) {
|
||||||
|
tick = callback
|
||||||
|
return 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
let calls = 0
|
||||||
|
let resolve!: (value: Response) => void
|
||||||
|
globalThis.fetch = async () => {
|
||||||
|
calls++
|
||||||
|
return new Promise<Response>((done) => {
|
||||||
|
resolve = done
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const app = renderer.createApp({
|
||||||
|
setup() {
|
||||||
|
state = useGameState(
|
||||||
|
() => id.value,
|
||||||
|
() => active.value,
|
||||||
|
)
|
||||||
|
return () => null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app.use(createPinia())
|
||||||
|
app.mount({})
|
||||||
|
try {
|
||||||
|
tick()
|
||||||
|
assert.equal(calls, 0)
|
||||||
|
active.value = true
|
||||||
|
assert.equal(calls, 1)
|
||||||
|
tick()
|
||||||
|
void state.reload()
|
||||||
|
assert.equal(calls, 1)
|
||||||
|
resolve(
|
||||||
|
response([
|
||||||
|
{ ID: 'b', connected: true, connections: [{}, {}] },
|
||||||
|
{ ID: 'z', connected: false },
|
||||||
|
{ ID: 'a', connected: false, connections: [{}] },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
await settle()
|
||||||
|
assert.deepEqual(
|
||||||
|
state.devices.value.map((d: { ID: string }) => d.ID),
|
||||||
|
['a', 'z', 'b'],
|
||||||
|
)
|
||||||
|
assert.deepEqual(state.summary.value, { connected: 1, disconnected: 2, connections: 3 })
|
||||||
|
globalThis.fetch = async () => {
|
||||||
|
throw new Error('offline')
|
||||||
|
}
|
||||||
|
await state.reload()
|
||||||
|
assert.equal(state.hasError.value, true)
|
||||||
|
assert.equal(state.devices.value.length, 3)
|
||||||
|
assert.ok(state.serverTime.value)
|
||||||
|
globalThis.fetch = async () => response()
|
||||||
|
await state.reload()
|
||||||
|
assert.equal(state.hasError.value, false)
|
||||||
|
assert.equal(state.devices.value.length, 0)
|
||||||
|
globalThis.fetch = async () => {
|
||||||
|
calls++
|
||||||
|
return new Promise<Response>((done) => {
|
||||||
|
resolve = done
|
||||||
|
})
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
active.value = false
|
||||||
|
resolve(response([{ ID: 'late' }]))
|
||||||
|
await settle()
|
||||||
|
assert.equal(state.devices.value.length, 0)
|
||||||
|
const stopped = calls
|
||||||
|
tick()
|
||||||
|
assert.equal(calls, stopped)
|
||||||
|
active.value = true
|
||||||
|
const oldResolve = resolve
|
||||||
|
id.value = 2
|
||||||
|
oldResolve(response([{ ID: 'old game' }]))
|
||||||
|
await settle()
|
||||||
|
assert.equal(state.devices.value.length, 0)
|
||||||
|
resolve(response([{ ID: 'new game', connected: true }]))
|
||||||
|
await settle()
|
||||||
|
assert.equal(state.devices.value[0].ID, 'new game')
|
||||||
|
tick()
|
||||||
|
app.unmount()
|
||||||
|
resolve(response([{ ID: 'destroyed' }]))
|
||||||
|
await settle()
|
||||||
|
assert.equal(state.devices.value[0].ID, 'new game')
|
||||||
|
} finally {
|
||||||
|
app.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { getAuthClient } from '@/api/auth_client'
|
||||||
|
import type { VenueDevice } from '@/api/generated/crabs/evening_detective_server'
|
||||||
|
import { useSimplePolling } from '@/composables/useSimplePolling'
|
||||||
|
|
||||||
|
export function useGameState(gameID: () => number, isActive: () => boolean) {
|
||||||
|
const client = getAuthClient()
|
||||||
|
const devices = ref<VenueDevice[]>([])
|
||||||
|
const serverTime = ref<string>()
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const hasError = ref(false)
|
||||||
|
let version = 0
|
||||||
|
let isDisposed = false
|
||||||
|
let isPending = false
|
||||||
|
const summary = computed(() => ({
|
||||||
|
connected: devices.value.filter((device) => device.connected).length,
|
||||||
|
disconnected: devices.value.filter((device) => !device.connected).length,
|
||||||
|
connections: devices.value.reduce(
|
||||||
|
(count, device) => count + (device.connections?.length || 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
if (!isActive() || isDisposed || isPending) return
|
||||||
|
const requestVersion = version
|
||||||
|
const id = gameID()
|
||||||
|
isPending = true
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await client.GetDevices({ gameID: id })
|
||||||
|
if (isDisposed || !isActive() || requestVersion !== version) return
|
||||||
|
devices.value = [...(result.devices || [])].sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(!!a.connected) - Number(!!b.connected) || (a.ID || '').localeCompare(b.ID || ''),
|
||||||
|
)
|
||||||
|
serverTime.value = result.serverTime
|
||||||
|
hasError.value = false
|
||||||
|
} catch {
|
||||||
|
if (!isDisposed && isActive() && requestVersion === version) hasError.value = true
|
||||||
|
} finally {
|
||||||
|
isPending = false
|
||||||
|
if (!isDisposed && requestVersion === version) isLoading.value = false
|
||||||
|
// A new context waits for the previous request before starting its own.
|
||||||
|
if (!isDisposed && isActive() && requestVersion !== version) void reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[gameID, isActive],
|
||||||
|
([id], [previousID]) => {
|
||||||
|
version++
|
||||||
|
isLoading.value = false
|
||||||
|
if (id !== previousID) {
|
||||||
|
devices.value = []
|
||||||
|
serverTime.value = undefined
|
||||||
|
hasError.value = false
|
||||||
|
}
|
||||||
|
if (isActive()) void reload()
|
||||||
|
},
|
||||||
|
{ flush: 'sync' },
|
||||||
|
)
|
||||||
|
useSimplePolling(() => {
|
||||||
|
void reload()
|
||||||
|
}, 5000)
|
||||||
|
onScopeDispose(() => {
|
||||||
|
isDisposed = true
|
||||||
|
version++
|
||||||
|
})
|
||||||
|
|
||||||
|
return { devices, serverTime, isLoading, hasError, summary, reload }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user