From b05d55b3fa5b465b7196465ab4a0d05d8b3caa1a Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sun, 18 Oct 2026 12:42:04 +0700 Subject: [PATCH] up --- COMPLETED_TASKS.md | 11 +++ README.md | 10 +-- docs/EVENT_CHECKLIST.md | 4 +- docs/USER_GUIDE.md | 25 +++--- electron/online.test.ts | 4 + electron/online.ts | 12 ++- electron/playback-control.test.ts | 17 ++++ scripts/online-smoke.cjs | 101 +++++++++++++++++++-- scripts/smoke.cjs | 7 ++ src/components/ConnectionPanel.vue | 72 +++++---------- src/components/ControlPage.vue | 107 ++++++----------------- src/components/MusicPlanPanel.vue | 2 - src/components/ScreenConnectionPanel.vue | 25 +----- src/components/ScreenPlanPanel.vue | 60 +++++++------ src/utils/playbackControl.ts | 12 +++ 15 files changed, 261 insertions(+), 208 deletions(-) create mode 100644 electron/playback-control.test.ts create mode 100644 src/utils/playbackControl.ts diff --git a/COMPLETED_TASKS.md b/COMPLETED_TASKS.md index c77ed06..1f22eb6 100644 --- a/COMPLETED_TASKS.md +++ b/COMPLETED_TASKS.md @@ -456,3 +456,14 @@ - Проверка: 121 тест приложения, type-check, ESLint, Stylelint, build и Electron GUI smoke — PASS. GUI проверяет все пять карточек, CRUD, сохранение ввода при опросе, native MP3, назначение игре, отмену/сохранение порядка без смены файла/операции/позиции. Сервер go test ./... — PASS; отдельная локальная PostgreSQL-база проверила атомарность/конфликты, сохранение track ID/тайминга, совместные чтение и перестановку, параллельные загрузки и назначение звука двум экранам. Сайт: type-check, lint, lint:css, build-only — PASS. - Ревью: полный diff вручную проверен по Go/frontend стандартам, независимые ревью сервера и приложения учтены; git diff --check во всех трёх репозиториях — PASS. Документация и чек-лист обновлены. - Ограничения: рабочий сервер не развёртывался; для нового API нужен обновлённый бэкенд. Установочные архивы и аппаратная матрица не пересобирались/не проверялись. Коммиты и push не выполнялись. + +## SCREEN-023 — Сократить интерфейс пульта + +- Статус: `DONE` (2026-10-18). +- План: сохранить пять блоков и раскрытые редакторы; убрать пояснения, технические статусы и дубли команд, объединить подключение и кнопки воспроизведения по состоянию. +- Границы: только приложение экрана; сервер, API и начальную громкость не менять. +- Проверка: тесты, type-check, lint, lint:css, build и обновлённый Electron smoke. +- Результат: сохранены пять блоков и раскрытые редакторы; удалены постоянные инструкции, технические замеры, повторные статусы и ручные действия, уже выполняемые автоматически. Подключение выбранной/текущей подготовленной игры объединено. Для медиа одна кнопка запуска/паузы/продолжения, рядом «Стоп» и «Сначала»; для таймера одна кнопка состояния и сброс. Текущая локальная игра видна и после выхода. +- Ошибки и подтверждения сохранены; фоновое обновление библиотеки больше не стирает подробную ошибку пользовательского действия. Описание интерфейса обновлено в руководстве и чек-листе. +- Проверка: 127 тестов, type-check, ESLint, Stylelint, build и Electron GUI smoke — PASS. GUI проверяет пять блоков, отсутствие удалённых элементов, подключение обеих целей одной кнопкой, запуск/паузу/продолжение/повтор медиа, управление таймером, автоматические результаты, CRUD и порядок музыки, подробные ошибки и сохранение названия игры после выхода. +- Ревью: весь diff проверен вручную по frontend-стандарту; git diff --check — PASS. Изменения только в evening_detective_screen; сервер, API, зависимости и громкость не менялись. Установочные архивы не пересобирались, аппаратная матрица не проверялась; commit/push не выполнялись. diff --git a/README.md b/README.md index 8d11269..f093e10 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ npm run dev В блоке «Сервер» доступны адрес и вход; в «Игра» — выбор игры и подготовка материалов. Для постоянной связи войдите, выберите игру в серверном списке и нажмите -«Подключить выбранную игру». Готовый пакет заранее не требуется: сначала сохраняются +«Подключить». Готовый пакет заранее не требуется: сначала сохраняются файлы текущего показа и трека, затем вся игра и связанные плейлисты. Опрос продолжается при закрытом окне. Окно показа открывается вручную; при открытии и восстановлении сети принимается актуальное состояние сервера, включая пуск медиа. @@ -200,9 +200,8 @@ URL, независимо от исходного имени файла. Лок В «План музыки» доступны плейлисты, загрузка MP3 до 64 МиБ и порядок треков ↑ / ↓. «Сохранить порядок» публикует перестановку, «Использовать в игре» назначает плейлист. -«Пуск с начала» и «Повторить с начала» запускают выбранный ресурс с нуля, -«Пауза» сохраняет фактическую позицию, «Продолжить» возобновляет её, «Стоп» -сбрасывает позицию. Перемотка сохраняет паузу/воспроизведение и ограничена реальной +Кнопка «Запуск» меняется на «Пауза» при воспроизведении и «Продолжить» на паузе. +«Сначала» запускает ресурс с нуля, «Стоп» останавливает и сбрасывает позицию. Перемотка сохраняет паузу/воспроизведение и ограничена реальной длительностью файла. Громкость не вызывает перемотку или повторный пуск. Следующий трек сохраняет состояние воспроизведения или паузы; по окончании трека переход выполняется локально. Повтор относится ко всему плейлисту; без повтора @@ -232,8 +231,7 @@ URL, независимо от исходного имени файла. Лок Проверки test и smoke запускайте последовательно: обе команды пересобирают `dist-electron`, из которого Electron загружает preload при открытии окна. -Программные замеры в пульте разделены по каналу и текущему виду медиа; они -не измеряют физический звук/первый кадр. Аппаратная приёмка, остальные ОС, +Программные замеры автоматических проверок не измеряют физический звук/первый кадр. Аппаратная приёмка, остальные ОС, реальные игровые материалы и p95 на M1 пока не подтверждены. Все восемь визуальных режимов доступны в SCREEN-005; авторизация и получение серверных команд остаются в SCREEN-006. diff --git a/docs/EVENT_CHECKLIST.md b/docs/EVENT_CHECKLIST.md index 6169e81..7ad8623 100644 --- a/docs/EVENT_CHECKLIST.md +++ b/docs/EVENT_CHECKLIST.md @@ -22,7 +22,7 @@ - [ ] В ОС выбран нужный аудиовыход. Пробный звук слышен физически, mute выключен, громкость основного канала и музыки подходит для помещения. - [ ] После подключения HDMI аудиовыход проверен повторно. -- [ ] Перед подключением к серверу проверено название в строке «Игра для управления»; +- [ ] Перед подключением к серверу проверено название в строке «Текущая игра»; подключено управление этой игрой, проверен номер экрана, связь с сервером и новая команда с сайта. Проверено назначение звука экрану и отдельно локальное выключение звука. @@ -51,7 +51,7 @@ ## После игры -- [ ] При доступной сети после завершения игры обновить снимок результатов, +- [ ] При доступной сети после завершения игры показать «Результаты», проверить таблицу и время снимка. - [ ] Остановить основной канал и музыку, закрыть показ и приложение. - [ ] Сохранить сведения о сбоях; не передавать пароли, токены и весь профиль. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index bb52842..4ae9d78 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -58,7 +58,7 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова Нажмите «Войти». Пароль вводите только в приложении, не отправляйте в отчёты. 2. В «Игра» при необходимости нажмите «Обновить список игр» и выберите игру в поле «Выберите игру для подготовки». -3. Нажмите «Подключить выбранную игру». Она станет игрой показа, даже если ещё +3. Нажмите «Подключить». Она станет игрой показа, даже если ещё не подготовлена на этом компьютере. Отдельно скачивать полный пакет не требуется. 4. Приложение получает состояние экрана и музыки раз в секунду, сначала скачивает нужные сейчас файлы, затем остальные материалы игры и связанные плейлисты. @@ -122,8 +122,8 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова | Управление | Действие | | --- | --- | -| «Пуск с начала», «Повторить с начала» | Запускает выбранный ресурс с нуля. | -| «Пауза», «Продолжить» | Сохраняет позицию и возобновляет с неё. | +| «Запуск» / «Пауза» / «Продолжить» | Одна кнопка запускает, приостанавливает или возобновляет ресурс по текущему состоянию. | +| «Сначала» | Запускает выбранный ресурс с нуля. | | «Стоп» | Останавливает и сбрасывает позицию. | | «Позиция, секунды» → «Перемотать» | Меняет позицию, сохраняя паузу или воспроизведение. | | «Громкость, %» | Меняет громкость отдельного канала. Начальное значение — 100%. | @@ -132,7 +132,7 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова | «Повторять плейлист» | После последнего трека возвращается к первому. | Вступление и видео управляются в активном шаге «Введение» или «Закрывашка»: -«Запуск», «Пауза», «Продолжить», «Стоп», «Повтор», громкость и перемотка. +кнопка запуска/паузы/продолжения, «Стоп», «Сначала», громкость и перемотка. Здесь же видны позиция, длительность, статус и ошибка воспроизведения. Файл определяется режимом шага; отдельного выбора медиа нет. Кнопки воспроизведения и перемотка доступны при открытом показе и выбранном файле. @@ -161,23 +161,22 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова - В редакторе плана добавьте шаг «Текст», заполните текст и сохраните план. Нажмите «Показать» у этого шага. Переносы строк сохраняются. - Для шага «Таймер» задайте надпись и длительность в секундах, сохраните план - и покажите шаг. В активном шаге доступны «Старт», «Пауза», «Продолжить» - и «Сбросить». При смене шага отсчёт продолжается в фоне; + и покажите шаг. В активном шаге одна кнопка меняется между «Старт», «Пауза» и «Продолжить»; + рядом доступно «Сбросить». При смене шага отсчёт продолжается в фоне; на нуле остаётся 00:00 без сигнала и автоматического перехода. -- После завершения игры при доступной сети нажмите «Обновить снимок результатов» - в «Игра» (кнопка доступна после подключения управления). - Покажите шаг «Результаты» и проверьте дату снимка. Офлайн показывается +- Покажите шаг «Результаты»: при подключённом управлении снимок обновляется + автоматически. Проверьте дату снимка. Офлайн показывается последний сохранённый снимок, а не обновляемые серверные результаты. Неудачное обновление сохраняет прежний снимок. «Закрыть показ» останавливает медиа, но **обычное закрытие не ставит таймер на паузу**. -При необходимости сначала нажмите «Пауза таймера». При смене пакета таймер сбрасывается. +При необходимости сначала нажмите «Пауза» в активном шаге таймера. При смене пакета таймер сбрасывается. ## Связь с сервером -Подключите выбранную игру кнопкой в «Игра». Для ранее сохранённой игры -можно использовать «Подключить управление этой игрой». Проверьте название -в строке «Игра для управления» и номер экрана. Подготовка файлов не требует +В «Игра» выберите игру и нажмите «Подключить». Если выбор пуст, эта же кнопка +подключает текущую подготовленную игру того сервера, на котором выполнен вход. +Проверьте название в строке «Текущая игра» и номер экрана. Подготовка файлов не требует открытого показа; само окно автоматически не открывается. Приложение постоянно принимает изменения сцены, текста, таймера и музыки. diff --git a/electron/online.test.ts b/electron/online.test.ts index 124d606..187dd90 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -1445,6 +1445,10 @@ test('редактор музыки: CRUD, native MP3, порядок, вали f.online.state.playlists[0]!.tracks.map((t) => t.id), [2, 1], ) + const conflict = f.online.state.error + f.model.playlistError = 0 + await f.online.act({ action: 'playlists' }) + assert.equal(f.online.state.error, conflict, 'background refresh preserves the action error') f.model.playlistError = 403 await assert.rejects(f.online.act({ action: 'playlist-create', name: 'Нет прав' }), /Нет доступа/) }) diff --git a/electron/online.ts b/electron/online.ts index 82b510d..4f99c4b 100644 --- a/electron/online.ts +++ b/electron/online.ts @@ -1096,14 +1096,18 @@ export class Online { return this.snapshot() } if (this.busy) throw new Error('Подождите завершения текущего действия') + const isBackgroundRefresh = data.action === 'playlists' this.state.busy = true - this.state.error = '' - this.state.notice = '' + if (!isBackgroundRefresh) { + this.state.error = '' + this.state.notice = '' + } try { await this.perform(data) } catch (error) { - this.state.error = error instanceof Error ? error.message : 'Действие не выполнено' - throw new Error(this.state.error) + const message = error instanceof Error ? error.message : 'Действие не выполнено' + if (!isBackgroundRefresh || !this.state.error) this.state.error = message + throw new Error(message) } finally { this.state.busy = false this.state.progress = null diff --git a/electron/playback-control.test.ts b/electron/playback-control.test.ts new file mode 100644 index 0000000..7ed5f74 --- /dev/null +++ b/electron/playback-control.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { playbackControl } from '../src/utils/playbackControl.js' +import type { ChannelState } from '../src/types/screen.js' + +for (const [desired, status, action, label] of [ + ['stopped', 'stopped', 'play', 'Запуск'], + ['playing', 'playing', 'pause', 'Пауза'], + ['playing', 'pending', 'pause', 'Пауза'], + ['paused', 'paused', 'resume', 'Продолжить'], + ['playing', 'error', 'play', 'Запуск'], + ['playing', 'ended', 'play', 'Запуск'], +] satisfies [ChannelState['desired'], ChannelState['status'], string, string][]) { + test(`кнопка проигрывателя ${desired}/${status}: ${action}`, () => { + assert.deepEqual(playbackControl({ desired, status }), { action, label }) + }) +} diff --git a/scripts/online-smoke.cjs b/scripts/online-smoke.cjs index 0644ab2..b89b023 100644 --- a/scripts/online-smoke.cjs +++ b/scripts/online-smoke.cjs @@ -250,7 +250,7 @@ module.exports = async function onlineSmoke({ await poll( () => evaluate( - `Array.from(document.querySelectorAll('.n-base-selection')).some(e => e.textContent.includes('Выберите игру для подготовки'))`, + `Array.from(document.querySelectorAll('.n-base-selection')).some(e => e.textContent.includes('Выберите игру'))`, ), 'server game selector', ) @@ -267,6 +267,32 @@ module.exports = async function onlineSmoke({ ), ['Сервер', 'Игра', 'Экран', 'План экранов', 'План музыки'], ) + assert.equal( + await evaluate(`document.querySelectorAll('details').length`), + 0, + 'no developer metrics', + ) + assert.equal( + await evaluate( + `document.body.textContent.includes('Автоматический сон') || document.body.textContent.includes('HDMI')`, + ), + false, + 'no permanent technical instructions', + ) + for (const label of [ + 'Проверить изменения', + 'Обновить снимок результатов', + 'Подключить выбранную игру', + 'Подключить управление этой игрой', + 'Повторить с начала', + ]) + assert.equal( + await evaluate( + `Array.from(document.querySelectorAll('button')).some(b => b.textContent.trim() === ${JSON.stringify(label)})`, + ), + false, + 'removed button: ' + label, + ) varyGames = true for (let refresh = 0; refresh < 5; refresh++) { await poll( @@ -312,13 +338,13 @@ module.exports = async function onlineSmoke({ await poll( () => evaluate(`(() => { - const select = Array.from(document.querySelectorAll('.n-base-selection')).find(e => e.textContent.includes('Выберите игру для подготовки')) + const select = Array.from(document.querySelectorAll('.n-base-selection')).find(e => e.textContent.includes('Выберите игру')) return !!select && !select.classList.contains('n-base-selection--disabled') && !document.body.textContent.includes('Список игр пока пуст') })()`), 'updated game list rendered', ) await evaluate( - `Array.from(document.querySelectorAll('.n-base-selection')).find(e => e.textContent.includes('Выберите игру для подготовки')).click()`, + `Array.from(document.querySelectorAll('.n-base-selection')).find(e => e.textContent.includes('Выберите игру')).click()`, ) await poll( () => @@ -361,7 +387,19 @@ module.exports = async function onlineSmoke({ 'anywhere', ) invalidStory = false - await act({ action: 'connect', gameId: 6 }) + await poll( + () => + evaluate(`(() => { + const button = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Подключить') + if (!button || button.disabled) return false + button.click(); return true + })()`), + 'single connect button', + ) + await poll( + async () => !!(await evaluate('window.screenBridge.onlineState()')).screenId, + 'selected game connected', + ) const packages = await evaluate('window.screenBridge.packages()') const pkg = packages.find((p) => p.name === 'HTTP smoke') assert.ok(pkg) @@ -444,7 +482,10 @@ module.exports = async function onlineSmoke({ async () => (await state()).channels.main.status === 'playing', 'new command after incident', ) - await act({ action: 'results' }) + remoteMode = 6 + currentCommand = 0 + version++ + await poll(async () => !!(await state()).results, 'results refresh automatically') assert.equal((await state()).results.teams[0].name, 'Детективы') assert.equal(JSON.stringify(await state()).includes('PRIVATE'), false) for (const method of [ @@ -592,6 +633,10 @@ module.exports = async function onlineSmoke({ await poll(async () => (await state()).timer.status === 'running', 'plan timer start') await clickPlan('Пауза') await poll(async () => (await state()).timer.status === 'paused', 'plan timer pause') + await clickPlan('Продолжить') + await poll(async () => (await state()).timer.status === 'running', 'plan timer resumes') + await clickPlan('Сбросить') + await poll(async () => (await state()).timer.status === 'stopped', 'plan timer resets') await clickPlan('Редактировать') await evaluate(`document.querySelector('[aria-label="Поднять шаг 3"]').click()`) await clickPlan('Сохранить план') @@ -664,7 +709,10 @@ module.exports = async function onlineSmoke({ 'delete confirmation ' + title, ) } - await clickMusic('Обновить плейлисты') + await poll( + async () => (await evaluate('window.screenBridge.onlineState()')).playlists.length > 0, + 'library loaded automatically', + ) await setInput('#new-playlist-name', 'Создан из приложения') await clickMusic('Создать плейлист') await poll(() => library.some((p) => p.name === 'Создан из приложения'), 'playlist created') @@ -740,9 +788,50 @@ module.exports = async function onlineSmoke({ assert.equal(afterOrder.desired, 'playing') assert.ok(afterOrder.position >= beforeOrder.position) console.log('SCREEN_MUSIC_PLAN_SMOKE_PASS') + await act({ action: 'disconnect' }) + await poll( + () => + evaluate(`(() => { + const select = document.querySelector('.connection-content .n-base-selection') + select?.dispatchEvent(new MouseEvent('mouseenter')) + const clear = select?.querySelector('.n-base-clear__clear') + if (!clear) return false + clear.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + clear.click(); return true + })()`), + 'clear game selection', + ) + await poll( + () => + evaluate( + `document.querySelector('.connection-content .n-base-selection')?.textContent.includes('Выберите игру')`, + ), + 'server selection cleared', + ) + await poll( + () => + evaluate(`(() => { + const button = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Подключить') + if (!button || button.disabled) return false + button.click(); return true + })()`), + 'connect current prepared game', + ) + await poll( + async () => (await evaluate('window.screenBridge.onlineState()')).connection === 'connected', + 'current game reconnected', + ) + assert.equal((await state()).catalog.name, 'HTTP smoke') await act({ action: 'logout' }) assert.equal((await evaluate('window.screenBridge.onlineState()')).authenticated, false) + await poll( + () => + evaluate( + `document.querySelector('[data-testid="control-game"]')?.textContent.includes('HTTP smoke')`, + ), + 'current local game stays visible after logout', + ) console.log('SCREEN_ONLINE_SMOKE_PASS', JSON.stringify({ polls, requests: requests.length })) } finally { server.closeAllConnections() diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index c656f11..45e0df2 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -442,6 +442,13 @@ app.whenReady().then(async () => { await poll(async () => (await state()).channels.main.volume === 42, 'plan media volume') assert.equal((await state()).channels.main.desired, 'paused') assert.equal((await state()).channels.music.desired, 'playing') + await clickMedia('Продолжить') + await poll( + async () => (await state()).channels.main.status === 'playing', + 'plan media resumes', + ) + await clickMedia('Сначала') + await poll(async () => (await state()).channels.main.position < 0.5, 'plan media restarts') await clickMedia('Стоп') await poll( async () => (await state()).channels.main.status === 'stopped', diff --git a/src/components/ConnectionPanel.vue b/src/components/ConnectionPanel.vue index 60b1ff7..87ed58d 100644 --- a/src/components/ConnectionPanel.vue +++ b/src/components/ConnectionPanel.vue @@ -1,5 +1,5 @@ diff --git a/src/components/ScreenPlanPanel.vue b/src/components/ScreenPlanPanel.vue index b6158db..050f64a 100644 --- a/src/components/ScreenPlanPanel.vue +++ b/src/components/ScreenPlanPanel.vue @@ -1,15 +1,20 @@