This commit is contained in:
2026-10-19 01:39:44 +07:00
parent 426acbc389
commit aa48965268
5 changed files with 135 additions and 103 deletions
+13 -2
View File
@@ -259,7 +259,7 @@ const gameOptions = computed(() => {
data-testid="game-selection-status"
:type="selectedKey ? 'success' : 'error'"
>
{{ selectedKey ? 'Выбрана' : 'Не выбрана' }}
{{ selectedKey ? 'Игра выбрана' : 'Игра не выбрана' }}
</n-tag>
</template>
<div class="connection-content">
@@ -358,6 +358,16 @@ const gameOptions = computed(() => {
:title="`Материалы — загружено ${loadedMaterials} из ${state.materials.length}`"
name="materials"
>
<template #header-extra>
<n-button
data-testid="clear-games"
text
type="error"
size="small"
:disabled="cannotClearGames"
@click.stop="clearGames"
>Удалить все скачанные игры</n-button>
</template>
<div class="materials-list">
<p v-if="!state.materials.length">В игре нет материалов.</p>
<n-table
@@ -459,6 +469,7 @@ const gameOptions = computed(() => {
</n-collapse>
<p v-if="!state.authenticated">Войдите на сервер, чтобы выбрать игру.</p>
<n-button
v-if="!selectedKey"
class="clear-games"
data-testid="clear-games"
text
@@ -484,7 +495,7 @@ const gameOptions = computed(() => {
<style scoped>
.clear-games {
align-self: flex-start;
align-self: flex-end;
}
.materials-list {
+68 -61
View File
@@ -288,53 +288,6 @@ onUnmounted(() => {
</n-tag>
</template>
<div class="screen-grid">
<n-space vertical>
<n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert>
<n-select
v-model:value="display"
:options="host.displays.map((d) => ({ label: d.label, value: d.id }))"
placeholder="Выберите внешний дисплей"
:input-props="{ 'aria-label': 'Дисплей показа' }"
/>
<label for="audio-output-input">Вывод звука</label>
<n-select
id="audio-output"
:value="host.audioOutputId ?? ''"
:options="[
{ label: 'По умолчанию в системе', value: '' },
...(host.audioOutputs ?? []).map((device) => ({ label: device.label, value: device.id })),
]"
:disabled="isBusy || isCheckingSound"
:input-props="{ id: 'audio-output-input', 'aria-label': 'Вывод звука' }"
@update:value="selectAudioOutput"
/>
<n-alert v-if="host.audioOutputNotice" type="warning" role="status">{{ host.audioOutputNotice }}</n-alert>
<n-space align="center">
<n-button
data-testid="check-sound"
:disabled="isBusy || isCheckingSound"
:loading="isCheckingSound"
@click="checkSound"
>Проверка звука</n-button>
<n-button
v-if="state"
square
:type="state.muted ? 'error' : 'default'"
:ghost="state.muted"
:disabled="isBusy"
:aria-label="state.muted ? 'Включить весь звук' : 'Выключить весь звук'"
:title="state.muted ? 'Включить весь звук' : 'Выключить весь звук'"
:aria-pressed="state.muted"
@click="send({ action: 'mute', enabled: !state.muted })"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M11 5 6 9H3v6h3l5 4z" />
<path v-if="state.muted" d="m16 9 6 6m0-6-6 6" />
<path v-else d="M15 8a6 6 0 0 1 0 8M18 5a10 10 0 0 1 0 14" />
</svg>
</n-button>
</n-space>
</n-space>
<div v-if="state" class="screen-preview">
<img
v-if="previewURL"
@@ -353,18 +306,66 @@ onUnmounted(() => {
}}
</div>
</div>
</div>
<template #footer>
<n-space justify="end">
<n-button
v-if="!state?.outputOpen"
:disabled="display === null || isBusy"
@click="run(() => bridge!.show(display!))"
>Открыть показ</n-button>
<n-button v-else :disabled="isBusy" @click="run(() => bridge!.closeOutput())"
>Закрыть показ</n-button>
<n-space vertical>
<n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert>
<label for="display-output-input">Вывод изображения</label>
<n-select
v-model:value="display"
:options="host.displays.map((d) => ({ label: d.label, value: d.id }))"
placeholder="Выберите внешний дисплей"
:input-props="{ id: 'display-output-input', 'aria-label': 'Дисплей показа' }"
/>
<label for="audio-output-input">Вывод звука</label>
<n-select
id="audio-output"
:value="host.audioOutputId ?? ''"
:options="[
{ label: 'По умолчанию в системе', value: '' },
...(host.audioOutputs ?? []).map((device) => ({ label: device.label, value: device.id })),
]"
:disabled="isBusy || isCheckingSound"
:input-props="{ id: 'audio-output-input', 'aria-label': 'Вывод звука' }"
@update:value="selectAudioOutput"
/>
<n-alert v-if="host.audioOutputNotice" type="warning" role="status">{{ host.audioOutputNotice }}</n-alert>
<div class="screen-actions">
<n-space align="center">
<n-button
data-testid="check-sound"
:disabled="isBusy || isCheckingSound"
:loading="isCheckingSound"
@click="checkSound"
>Проверка звука</n-button>
<n-button
v-if="state"
square
:type="state.muted ? 'error' : 'default'"
:ghost="state.muted"
:disabled="isBusy"
:aria-label="state.muted ? 'Включить весь звук' : 'Выключить весь звук'"
:title="state.muted ? 'Включить весь звук' : 'Выключить весь звук'"
:aria-pressed="state.muted"
@click="send({ action: 'mute', enabled: !state.muted })"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M11 5 6 9H3v6h3l5 4z" />
<path v-if="state.muted" d="m16 9 6 6m0-6-6 6" />
<path v-else d="M15 8a6 6 0 0 1 0 8M18 5a10 10 0 0 1 0 14" />
</svg>
</n-button>
</n-space>
<div class="output-toggle">
<n-button
v-if="!state?.outputOpen"
:disabled="display === null || isBusy"
@click="run(() => bridge!.show(display!))"
>Открыть показ</n-button>
<n-button v-else :disabled="isBusy" @click="run(() => bridge!.closeOutput())"
>Закрыть показ</n-button>
</div>
</div>
</n-space>
</template>
</div>
</n-card>
<template v-if="state">
<section class="presentation-grid">
@@ -515,9 +516,15 @@ onUnmounted(() => {
min-width: 0;
}
.screen-preview {
justify-self: center;
width: 85%;
.screen-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.output-toggle {
margin-left: auto;
}
.preview {
+22 -3
View File
@@ -45,10 +45,22 @@ function run(command: GameCommand) {
</script>
<template>
<n-card title="Игра" data-testid="game-control">
<n-card data-testid="game-control">
<template #header>
<div class="game-heading">
<span>Игра</span>
<n-tag role="status" data-testid="game-state" :type="state.gameError ? 'warning' : status?.type ?? 'default'">
{{ !selectedKey ? 'Игра не выбрана' : status?.label ?? (!state.authenticated ? 'Требуется вход' : state.gameError ? 'Нет данных' : 'Загрузка…') }}
</n-tag>
</div>
</template>
<template #header-extra>
<n-tag role="status" data-testid="game-state" :type="state.gameError ? 'warning' : status?.type ?? 'default'">
{{ !selectedKey ? 'Игра не выбрана' : status?.label ?? (!state.authenticated ? 'Требуется вход' : state.gameError ? 'Нет данных' : 'Загрузка…') }}
<n-tag
role="status"
data-testid="game-connection-status"
:type="state.connection === 'connected' ? 'success' : 'error'"
>
{{ state.connection === 'connected' ? 'Подключено' : 'Не подключено' }}
</n-tag>
</template>
<div class="game-grid">
@@ -80,6 +92,13 @@ function run(command: GameCommand) {
</template>
<style scoped>
.game-heading {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.game-grid {
align-items: start;
display: grid;
+30 -19
View File
@@ -8,7 +8,6 @@ import {
NProgress,
NSelect,
NSpace,
NSwitch,
useDialog,
} from 'naive-ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
@@ -229,25 +228,31 @@ function remove(trackId?: number) {
</template>
</n-space>
<div v-else class="music-plan-view" data-testid="music-plan-view">
<n-select
:value="channel.playlistId"
:options="localPlaylists.map((p) => ({ label: p.name, value: p.id }))"
:disabled="isBusy"
placeholder="Выберите плейлист"
:input-props="{ 'aria-label': 'Плейлист' }"
@update:value="
emit('command', { action: 'playlist', channel: 'music', playlistId: $event })
"
/>
<label class="music-loop"
>Повторять плейлист
<n-switch
:value="channel.loop"
<div class="playlist-controls">
<n-select
:value="channel.playlistId"
:options="localPlaylists.map((p) => ({ label: p.name, value: p.id }))"
:disabled="isBusy"
aria-label="Повторять плейлист"
@update:value="emit('command', { action: 'loop', channel: 'music', enabled: $event })"
placeholder="Выберите плейлист"
:input-props="{ 'aria-label': 'Плейлист' }"
@update:value="
emit('command', { action: 'playlist', channel: 'music', playlistId: $event })
"
/>
</label>
<n-button
square
:type="channel.loop ? 'primary' : 'default'"
:disabled="isBusy"
:aria-pressed="channel.loop"
aria-label="Повторять плейлист"
title="Повторять плейлист"
@click="emit('command', { action: 'loop', channel: 'music', enabled: !channel.loop })"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="m17 2 4 4-4 4M3 11V8a2 2 0 0 1 2-2h16M7 22l-4-4 4-4m14-1v3a2 2 0 0 1-2 2H3" />
</svg>
</n-button>
</div>
<p v-if="channel.source" class="current-track" data-testid="current-music-track">{{ channel.source.name }}</p>
<div class="track-actions">
<n-space align="center">
@@ -395,6 +400,13 @@ function remove(trackId?: number) {
gap: 12px;
}
.playlist-controls {
align-items: center;
display: grid;
gap: 8px;
grid-template-columns: minmax(0, 1fr) auto;
}
.track-list {
display: grid;
gap: 12px;
@@ -451,7 +463,6 @@ function remove(trackId?: number) {
overflow-wrap: anywhere;
}
.music-loop,
.track-volume {
align-items: center;
display: flex;
+2 -18
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton, NSpace, NTag } from 'naive-ui'
import { NButton, NSpace } from 'naive-ui'
import type { OnlineIntent, OnlineState } from '../types/online'
defineProps<{ state: OnlineState; isBusy: boolean }>()
const emit = defineEmits<{ action: [intent: OnlineIntent] }>()
@@ -9,16 +9,7 @@ function run(intent: OnlineIntent) {
</script>
<template>
<section class="screen-connection" aria-label="Экран игры">
<div class="screen-heading">
<span>Экран игры</span>
<n-tag
role="status"
data-testid="game-connection-status"
:type="state.connection === 'connected' ? 'success' : 'error'"
>
{{ state.connection === 'connected' ? 'Подключено' : 'Не подключено' }}
</n-tag>
</div>
<span>Экран игры</span>
<slot />
<n-space v-if="state.screenId" align="center">
<span>Экран №{{ state.screenId }}</span>
@@ -58,11 +49,4 @@ function run(intent: OnlineIntent) {
gap: 12px;
}
.screen-heading {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: space-between;
}
</style>