up
This commit is contained in:
@@ -1,7 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NCard, NInput, NSelect } from 'naive-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { DEFAULT_SERVER, type OnlineIntent, type OnlineState } from '../types/online'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NCollapse,
|
||||
NCollapseItem,
|
||||
NInput,
|
||||
NSelect,
|
||||
NSpin,
|
||||
NTag,
|
||||
NTable,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
DEFAULT_SERVER,
|
||||
type GameMaterial,
|
||||
type OnlineIntent,
|
||||
type OnlineState,
|
||||
} from '../types/online'
|
||||
|
||||
const props = defineProps<{
|
||||
state: OnlineState
|
||||
@@ -11,17 +28,61 @@ const props = defineProps<{
|
||||
selectedName: string | null
|
||||
canConnectCurrent: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ action: [intent: OnlineIntent]; cancel: [] }>()
|
||||
const emit = defineEmits<{ action: [intent: OnlineIntent]; cancel: []; resetGame: [] }>()
|
||||
const bridge = window.screenBridge
|
||||
const message = useMessage()
|
||||
async function openMaterialsFolder() {
|
||||
if (!bridge || !props.selectedKey) return
|
||||
try {
|
||||
await bridge.openMaterialsFolder(props.selectedKey)
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : 'Не удалось открыть папку материалов')
|
||||
}
|
||||
}
|
||||
const materialLabels = {
|
||||
missing: 'Не загружен',
|
||||
loading: 'Загружается…',
|
||||
ready: 'Загружен',
|
||||
error: 'Ошибка',
|
||||
}
|
||||
const loadedMaterials = computed(
|
||||
() => props.state.materials.filter((m) => m.status === 'ready').length,
|
||||
)
|
||||
const materialGroups = computed(() => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ id: string; name: string; rows: { material: GameMaterial; name: string; order: number }[] }
|
||||
>()
|
||||
for (const material of props.state.materials) {
|
||||
for (const usage of material.usages) {
|
||||
let group = groups.get(usage.groupId)
|
||||
if (!group) {
|
||||
group = { id: usage.groupId, name: usage.groupName, rows: [] }
|
||||
groups.set(group.id, group)
|
||||
}
|
||||
group.rows.push({ material, name: usage.name, order: usage.order })
|
||||
}
|
||||
}
|
||||
return [...groups.values()]
|
||||
.sort((a, b) => Number(b.id === 'game') - Number(a.id === 'game'))
|
||||
.map((group) => ({ ...group, rows: group.rows.sort((a, b) => a.order - b.order) }))
|
||||
})
|
||||
const server = ref(DEFAULT_SERVER)
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const gameId = ref<number | null>(null)
|
||||
const showServerContent = computed(
|
||||
() =>
|
||||
!props.state.authenticated ||
|
||||
!props.state.remembered ||
|
||||
props.state.busy ||
|
||||
!!props.state.error ||
|
||||
!!props.state.notice,
|
||||
)
|
||||
watch(
|
||||
() => props.state.server,
|
||||
(value) => {
|
||||
server.value = value
|
||||
gameId.value = null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -29,9 +90,20 @@ watch(
|
||||
() => props.state.authenticated,
|
||||
() => {
|
||||
password.value = ''
|
||||
if (!props.state.authenticated) gameId.value = null
|
||||
},
|
||||
)
|
||||
watch(
|
||||
[
|
||||
() => props.state.currentGameId,
|
||||
() => props.state.server,
|
||||
() => props.state.authenticated,
|
||||
() => props.selectedKey,
|
||||
],
|
||||
() => {
|
||||
gameId.value = props.state.authenticated ? props.state.currentGameId : null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
function run(intent: OnlineIntent) {
|
||||
emit('action', intent)
|
||||
if (intent.action === 'login') password.value = ''
|
||||
@@ -45,17 +117,50 @@ const gameStatusLabels = new Map([
|
||||
['pause', 'Пауза'],
|
||||
['finish', 'Завершена'],
|
||||
])
|
||||
const connectionLabels = {
|
||||
disabled: 'Управление не подключено',
|
||||
waiting: 'Ожидание ответа сервера',
|
||||
connected: 'Связь с сервером есть',
|
||||
offline: 'Нет связи; локальный показ продолжается',
|
||||
expired: 'Сессия экрана недействительна; подключите управление заново',
|
||||
}
|
||||
const gameOptions = computed(() => {
|
||||
const options = props.state.games.map((g) => ({
|
||||
label: `${g.name} · ${gameStatusLabels.get(g.status) ?? 'Неизвестный статус'}`,
|
||||
value: g.id,
|
||||
}))
|
||||
const currentId = props.state.currentGameId
|
||||
if (currentId !== null && !options.some((option) => option.value === currentId))
|
||||
options.push({ label: props.selectedName ?? 'Текущая игра', value: currentId })
|
||||
return options
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<n-card v-if="bridge && isReady" title="Сервер">
|
||||
<div class="connection-content">
|
||||
<n-card v-if="bridge && isReady">
|
||||
<template #header>
|
||||
<div class="server-heading">
|
||||
<span>Сервер</span>
|
||||
<span v-if="state.authenticated" class="server-address" :title="state.server">
|
||||
{{ state.server }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<div class="server-actions">
|
||||
<n-tag
|
||||
role="status"
|
||||
data-testid="auth-status"
|
||||
:type="state.authenticated ? 'success' : 'error'"
|
||||
>
|
||||
{{ state.authenticated ? 'Вход выполнен' : 'Не выполнен' }}
|
||||
</n-tag>
|
||||
<n-button
|
||||
v-if="state.authenticated"
|
||||
size="small"
|
||||
:disabled="isBusy || state.busy"
|
||||
@click="run({ action: 'logout' })"
|
||||
>Выйти</n-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="showServerContent" class="connection-content">
|
||||
<div v-if="state.busy" class="request-status" role="status" data-testid="request-status">
|
||||
<n-spin size="small" />
|
||||
<span>{{ state.busyMessage || 'Выполнение запроса…' }}</span>
|
||||
</div>
|
||||
<template v-if="!state.authenticated">
|
||||
<label for="server">Адрес сервера</label>
|
||||
<n-input
|
||||
@@ -81,6 +186,7 @@ const connectionLabels = {
|
||||
@keyup.enter="run({ action: 'login', server, email, password })"
|
||||
/>
|
||||
<n-button
|
||||
class="auth-action"
|
||||
:loading="isBusy"
|
||||
:disabled="!server || !email || !password"
|
||||
@click="run({ action: 'login', server, email, password })"
|
||||
@@ -88,15 +194,10 @@ const connectionLabels = {
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>{{ state.server }}</p>
|
||||
<n-alert v-if="!state.remembered" type="warning"
|
||||
>Вход не сохранён: после перезапуска потребуется войти снова.</n-alert
|
||||
>
|
||||
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'logout' })"
|
||||
>Выйти</n-button
|
||||
>
|
||||
</template>
|
||||
<p role="status">{{ connectionLabels[state.connection] }}</p>
|
||||
<n-alert
|
||||
v-if="state.error"
|
||||
type="error"
|
||||
@@ -108,37 +209,59 @@ const connectionLabels = {
|
||||
</div>
|
||||
</n-card>
|
||||
<n-card v-if="bridge && isReady" title="Игра">
|
||||
<template #header-extra>
|
||||
<n-tag
|
||||
role="status"
|
||||
data-testid="game-connection-status"
|
||||
:type="state.connection === 'connected' ? 'success' : 'error'"
|
||||
>
|
||||
{{ state.connection === 'connected' ? 'Подключено' : 'Не подключено' }}
|
||||
</n-tag>
|
||||
</template>
|
||||
<div class="connection-content">
|
||||
<div v-if="selectedKey" class="current-game">
|
||||
<span data-testid="control-game">Текущая игра: {{ selectedName }}</span>
|
||||
<n-button :disabled="isBusy || state.busy" @click="emit('resetGame')"> Сбросить </n-button>
|
||||
</div>
|
||||
<template v-if="state.authenticated">
|
||||
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'games' })"
|
||||
>Обновить список игр</n-button
|
||||
>
|
||||
<n-select
|
||||
v-model:value="gameId"
|
||||
clearable
|
||||
: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>
|
||||
<div class="game-selection">
|
||||
<n-select
|
||||
class="game-select"
|
||||
v-model:value="gameId"
|
||||
clearable
|
||||
:options="gameOptions"
|
||||
:disabled="isBusy || state.busy"
|
||||
placeholder="Выберите игру"
|
||||
:input-props="{ 'aria-label': 'Игра на сервере' }"
|
||||
/>
|
||||
<n-button
|
||||
square
|
||||
aria-label="Обновить список игр"
|
||||
title="Обновить список игр"
|
||||
:disabled="isBusy || state.busy"
|
||||
@click="run({ action: 'games' })"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 7v5h-5M4 17v-5h5" />
|
||||
<path d="M6.1 6.1A8 8 0 0 1 20 12M4 12a8 8 0 0 0 13.9 5.9" />
|
||||
</svg>
|
||||
</n-button>
|
||||
</div>
|
||||
<p v-if="!gameOptions.length">Нет игр.</p>
|
||||
<n-button
|
||||
:disabled="(gameId === null && !canConnectCurrent) || isBusy || state.busy"
|
||||
@click="run(gameId === null ? { action: 'connect' } : { action: 'connect', gameId })"
|
||||
>Подключить</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="gameId === null || isBusy || state.busy"
|
||||
@click="run({ action: 'download', gameId: gameId! })"
|
||||
>Загрузить материалы</n-button
|
||||
>Подключиться</n-button
|
||||
>
|
||||
</template>
|
||||
<p v-if="selectedKey" data-testid="control-game">Текущая игра: {{ selectedName }}</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) }} МиБ
|
||||
@@ -158,12 +281,240 @@ const connectionLabels = {
|
||||
</p>
|
||||
<n-button v-if="state.canCancel" @click="cancel">Отменить загрузку</n-button>
|
||||
</template>
|
||||
<n-collapse v-if="selectedKey">
|
||||
<n-collapse-item
|
||||
:title="`Материалы — загружено ${loadedMaterials} из ${state.materials.length}`"
|
||||
name="materials"
|
||||
>
|
||||
<div class="materials-list">
|
||||
<n-button text size="small" @click="openMaterialsFolder">Открыть папку</n-button>
|
||||
<p v-if="!state.materials.length">В игре нет материалов.</p>
|
||||
<n-table
|
||||
v-if="state.materials.length"
|
||||
class="materials-table"
|
||||
size="small"
|
||||
:single-line="false"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Материал</th>
|
||||
<th scope="col" class="status-column">Статус</th>
|
||||
<th scope="col" class="action-column">
|
||||
<span class="visually-hidden">Загрузка</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-for="group in materialGroups" :key="group.id">
|
||||
<tr>
|
||||
<th scope="rowgroup" colspan="3">{{ group.name }}</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(row, index) in group.rows"
|
||||
:key="`${row.material.id}-${index}`"
|
||||
class="material-row"
|
||||
>
|
||||
<td class="material-name">
|
||||
{{ row.name }}
|
||||
<span v-if="row.material.status === 'error'" class="material-error">{{
|
||||
row.material.error
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="material-status-cell">
|
||||
<span
|
||||
role="status"
|
||||
:aria-label="materialLabels[row.material.status]"
|
||||
:title="materialLabels[row.material.status]"
|
||||
class="material-state"
|
||||
:class="{
|
||||
'material-ready': row.material.status === 'ready',
|
||||
'material-unavailable': row.material.status !== 'ready',
|
||||
}"
|
||||
>
|
||||
<n-spin v-if="row.material.status === 'loading'" size="small" />
|
||||
<svg
|
||||
v-else
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path v-if="row.material.status === 'ready'" d="m5 12 4 4L19 6" />
|
||||
<path v-else d="m6 6 12 12M6 18 18 6" />
|
||||
</svg>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<n-button
|
||||
v-if="row.material.status === 'missing' || row.material.status === 'error'"
|
||||
square
|
||||
size="small"
|
||||
:disabled="isBusy || state.busy"
|
||||
:aria-label="`${row.material.status === 'error' ? 'Повторить загрузку' : 'Загрузить'}: ${row.name}`"
|
||||
:title="row.material.status === 'error' ? 'Повторить загрузку' : 'Загрузить'"
|
||||
@click="
|
||||
run({
|
||||
action: 'download-material',
|
||||
key: selectedKey!,
|
||||
assetId: row.material.id,
|
||||
})
|
||||
"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 3v12m-5-5 5 5 5-5M5 16v5h14v-5" />
|
||||
</svg>
|
||||
</n-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</n-table>
|
||||
<div class="materials-actions">
|
||||
<n-button
|
||||
:disabled="
|
||||
!state.authenticated || state.currentGameId === null || isBusy || state.busy
|
||||
"
|
||||
@click="run({ action: 'download', gameId: state.currentGameId! })"
|
||||
>Загрузить материалы</n-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
<p v-if="!state.authenticated">Войдите на сервер, чтобы выбрать игру.</p>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.materials-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.materials-list > button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.materials-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.status-column {
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.action-column {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.material-name {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.material-error {
|
||||
color: #d03050;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.material-status-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.material-state {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.material-ready {
|
||||
color: #18a058;
|
||||
}
|
||||
|
||||
.material-unavailable {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.materials-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.current-game {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.current-game > span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.current-game > button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.game-selection {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.game-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.request-status {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.server-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.server-heading {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.server-heading > span:first-child {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.server-address {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.connection-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -174,6 +525,10 @@ const connectionLabels = {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.connection-content > .auth-action {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.connection-error {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
|
||||
@@ -203,6 +203,13 @@ async function send(intent: Intent) {
|
||||
message.error(error instanceof Error ? error.message : 'Команда не выполнена')
|
||||
}
|
||||
}
|
||||
async function resetGame() {
|
||||
if (!bridge || isBusy.value || isOnlineBusy.value || onlineState.value.busy) return
|
||||
await run(async () => {
|
||||
acceptState(await bridge.selectPackage(null))
|
||||
onlineState.value = await bridge.onlineState()
|
||||
})
|
||||
}
|
||||
async function refreshHost() {
|
||||
if (!bridge) return
|
||||
host.value = await bridge.host()
|
||||
@@ -240,14 +247,14 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<main class="control">
|
||||
<h1>Вечерний детектив</h1>
|
||||
<n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
|
||||
<connection-panel
|
||||
:state="onlineState"
|
||||
:is-ready="isOnlineReady"
|
||||
:is-busy="isOnlineBusy"
|
||||
:is-busy="isOnlineBusy || isBusy"
|
||||
@action="runOnline"
|
||||
@cancel="cancelOnline"
|
||||
@reset-game="resetGame"
|
||||
:selected-key="state?.catalog.key ?? null"
|
||||
:selected-name="state?.catalog.name ?? null"
|
||||
:can-connect-current="
|
||||
|
||||
@@ -8,9 +8,12 @@ export function useOnlineConnection() {
|
||||
const isReady = ref(false)
|
||||
const state = ref<OnlineState>({
|
||||
server: DEFAULT_SERVER,
|
||||
currentGameId: null,
|
||||
materials: [],
|
||||
authenticated: false,
|
||||
remembered: false,
|
||||
busy: false,
|
||||
busyMessage: '',
|
||||
canCancel: false,
|
||||
games: [],
|
||||
playlists: [],
|
||||
|
||||
+18
-1
@@ -8,7 +8,16 @@ export interface MusicPlaylist {
|
||||
tracks: { id: number; name: string }[]
|
||||
}
|
||||
|
||||
export interface GameMaterial {
|
||||
id: string
|
||||
name: string
|
||||
usages: { groupId: string; groupName: string; name: string; order: number }[]
|
||||
status: 'missing' | 'loading' | 'ready' | 'error'
|
||||
error: string
|
||||
}
|
||||
|
||||
export type OnlineIntent =
|
||||
| { action: 'download-material'; key: string; assetId: string }
|
||||
| { action: 'playlists' }
|
||||
| { action: 'playlist-create'; name: string }
|
||||
| { action: 'playlist-rename'; playlistId: number; name: string }
|
||||
@@ -25,9 +34,12 @@ export type OnlineIntent =
|
||||
| { action: 'remove'; key: string }
|
||||
export interface OnlineState {
|
||||
server: string
|
||||
currentGameId: number | null
|
||||
materials: GameMaterial[]
|
||||
authenticated: boolean
|
||||
remembered: boolean
|
||||
busy: boolean
|
||||
busyMessage: string
|
||||
canCancel: boolean
|
||||
playlists: MusicPlaylist[]
|
||||
games: { id: number; name: string; status: string }[]
|
||||
@@ -36,7 +48,12 @@ export interface OnlineState {
|
||||
connection: 'disabled' | 'waiting' | 'connected' | 'offline' | 'expired'
|
||||
error: string
|
||||
notice: string
|
||||
progress: { downloadedBytes: number; completedFiles: number; totalFiles: number } | null
|
||||
progress: {
|
||||
downloadedBytes: number
|
||||
completedFiles: number
|
||||
totalFiles: number
|
||||
assetId?: string | null
|
||||
} | null
|
||||
preparation: {
|
||||
active: boolean
|
||||
completed: number
|
||||
|
||||
@@ -160,4 +160,5 @@ export interface ScreenBridge {
|
||||
show: (displayId: number) => Promise<void>
|
||||
packages: () => Promise<PackageSummary[]>
|
||||
selectPackage: (key: string | null) => Promise<State>
|
||||
openMaterialsFolder: (key: string) => Promise<void>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user