generated from VLADIMIR/template_frontend
up
This commit is contained in:
+109
-26
@@ -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="Отзывы игры"
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
@@ -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