254 lines
10 KiB
Vue
254 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { NAlert, NButton, NCard, NInput, NSelect, NSpace, useMessage } from 'naive-ui'
|
|
import { onMounted, onUnmounted, ref } from 'vue'
|
|
import type { OnlineIntent, OnlineState } from '../types/online'
|
|
|
|
defineProps<{ outputOpen: boolean; selectedKey: string | null; selectedName: string | null }>()
|
|
const bridge = window.screenBridge
|
|
const message = useMessage()
|
|
const isReady = ref(false)
|
|
const state = ref<OnlineState>({
|
|
server: '',
|
|
authenticated: false,
|
|
remembered: false,
|
|
busy: false,
|
|
canCancel: false,
|
|
games: [],
|
|
screenId: null,
|
|
soundEnabled: null,
|
|
connection: 'disabled',
|
|
error: '',
|
|
notice: '',
|
|
progress: null,
|
|
preparation: { active: false, completed: 0, total: 0, downloadedBytes: 0, error: '' },
|
|
packagesRevision: 0,
|
|
})
|
|
const server = ref('')
|
|
const email = ref('')
|
|
const password = ref('')
|
|
const gameId = ref<number | null>(null)
|
|
const isBusy = ref(false)
|
|
const gameStatusLabels = new Map([
|
|
['draft', 'Черновик'],
|
|
['run', 'Идёт'],
|
|
['pause', 'Пауза'],
|
|
['finish', 'Завершена'],
|
|
])
|
|
const connectionLabels = {
|
|
disabled: 'Управление не подключено',
|
|
waiting: 'Ожидание ответа сервера',
|
|
connected: 'Связь с сервером есть',
|
|
offline: 'Нет связи; локальный показ продолжается',
|
|
expired: 'Сессия экрана недействительна; подключите управление заново',
|
|
}
|
|
let timer: ReturnType<typeof setInterval> | undefined
|
|
let refreshing = false
|
|
function accept(value: OnlineState) {
|
|
state.value = value
|
|
isReady.value = true
|
|
}
|
|
async function refresh() {
|
|
if (!bridge || refreshing) return
|
|
refreshing = true
|
|
try {
|
|
accept(await bridge.onlineState())
|
|
} catch {
|
|
/* Закрытие приложения не влияет на подготовленные материалы. */
|
|
} finally {
|
|
refreshing = false
|
|
}
|
|
}
|
|
async function run(intent: OnlineIntent) {
|
|
if (!bridge || isBusy.value) return
|
|
isBusy.value = true
|
|
try {
|
|
accept(await bridge.onlineAction(intent))
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : 'Действие не выполнено')
|
|
} finally {
|
|
if (intent.action === 'login') password.value = ''
|
|
isBusy.value = false
|
|
await refresh()
|
|
}
|
|
}
|
|
async function cancel() {
|
|
try {
|
|
await bridge?.onlineAction({ action: 'cancel' })
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : 'Не удалось отменить загрузку')
|
|
}
|
|
}
|
|
onMounted(async () => {
|
|
await refresh()
|
|
server.value = state.value?.server ?? ''
|
|
timer = setInterval(() => {
|
|
void refresh()
|
|
}, 500)
|
|
})
|
|
onUnmounted(() => clearInterval(timer))
|
|
</script>
|
|
|
|
<template>
|
|
<n-card v-if="bridge && isReady" title="Игра и сервер">
|
|
<n-space vertical>
|
|
<template v-if="!state.authenticated">
|
|
<label for="server">Адрес сервера</label>
|
|
<n-input
|
|
:input-props="{ id: 'server' }"
|
|
v-model:value="server"
|
|
placeholder="https://…"
|
|
:disabled="isBusy"
|
|
/>
|
|
<label for="email">Email</label>
|
|
<n-input
|
|
:input-props="{ id: 'email' }"
|
|
v-model:value="email"
|
|
placeholder="Email организатора"
|
|
:disabled="isBusy"
|
|
/>
|
|
<label for="password">Пароль</label>
|
|
<n-input
|
|
:input-props="{ id: 'password' }"
|
|
v-model:value="password"
|
|
type="password"
|
|
show-password-on="click"
|
|
:disabled="isBusy"
|
|
@keyup.enter="run({ action: 'login', server, email, password })"
|
|
/>
|
|
<n-button
|
|
:loading="isBusy"
|
|
:disabled="!server || !email || !password"
|
|
@click="run({ action: 'login', server, email, password })"
|
|
>Войти</n-button
|
|
>
|
|
</template>
|
|
<template v-else>
|
|
<p>{{ state.server }}</p>
|
|
<n-alert v-if="!state.remembered" type="warning"
|
|
>Хранилище секретов ОС недоступно. Вход действует до закрытия приложения; материалы
|
|
сохраняются.</n-alert
|
|
>
|
|
<n-space>
|
|
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'games' })"
|
|
>Обновить список игр</n-button
|
|
>
|
|
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'logout' })"
|
|
>Выйти</n-button
|
|
>
|
|
</n-space>
|
|
<n-select
|
|
v-model:value="gameId"
|
|
:options="
|
|
state.games.map((g) => ({
|
|
label: `${g.name} · ${gameStatusLabels.get(g.status) ?? 'Неизвестный статус'}`,
|
|
value: g.id,
|
|
}))
|
|
"
|
|
:disabled="isBusy || state.busy"
|
|
placeholder="Выберите игру для подготовки"
|
|
:input-props="{ 'aria-label': 'Игра на сервере' }"
|
|
/>
|
|
<p v-if="!state.games.length">Список игр пока пуст. Обновите его при доступном сервере.</p>
|
|
<n-button
|
|
:disabled="gameId === null || isBusy || state.busy"
|
|
@click="run({ action: 'connect', gameId: gameId! })"
|
|
>Подключить выбранную игру</n-button
|
|
>
|
|
<n-space>
|
|
<n-button
|
|
:disabled="gameId === null || isBusy || state.busy"
|
|
@click="run({ action: 'check', gameId: gameId! })"
|
|
>Проверить изменения</n-button
|
|
>
|
|
<n-button
|
|
:disabled="gameId === null || isBusy || state.busy"
|
|
@click="run({ action: 'download', gameId: gameId! })"
|
|
>Загрузить материалы</n-button
|
|
>
|
|
</n-space>
|
|
<p>
|
|
Новые материалы загружаются автоматически. Для принудительного обновления файлов по
|
|
прежним адресам закройте показ и загрузите материалы повторно.
|
|
</p>
|
|
<p>
|
|
Игру можно подключить без предварительной загрузки: недостающие файлы сохранятся
|
|
автоматически.
|
|
</p>
|
|
<p v-if="selectedKey" data-testid="control-game">Игра для управления: {{ selectedName }}</p>
|
|
<n-button
|
|
v-if="selectedKey"
|
|
:disabled="isBusy || state.busy"
|
|
@click="run({ action: 'connect' })"
|
|
>Подключить управление этой игрой</n-button
|
|
>
|
|
</template>
|
|
<p role="status">
|
|
{{ connectionLabels[state.connection]
|
|
}}{{ state.screenId ? ` · Экран №${state.screenId}` : '' }}
|
|
</p>
|
|
<p v-if="state.screenId && !outputOpen">
|
|
Связь и загрузка материалов продолжаются; показ откройте вручную.
|
|
</p>
|
|
<template v-if="state.screenId">
|
|
<p role="status" data-testid="sound-assignment">
|
|
{{
|
|
state.soundEnabled === null
|
|
? 'Назначение звука пока неизвестно'
|
|
: state.soundEnabled
|
|
? 'Экран назначен для звука'
|
|
: 'Экран не назначен для звука'
|
|
}}
|
|
<template
|
|
v-if="state.soundEnabled !== null && (!outputOpen || state.connection !== 'connected')"
|
|
>
|
|
· Последнее подтверждённое состояние; сейчас назначение не проверяется.
|
|
</template>
|
|
</p>
|
|
<p>Переключатель «Выключить весь звук» управляет локальным звуком отдельно.</p>
|
|
</template>
|
|
<n-space v-if="state.screenId">
|
|
<n-button
|
|
:disabled="isBusy || state.busy || !state.authenticated"
|
|
@click="run({ action: 'sound' })"
|
|
>Назначить звук этому экрану</n-button
|
|
>
|
|
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'results' })"
|
|
>Обновить снимок результатов</n-button
|
|
>
|
|
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'disconnect' })"
|
|
>Отключить управление</n-button
|
|
>
|
|
</n-space>
|
|
<p v-if="state.screenId">
|
|
«Отключить управление» прекращает управление приложением; привязка экрана на сервере
|
|
сохраняется.
|
|
</p>
|
|
<p v-if="state.preparation.active" role="status" data-testid="materials-progress">
|
|
Подготовка материалов: {{ state.preparation.completed }} /
|
|
{{ state.preparation.total }} файлов; текущая загрузка
|
|
{{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ.
|
|
</p>
|
|
<n-alert v-if="state.preparation.error" type="warning">
|
|
{{ state.preparation.error }}. Прежний показ сохраняется до готовности замены.
|
|
<n-button
|
|
:disabled="isBusy || state.busy || state.preparation.active"
|
|
@click="run({ action: 'retry-materials' })"
|
|
>Повторить подготовку</n-button
|
|
>
|
|
</n-alert>
|
|
<n-alert v-if="state.error" type="error">{{ state.error }}</n-alert>
|
|
<n-alert v-if="state.notice" type="info">{{ state.notice }}</n-alert>
|
|
<template v-if="state.busy">
|
|
<p role="status">
|
|
{{
|
|
state.progress
|
|
? `Файлов: ${state.progress.completedFiles}/${state.progress.totalFiles}, загружено ${(state.progress.downloadedBytes / 1048576).toFixed(1)} МиБ`
|
|
: 'Выполняется запрос…'
|
|
}}
|
|
</p>
|
|
<n-button v-if="state.canCancel" @click="cancel">Отменить загрузку</n-button>
|
|
</template>
|
|
</n-space>
|
|
</n-card>
|
|
</template>
|