up
This commit is contained in:
@@ -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>
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
State,
|
||||
} from '../types/screen'
|
||||
|
||||
import ConnectionPanel from './ConnectionPanel.vue'
|
||||
import { modeLabels } from '../utils/presentation'
|
||||
import { useScreenTimer } from '../composables/useScreenTimer'
|
||||
|
||||
@@ -168,6 +169,15 @@ async function selectPackage(key: string) {
|
||||
}
|
||||
})
|
||||
}
|
||||
async function refreshPackages() {
|
||||
if (bridge) packages.value = await bridge.packages()
|
||||
}
|
||||
async function removePackage(key: string) {
|
||||
await run(async () => {
|
||||
await bridge?.onlineAction({ action: 'remove', key })
|
||||
await refreshPackages()
|
||||
})
|
||||
}
|
||||
async function refreshHost() {
|
||||
if (bridge) host.value = await bridge.host()
|
||||
}
|
||||
@@ -216,6 +226,11 @@ onUnmounted(() => {
|
||||
<main class="control">
|
||||
<h1>Вечерний детектив · Локальный проигрыватель</h1>
|
||||
<n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
|
||||
<connection-panel
|
||||
:output-open="state?.outputOpen ?? false"
|
||||
:selected-key="state?.catalog.key ?? null"
|
||||
@packages-changed="refreshPackages"
|
||||
/>
|
||||
<n-card title="Материалы показа">
|
||||
<n-select
|
||||
:value="state?.catalog.key ?? 'fixtures'"
|
||||
@@ -227,6 +242,14 @@ onUnmounted(() => {
|
||||
<n-alert v-if="state && !state.catalog.key" type="info"
|
||||
>Тестовый режим: синтетические аудио и видео.</n-alert
|
||||
>
|
||||
<n-space v-for="pkg in packages" :key="pkg.key">
|
||||
<span>{{ pkg.name }}</span>
|
||||
<n-button
|
||||
:disabled="isBusy || pkg.key === state?.catalog.key"
|
||||
@click="removePackage(pkg.key)"
|
||||
>Удалить материалы</n-button
|
||||
>
|
||||
</n-space>
|
||||
<p v-if="!packages.length">Готовых игровых пакетов на диске пока нет.</p>
|
||||
</n-card>
|
||||
<n-card v-if="bridge" title="Дисплей показа">
|
||||
@@ -243,6 +266,9 @@ onUnmounted(() => {
|
||||
<n-button :disabled="display === null || isBusy" @click="run(() => bridge!.show(display!))"
|
||||
>Открыть показ</n-button
|
||||
>
|
||||
<n-button v-if="state?.outputOpen" @click="run(() => bridge!.closeOutput())"
|
||||
>Закрыть показ</n-button
|
||||
>
|
||||
</n-space>
|
||||
</n-card>
|
||||
<template v-if="state">
|
||||
|
||||
Reference in New Issue
Block a user