This commit is contained in:
2026-10-17 20:16:24 +07:00
parent f5dc9c1e5b
commit 40456eebf1
16 changed files with 2122 additions and 54 deletions
+198
View File
@@ -0,0 +1,198 @@
<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 }>()
const emit = defineEmits<{ packagesChanged: [] }>()
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,
connection: 'disabled',
error: '',
notice: '',
progress: null,
packagesRevision: 0,
})
const server = ref('')
const email = ref('')
const password = ref('')
const gameId = ref<number | null>(null)
const isBusy = ref(false)
const connectionLabels = {
disabled: 'Управление не подключено',
waiting: 'Ожидание открытия показа',
connected: 'Связь с сервером есть',
offline: 'Нет связи; локальный показ продолжается',
expired: 'Сессия экрана недействительна; подключите управление заново',
}
let timer: ReturnType<typeof setInterval> | undefined
let refreshing = false
function accept(value: OnlineState) {
if (state.value && value.packagesRevision !== state.value.packagesRevision)
emit('packagesChanged')
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()
emit('packagesChanged')
}
}
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} · ${g.status}`, value: g.id }))"
:disabled="isBusy || state.busy"
placeholder="Выберите игру для подготовки"
:input-props="{ 'aria-label': 'Игра на сервере' }"
/>
<p v-if="!state.games.length">Список игр пока пуст. Обновите его при доступном сервере.</p>
<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>
<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>
<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>
<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>