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>
+141
View File
@@ -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()
}
})
+73
View File
@@ -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 }
}