Files
evening_detective_screen/src/components/ConnectionPanel.vue
T
2026-10-18 03:32:50 +07:00

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>