This commit is contained in:
2026-10-18 03:32:50 +07:00
parent 3e2608eae3
commit 9c5419b9bf
10 changed files with 73 additions and 193 deletions
+32
View File
@@ -265,3 +265,35 @@
сервер и физический проектор/звук не проверялись. Замена байтов по прежнему
URL требует ручной повторной загрузки; точность позиции ограничена API сервера.
Commit/push не выполнялись.
## SCREEN-013 — Убрать блок «Материалы показа»
- Статус: `DONE`.
- Дата: 2026-10-18.
- Результат: с пульта удалён блок выбора локального пакета, тестового режима
и удаления материалов. Убраны его обработчики, список пакетов и неиспользуемое
событие ConnectionPanel. В настройки ничего не переносилось.
- Подключение игры через сервер, автоматический кеш и воспроизведение с диска
сохранены. Руководство, README и инструкции приёмки обновлены; smoke выбирает
тестовый пакет через существующий IPC и проверяет отсутствие блока в интерфейсе.
- Проверки: type-check, lint, lint:css, сборка, Electron GUI smoke
(локальный показ, связь, дозагрузка, план и устойчивость) и git diff --check — PASS.
- Границы: изменения только evening_detective_screen. Архивы не пересобирались;
физический проектор не проверялся. Commit/push не выполнялись.
## SCREEN-014 — Настройка экранов только через план
- Статус: `DONE`.
- Дата: 2026-10-18.
- Результат: удалены блок «Текст и таймер», отдельный переключатель режима
и используемые только ими поля/обработчики. Режим, текст, подпись и длительность
таймера настраиваются в редакторе шагов. Пустой план предлагает создать план.
- Управление таймером сохранено в активном шаге; статус и остаток таймера показаны
рядом с планом, включая отсчёт в фоне. Предпросмотр, музыка, дисплей, API,
типы команд и серверная синхронизация сохранены.
- Проверки: type-check, lint, lint:css, сборка и Electron GUI smoke — PASS.
Проверены отсутствие удалённых элементов, редактирование текста/таймера,
сохранение, переходы, старт/пауза таймера, локальный показ и синхронизация.
33 локальные Markdown-ссылки и git diff --check — PASS.
- Границы: изменения только evening_detective_screen. Архивы не пересобирались;
физический проектор не проверялся. Commit/push не выполнялись.
+7 -8
View File
@@ -81,8 +81,7 @@ npm run dev
и назначьте экран компьютера основным. В блоке «Дисплей показа» выберите
внешний дисплей и нажмите «Открыть показ». Аудио и видео — синтетические локальные файлы длительностью
60 секунд; бинарники находятся в игнорируемом fixtures/ и не входят в Git.
Тестовый режим сохраняет отдельный выбор WAV/видео; игровые пакеты выбираются
в разделе «Материалы показа». В блоке «Игра и сервер» доступны вход, список игр и загрузка материалов.
В блоке «Игра и сервер» доступны вход, выбор игры и подготовка материалов.
Для постоянной связи войдите, выберите игру в серверном списке и нажмите
«Подключить выбранную игру». Готовый пакет заранее не требуется: сначала сохраняются
@@ -189,7 +188,7 @@ URL, независимо от исходного имени файла. Лок
## Локальный проигрыватель SCREEN-004
На пульте выберите готовый пакет или «Тестовый режим SCREEN-001», внешний дисплей
Подключите игру в блоке «Игра и сервер», выберите внешний дисплей
и откройте показ. В основном канале доступны вступление и заключительное видео;
их выбор останавливает прежнее основное медиа. Музыка воспроизводится отдельным
каналом без автоматического приглушения. Звук выводится только окном показа.
@@ -234,17 +233,17 @@ URL, независимо от исходного имени файла. Лок
## Пульт и режимы SCREEN-005
Пульт включает готовый пакет, план шагов, переключатель восьми режимов, текст,
таймер, предпросмотр и прежние независимые каналы медиа/музыки. План экранов редактируется в пульте и сохраняется на сервере выбранной игры;
Пульт включает подключение игры через сервер, план шагов с восемью режимами, текстом и параметрами таймера,
предпросмотр и прежние независимые каналы медиа/музыки. План экранов редактируется в пульте и сохраняется на сервере выбранной игры;
последняя подтверждённая копия хранится отдельно от пакета для офлайн-показа.
Онлайн-переход меняет общий шаг, офлайн-переход остаётся локальным и не отправляется
позже. Восстановление сети само не переключает показ. Выбор шага применяет его параметры без автоматического
позже. При восстановлении сети применяется актуальное состояние сервера. Выбор шага применяет его параметры без автоматического
запуска звука/таймера; окончание медиа не переключает шаг. Пуск основного ресурса
показывает соответствующий режим вступления или видео. Уход в другую сцену
останавливает основной канал, сохраняя независимую музыку.
Текст и параметры таймера редактируются как черновик; только «Показать текст» /
«Показать таймер» публикуют их. Текст отображается как обычный текст, без HTML.
Режим, текст и параметры таймера задаются только в редакторе шагов плана.
Сохранение черновика не меняет показ; для перехода нажмите «Показать» у шага. Текст отображается как обычный текст, без HTML.
Таймер вычисляется в основном процессе по монотонному deadline; интервалы только
обновляют снимок. Пауза сохраняет точный остаток, сброс возвращает полную длительность.
Таймер продолжает идти за другой сценой и при закрытом окне; смена пакета сбрасывает
+1 -1
View File
@@ -8,7 +8,7 @@
- [ ] Выбран архив своей ОС/архитектуры, SHA-256 совпал, приложение запускается.
- [ ] В «Игра и сервер» выполнен вход, нужная игра загружена до завершения.
- [ ] В «Материалы показа» выбрана именно игра, а не тестовый режим.
- [ ] В блоке «Игра и сервер» подключена нужная игра; подготовка материалов завершена.
- [ ] Проверены обложка, шаги, вступление, видео, текст, таймер и нужные плейлисты.
- [ ] Пробный показ и перезапуск без интернета подтвердили готовность локальной копии.
- [ ] Если нужны результаты, сохранён доступный снимок и проверена его дата.
+2 -2
View File
@@ -12,8 +12,8 @@ SCREEN-004 остаётся IN_PROGRESS до результатов владел
на основном дисплее компьютера. Ручная проверка не подставляет синтетический монитор.
3. Команда создаст отдельный временный каталог данных и пакет «Offline smoke»:
WAV-вступление, H.264/AAC-видео и два четырёхсекундных тональных трека.
Путь данных печатается как SCREEN004_ACCEPTANCE_READY. Откройте пакет через
«Материалы показа», выберите внешний дисплей и нажмите «Открыть показ».
Путь данных печатается как SCREEN004_ACCEPTANCE_READY. Пакет выбирается автоматически
проверочным сценарием; выберите внешний дисплей и нажмите «Открыть показ».
4. Отключите сеть после запуска. Не используйте --mute-audio для физической проверки.
Установите подходящую громкость и проверьте нужный аудиовыход ОС. Параметры
--no-sandbox/--disable-gpu, применённые в автоматическом smoke текущей среды,
+4 -3
View File
@@ -25,10 +25,11 @@ SCREEN-001/SCREEN-004 остаётся открытой; эта инструкц
При закрытом окне предпросмотр недоступен; скрытый/свёрнутый пульт не захватывает кадры.
- Проверьте обложку, затемнённый фон и кириллицу без интернета. На проекторе нет
кнопок, сообщений пульта, диагностических статусов и исходных путей файлов.
- Введите текст с переносами строк, кириллицей и `<script>текст</script>`.
До «Показать текст» сцена не меняется; после применения это обычный текст,
- В редакторе плана задайте текст шага с переносами строк, кириллицей и `<script>текст</script>`.
До перехода к сохранённому шагу сцена не меняется; после перехода это обычный текст,
без выполнения HTML/JavaScript.
- Настройте таймер на 10 секунд. «Показать таймер» применяет параметры без старта.
- В редакторе плана настройте таймерный шаг на 10 секунд и сохраните план.
«Показать» у шага применяет параметры без старта.
Проверьте старт, паузу, ожидание на паузе, продолжение и сброс. При переходе
к обложке отсчёт продолжается; при возвращении виден актуальный остаток.
В конце остаётся 00:00, без сигнала и автоматической смены режима.
+11 -14
View File
@@ -68,7 +68,6 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
включая воспроизведение. До готовности нового файла прежнее содержимое канала
сохраняется. Все медиа читаются только с локального диска.
6. Перед выездом дождитесь полной подготовки без ошибок и проверьте игру без сети.
«Материалы показа» позволяет выбирать ранее сохранённые игры и тестовый режим.
Офлайн доступны только фактически скачанные файлы.
Если ОС не предоставляет защищённое хранилище, приложение предупреждает:
@@ -83,7 +82,7 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
## Проведение показа
В «План экранов» нажмите «Показать» у нужного шага, используйте «Предыдущий шаг» / «Следующий шаг»
либо переключатель «Режим показа». Доступны восемь режимов: «Обложка»,
для переходов по плану. В редакторе шагов доступны восемь режимов: «Обложка»,
«Введение», «Успехов», «Закрывашка», «Спасибо за игру»,
«Результаты», «Таймер» и «Текст». Переход к шагу или режиму сам не запускает
медиа и таймер. Дождитесь готовности; отправленная команда ещё не означает
@@ -96,9 +95,8 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
режим, текст, надпись и длительность таймера. «Сохранить план» отправляет план
на сервер; «Отменить» возвращает сохранённый вариант. Для удаления всего плана
нажмите «Удалить план», затем «Сохранить план». При ошибке сохранения черновик
остаётся в редакторе. Изменения черновика и сохранение сами не меняют проектор. Публикация текста
кнопкой «Показать текст» использует подходящий текстовый шаг общего плана
или добавляет новый, поскольку сервер передаёт произвольный текст через план.
остаётся в редакторе. Изменения черновика и сохранение сами не меняют проектор.
Для показа сохранённого текста или таймера нажмите «Показать» у нужного шага.
План общий с сайтом. При параллельных правках последнее сохранение
заменяет его целиком. Во время редактирования фоновое обновление не меняет
@@ -134,15 +132,15 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
### Текст, таймер и результаты
- В «Текст и таймер» заполните «Текст для показа» и нажмите «Показать текст».
До нажатия введённый текст не применяется. Переносы строк сохраняются.
- Заполните «Подпись таймера» и «Длительность, секунды», нажмите «Показать таймер».
Затем используйте «Запустить таймер», «Пауза таймера», «Продолжить таймер»
и «Сбросить таймер». При смене режима отсчёт продолжается в фоне;
- В редакторе плана добавьте шаг «Текст», заполните текст и сохраните план.
Нажмите «Показать» у этого шага. Переносы строк сохраняются.
- Для шага «Таймер» задайте надпись и длительность в секундах, сохраните план
и покажите шаг. В активном шаге доступны «Старт», «Пауза», «Продолжить»
и «Сбросить». При смене шага отсчёт продолжается в фоне;
на нуле остаётся 00:00 без сигнала и автоматического перехода.
- После завершения игры при доступной сети нажмите «Обновить снимок результатов»
в «Игра и сервер» (кнопка доступна после подключения управления).
Выберите режим «Результаты» и проверьте дату снимка. Офлайн показывается
Покажите шаг «Результаты» и проверьте дату снимка. Офлайн показывается
последний сохранённый снимок, а не обновляемые серверные результаты.
Неудачное обновление сохраняет прежний снимок.
@@ -179,7 +177,7 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
| Проектор отключился, изменилось разрешение или произошёл сон | Прочитайте причину в пульте, восстановите дисплей/выход звука, выберите внешний экран и явно откройте показ. Медиа остановлены, работающий таймер сохранён на паузе. |
| Нет звука | Проверьте выход и mute ОС, кабель/USB/HDMI, «Выключить весь звук», громкость канала, открытое окно и статус проигрывателя. Для серверных команд проверьте назначение звука экрану. |
| Ошибка подготовки сцены | Проверьте сообщение и нажмите «Повторить подготовку». При повторении проверьте пакет до продолжения игры. |
| Пакет повреждён или отсутствует файл | При доступной сети закройте показ и загрузите материалы заново. Для удаления выберите другой пакет или тестовый режим, затем «Удалить материалы» у нужной игры. Без сети восстановить отсутствующие байты нельзя. |
| Пакет повреждён или отсутствует файл | При доступной сети нажмите «Повторить подготовку». Для полной повторной загрузки закройте показ и загрузите материалы заново. Без сети восстановить отсутствующие байты нельзя. |
| Истёк вход или сессия экрана | Повторно войдите или подключите управление по сообщению пульта. Готовые материалы остаются на диске. |
| Приложение перезапущено | Выбранная игра и сохранённые результаты восстанавливаются. Откройте показ вручную: онлайн применяется текущее состояние сервера, офлайн запускайте медиа локально. |
| Пульт пуст или зависает при запуске | Запишите ОС, версию, симптомы и сообщения запуска, передайте отчёт. Не считайте такой запуск успешным. |
@@ -203,8 +201,7 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
«Выйти» отключает серверное управление, но не удаляет материалы и не прерывает
локальное воспроизведение. Для завершения остановите медиа, закройте показ
и приложение. «Удалить материалы» используйте только для пакетов, которые
больше не нужны: это удаление локальной копии.
и приложение. Скачанные материалы сохраняются в локальном хранилище.
## Журналы и обращение за помощью
+9 -15
View File
@@ -214,6 +214,7 @@ app.whenReady().then(async () => {
'control UI',
)
if (manual) {
await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)
console.log(
'SCREEN004_ACCEPTANCE_READY',
JSON.stringify({ userData: app.getPath('userData'), electron: process.versions.electron }),
@@ -273,16 +274,8 @@ app.whenReady().then(async () => {
)
assert.deepEqual(range, { status: 206, size: 100 })
await command({ action: 'pause', channel: 'main' })
await evaluate("document.querySelector('.n-base-selection').click()")
await poll(
() =>
evaluate(`(() => {
const option = Array.from(document.querySelectorAll('.n-base-select-option')).find(el => el.textContent.includes('Offline smoke'))
if (!option) return false
option.click(); return true
})()`),
'UI package choice',
)
assert.equal(await evaluate('document.body.textContent.includes("Материалы показа")'), false)
await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)
await poll(async () => {
const s = await state()
return (
@@ -424,12 +417,13 @@ app.whenReady().then(async () => {
if (index === 7)
assert.ok(await inspect('document.body.textContent.includes("Кириллица: Ёжик и улика №7")'))
}
const publishedText = (await state()).scene.text
await evaluate(
`(() => { const input = document.querySelector('#scene-text'); input.value = 'Черновик ещё не показан'; input.dispatchEvent(new Event('input', {bubbles:true})) })()`,
assert.equal(
await evaluate(
`!!document.querySelector(${JSON.stringify('#scene-text, #timer-label, #timer-seconds, [aria-label="Режим показа"]')})`,
),
false,
)
await delay(100)
assert.equal((await state()).scene.text, publishedText)
assert.equal(await evaluate('document.body.textContent.includes("Текст и таймер")'), false)
const safeText = 'Привет\n<script>window.__unsafe = true</script>'
await command({ action: 'text', text: safeText })
await sceneReady(8)
-4
View File
@@ -4,7 +4,6 @@ import { onMounted, onUnmounted, ref } from 'vue'
import type { OnlineIntent, OnlineState } from '../types/online'
defineProps<{ outputOpen: boolean; selectedKey: string | null; selectedName: string | null }>()
const emit = defineEmits<{ packagesChanged: [] }>()
const bridge = window.screenBridge
const message = useMessage()
const isReady = ref(false)
@@ -45,8 +44,6 @@ const connectionLabels = {
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
}
@@ -72,7 +69,6 @@ async function run(intent: OnlineIntent) {
if (intent.action === 'login') password.value = ''
isBusy.value = false
await refresh()
emit('packagesChanged')
}
}
async function cancel() {
+6 -145
View File
@@ -4,7 +4,6 @@ import {
NButton,
NCard,
NInputNumber,
NInput,
NSelect,
NSpace,
NSwitch,
@@ -16,10 +15,8 @@ import type {
Channel,
HostInfo,
Intent,
PackageSummary,
Playback,
PreviewFrame,
ScreenMode,
State,
ScreenStep,
PlanStatus,
@@ -27,7 +24,6 @@ import type {
import ConnectionPanel from './ConnectionPanel.vue'
import ScreenPlanPanel from './ScreenPlanPanel.vue'
import { modeLabels } from '../utils/presentation'
import { useScreenTimer } from '../composables/useScreenTimer'
const message = useMessage()
@@ -40,7 +36,6 @@ const originalPlan = ref('')
const isPlanDirty = computed(() => JSON.stringify(planDraft.value) !== originalPlan.value)
const bridge = window.screenBridge
const state = ref<State | null>(null)
const packages = ref<PackageSummary[]>([])
const host = ref<HostInfo>({
displays: [],
notice: '',
@@ -50,15 +45,8 @@ const host = ref<HostInfo>({
const display = ref<number | null>(null)
const positions = ref<Record<Channel, number | null>>({ main: 10, music: 10 })
const isBusy = ref(false)
const textDraft = ref('')
const timerLabel = ref('Перерыв')
const timerSeconds = ref<number | null>(300)
const timerDisplay = useScreenTimer(() => state.value)
const preview = ref<PreviewFrame | null>(null)
const modeOptions = Object.entries(modeLabels).map(([key, label]) => ({
label,
value: Number(key) as ScreenMode,
}))
const previewURL = computed(() =>
preview.value?.session === state.value?.session &&
preview.value?.revision === state.value?.scene.revision
@@ -110,10 +98,6 @@ const channels = ['main', 'music'] as const
const playlist = computed(() =>
state.value?.catalog.playlists.find((p) => p.id === state.value?.channels.music.playlistId),
)
const packageOptions = computed(() => [
{ label: 'Тестовый режим SCREEN-001', value: 'fixtures' },
...packages.value.map((p) => ({ label: p.name, value: p.key })),
])
const statusLabels: Record<Playback, string> = {
idle: 'Ресурс не выбран',
pending: 'Подготовка или выполнение команды',
@@ -181,16 +165,6 @@ function acceptState(value: State) {
isPlanEditing.value = false
planDraft.value = []
}
if (value.session !== state.value?.session || value.scene.text !== state.value?.scene.text)
textDraft.value = value.scene.text
if (
value.session !== state.value?.session ||
value.timer.label !== state.value?.timer.label ||
value.timer.durationMs !== state.value?.timer.durationMs
) {
timerLabel.value = value.timer.label
timerSeconds.value = value.timer.durationMs / 1000
}
if (
!value.outputOpen ||
value.session !== state.value?.session ||
@@ -221,30 +195,6 @@ async function send(intent: Intent) {
message.error(error instanceof Error ? error.message : 'Команда не выполнена')
}
}
function selectPackage(key: string) {
discardPlan(() => {
void changePackage(key)
})
}
async function changePackage(key: string) {
await run(async () => {
if (!bridge) return
try {
acceptState(await bridge.selectPackage(key === 'fixtures' ? null : key))
} finally {
packages.value = await bridge.packages()
}
})
}
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) return
host.value = await bridge.host()
@@ -269,7 +219,6 @@ onMounted(async () => {
if (!bridge) throw new Error('Откройте пульт в приложении Electron')
unsubscribe = bridge.subscribe(acceptState)
acceptState(await bridge.state())
packages.value = await bridge.packages()
await refreshHost()
})
previewTimer = setInterval(() => {
@@ -301,29 +250,7 @@ onUnmounted(() => {
:output-open="state?.outputOpen ?? false"
:selected-key="state?.catalog.key ?? null"
:selected-name="state?.catalog.name ?? null"
@packages-changed="refreshPackages"
/>
<n-card title="Материалы показа">
<n-select
:value="state?.catalog.key ?? 'fixtures'"
:options="packageOptions"
:disabled="!bridge || isBusy || isPlanSaving"
:input-props="{ 'aria-label': 'Игра показа' }"
@update:value="selectPackage"
/>
<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="Дисплей показа">
<n-space vertical>
<n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert>
@@ -385,15 +312,13 @@ onUnmounted(() => {
@update-step="(index, patch) => Object.assign(planDraft[index]!, patch)"
@command="send"
/>
<label>Режим показа</label>
<n-select
:value="state.scene.mode"
:options="modeOptions"
:disabled="isBusy"
:input-props="{ 'aria-label': 'Режим показа' }"
@update:value="send({ action: 'mode', mode: $event })"
/>
<p role="status">{{ sceneLabels[state.scene.status] }}</p>
<p v-if="state.timer.durationMs > 0" role="status" class="timer-status">
{{ state.timer.label }} · {{ timerDisplay }} · {{ timerLabels[state.timer.status] }}
</p>
<p v-if="state.scene.mode !== 7 && state.timer.status === 'running'">
Таймер продолжает отсчёт в фоне.
</p>
<n-alert v-if="state.scene.error" type="error">{{ state.scene.error }}</n-alert>
<n-button
v-if="state.scene.status === 'error'"
@@ -429,70 +354,6 @@ onUnmounted(() => {
<p>Кадр реального окна показа, без второго звука.</p>
</n-card>
</section>
<n-card title="Текст и таймер">
<n-space vertical>
<label for="scene-text">Текст для показа</label>
<n-input
v-model:value="textDraft"
type="textarea"
:maxlength="10000"
:autosize="{ minRows: 2, maxRows: 6 }"
:input-props="{ id: 'scene-text' }"
/>
<n-button
:disabled="isBusy || !textDraft.trim()"
@click="send({ action: 'text', text: textDraft })"
>Показать текст</n-button
>
<label for="timer-label">Подпись таймера</label>
<n-input
v-model:value="timerLabel"
:maxlength="200"
:input-props="{ id: 'timer-label' }"
/>
<label for="timer-seconds">Длительность, секунды</label>
<n-input-number
v-model:value="timerSeconds"
:min="1"
:max="86400"
:precision="0"
:input-props="{ id: 'timer-seconds' }"
/>
<n-button
:disabled="isBusy || !timerSeconds"
@click="
send({ action: 'timer-set', label: timerLabel, durationSeconds: timerSeconds ?? 0 })
"
>Показать таймер</n-button
>
<p role="status" class="timer-status">
{{ state.timer.label }} · {{ timerDisplay }} · {{ timerLabels[state.timer.status] }}
</p>
<p v-if="state.scene.mode !== 7 && state.timer.status === 'running'">
Таймер продолжает отсчёт в фоне.
</p>
<n-space>
<n-button
:disabled="isBusy || state.timer.status === 'running'"
@click="send({ action: 'timer-start' })"
>Запустить таймер</n-button
>
<n-button
:disabled="isBusy || state.timer.status !== 'running'"
@click="send({ action: 'timer-pause' })"
>Пауза таймера</n-button
>
<n-button
:disabled="isBusy || state.timer.status !== 'paused'"
@click="send({ action: 'timer-resume' })"
>Продолжить таймер</n-button
>
<n-button :disabled="isBusy" @click="send({ action: 'timer-reset' })"
>Сбросить таймер</n-button
>
</n-space>
</n-space>
</n-card>
<label class="mute"
>Выключить весь звук
<n-switch
+1 -1
View File
@@ -110,7 +110,7 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
</n-space>
</template>
<template v-else>
<p v-if="!steps.length">План экранов ещё не создан. Используйте переключатель режимов.</p>
<p v-if="!steps.length">План экранов ещё не создан. Создайте план, чтобы настроить экраны.</p>
<n-space
v-for="(step, index) in steps"
:key="index"