This commit is contained in:
2026-10-18 14:15:43 +07:00
parent 0cd736f682
commit 913b237cb8
14 changed files with 1042 additions and 100 deletions
+399 -44
View File
@@ -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;
+9 -2
View File
@@ -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="
+3
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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>
}