feat: add night mode settings and validation for board devices

This commit is contained in:
2026-10-11 05:02:39 +03:00
parent 360b684019
commit e959f55ea1
6 changed files with 31 additions and 5 deletions

View File

@@ -396,3 +396,11 @@ MIT License - используйте свободно!
Табло опрашивает настройки раз в 15 секунд, отправляет сигнал работы раз в 30 и подтверждает применённую версию. «В сети» означает связь за последние 90 секунд. Офлайн-устройство продолжает работать со своим кешем, изменения остаются «Ожидают применения». Отзыв доступа действует после следующей связи с сервером. Команд перезагрузки ОС, удалённого shell или установки ПО нет.
Проверки: `npm test --prefix backend`, `npm run build --prefix frontend`. Отдельный PostgreSQL-тест устройств запускается с `BOARD_DEVICE_TEST_DATABASE_URL` (имя БД должно содержать `test`); он создаёт и удаляет изолированную схему, не использует рабочие таблицы. Совместный визуальный стенд находится в `ridebus-board/test/fixtures/device-flow-server.mjs` и использует явные тестовые данные.
### Ночной режим табло
В настройках устройства доступны `nightMode` (`auto` / `off`) и `nightWakeMinutes` (45 / 60). По умолчанию — автоматически, возврат за 60 минут. Обновлённый Board включает режим после завершения движения, если весь ночной перерыв до первого следующего рейса длиннее двух часов, только по полному действующему расписанию. Перезагрузка в середине перерыва сохраняет ночной экран до выбранного порога возврата 45/60 минут. Ночные продолжения рейсов, фильтр маршрута и отмены учитываются. В ночном экране остаются первый рейс, часы без секунд и сообщения о движении; цикл информации и рекламы приостанавливается. Дневная тема возвращается при выходе. Лимиты, привязка и подтверждение версии сохраняются.
Поля находятся в существующем JSONB настроек устройств; новая SQL-миграция не требуется. Старые сохранённые конфигурации нормализуются при чтении (`auto / 60`) и не изменяются в базе автоматически. Для редактирования этих полей разверните обновлённые API, frontend админки и Board вместе.
Блок информации «Дальше по маршруту» заменяет первый и последний рейс. Его переключатель сохраняет существующий ключ `infoServiceEnabled`: ранее отключённый блок остаётся отключённым, SQL-миграция и изменение JSON устройства не нужны. Схему следующих остановок и время пути строит обновлённый Board из полного кеша расписания; в админке изменено только название переключателя.

View File

@@ -11,7 +11,7 @@ function name(value) {
}
function deviceId(value) { if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) throw new DeviceError(400, 'invalid_device_id'); return value; }
function publicDevice(row, now) {
return { id: row.id, name: row.name, cityId: row.city_id, cityName: row.city_name, stopName: row.stop_name, stopDirection: row.stop_direction || '', settings: row.settings,
return { id: row.id, name: row.name, cityId: row.city_id, cityName: row.city_name, stopName: row.stop_name, stopDirection: row.stop_direction || '', settings: validSettings(row.settings),
revision: row.revision, appliedRevision: row.applied_revision, lastSeenAt: row.last_seen_at, deviceInfo: row.device_info, revokedAt: row.revoked_at,
online: !row.revoked_at && Boolean(row.last_seen_at && now - Date.parse(row.last_seen_at) < 90000) };
}

View File

@@ -3,6 +3,7 @@ const defaults = {
theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue',
infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true,
showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near',
nightMode: 'auto', nightWakeMinutes: 60,
adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.',
};
class DeviceError extends Error {
@@ -17,7 +18,8 @@ function validSettings(input) {
if (!Number.isInteger(value[key]) || value[key] < min || value[key] > max) fail(key);
}
for (const key of ['holdNear', 'infoServiceEnabled', 'infoWeatherEnabled', 'infoQrEnabled', 'showNextSameRoute', 'longWaitEnabled', 'adsEnabled']) if (typeof value[key] !== 'boolean') fail(key);
for (const [key, choices] of Object.entries({ theme: ['dark', 'light'], colorScheme: ['lime', 'amber', 'cyan', 'mint', 'lavender', 'white'], lightColorScheme: ['blue', 'forest', 'crimson', 'violet', 'teal', 'graphite'], displayProfile: ['tv', 'street'], viewingDistance: ['near', 'medium', 'far'] })) if (!choices.includes(value[key])) fail(key);
for (const [key, choices] of Object.entries({ theme: ['dark', 'light'], colorScheme: ['lime', 'amber', 'cyan', 'mint', 'lavender', 'white'], lightColorScheme: ['blue', 'forest', 'crimson', 'violet', 'teal', 'graphite'], displayProfile: ['tv', 'street'], viewingDistance: ['near', 'medium', 'far'], nightMode: ['off', 'auto'] })) if (!choices.includes(value[key])) fail(key);
if (![45, 60].includes(value.nightWakeMinutes)) fail('nightWakeMinutes');
if (typeof value.routeFilter !== 'string' || !/^(|[1-9]\d{0,8})$/.test(value.routeFilter)) fail('routeFilter');
for (const [key, max] of [['adTitle', 80], ['adText', 200]]) if (typeof value[key] !== 'string' || value[key].length > max) fail(key);
return value;

View File

@@ -49,3 +49,13 @@ test('remote settings are bounded, preserve supported palettes and cannot enable
for (const bad of [{ ...defaults, holdNear: 'yes' }, { ...defaults, adText: 'x'.repeat(201) }, { ...defaults, toString: 'bad' }, { ...defaults, stopId: NaN }, { ...defaults, routeFilter: '-1' }]) assert.throws(() => validSettings(bad), e => e.status === 400);
assert.equal(validSettings({ ...defaults, colorScheme: 'cyan' }).colorScheme, 'cyan');
});
test('night preferences validate, survive updates and migrate old stored device settings without a SQL migration', async () => {
const repo=memoryRepository(), service=createBoardDeviceService(repo);const p=await service.start({});const d=await service.claim(1,{code:p.code,name:'Night',settings:defaults},1);
delete repo.devices.get(d.id).settings.nightMode;delete repo.devices.get(d.id).settings.nightWakeMinutes;
assert.equal((await service.status(p.token)).device.settings.nightMode,'auto');assert.equal((await service.list(1))[0].settings.nightWakeMinutes,60);
assert.equal(repo.devices.get(d.id).settings.nightMode,undefined);
for(const settings of [{...defaults,nightMode:'always'},{...defaults,nightWakeMinutes:5}]) assert.throws(()=>validSettings(settings),e=>e.status===400);
await service.update(d.id,1,{name:'Night',revision:1,settings:{...defaults,nightMode:'off',nightWakeMinutes:45}},1);
const saved=(await service.status(p.token)).device;assert.equal(saved.settings.nightMode,'off');assert.equal(saved.settings.nightWakeMinutes,45);assert.equal(saved.revision,2);
});

View File

@@ -1 +1,3 @@
.devices-page{max-width:1200px;margin:auto;color:#17251d}.devices-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:28px}.devices-heading h1{font-size:32px;font-weight:700;margin:4px 0 8px}.devices-heading p{color:#657068}.devices-heading>div:last-child{display:flex;gap:10px}.devices-page button,.device-dialog button{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid #d8dfda;border-radius:12px;background:white;padding:10px 16px;font-weight:600;min-height:44px}.devices-page button:disabled,.device-dialog button:disabled{opacity:.45;cursor:not-allowed}.devices-page .device-primary,.device-dialog .device-primary{background:#1c6c46;color:white;border-color:#1c6c46}.device-kicker{font-size:11px;font-weight:700;letter-spacing:.14em;color:#6a7c70}.devices-empty{text-align:center;background:white;border:1px dashed #c6d3ca;border-radius:24px;padding:64px 24px}.devices-empty svg{margin:auto;color:#478a62}.devices-empty h2{font-weight:650;font-size:22px;margin:16px}.devices-empty p{color:#657068;margin:0 0 24px}.device-list{display:grid;gap:14px}.device-card{display:flex;align-items:center;gap:20px;background:white;border:1px solid #dfe7e1;border-radius:20px;padding:24px}.device-card-icon{background:#edf5ef;border-radius:14px;padding:16px;color:#276347}.device-card-body{flex:1;min-width:0}.device-card-title{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.device-card h2{font-weight:650;font-size:20px;overflow-wrap:anywhere}.device-card p{color:#58675d;margin:6px 0 12px}.device-state{background:#f1f3f1;color:#6d766f;font-size:12px;border-radius:999px;padding:5px 10px}.device-state.online{background:#e0f3e5;color:#176439}.device-card-meta{display:flex;gap:16px;flex-wrap:wrap;font-size:12px;color:#69766d}.device-card-meta>span,.device-notice{display:flex;align-items:center;gap:6px}.device-card[data-revoked=true]{opacity:.65}.device-notice,.device-error{border-radius:12px;padding:14px 18px;margin:16px 0;background:#e8f4eb;color:#22633e}.device-error{background:#fff0ec;color:#973b22}.device-dialog{padding:0;border:1px solid #dde5df;border-radius:24px;width:min(660px,calc(100% - 24px));max-height:calc(100dvh - 32px);color:#17251d;box-shadow:0 24px 100px #17251d33}.device-dialog::backdrop{background:#11211880;backdrop-filter:blur(4px)}.device-dialog form{display:flex;flex-direction:column;max-height:calc(100dvh - 34px)}.device-dialog header,.device-dialog footer{padding:22px 26px;display:flex;align-items:center;justify-content:space-between;gap:14px;flex-shrink:0}.device-dialog header{border-bottom:1px solid #e4ebe6}.device-dialog h2{font-size:23px;font-weight:700;margin-top:4px}.device-form-content{padding:24px 26px;overflow:auto}.device-form-content>label{margin-bottom:16px}.device-dialog label{display:flex;flex-direction:column;gap:8px;font-size:13px;font-weight:600}.device-dialog input,.device-dialog select,.device-dialog textarea{border:1px solid #d0dbd3;border-radius:10px;padding:11px 12px;font-size:15px;background:#fafcfb;min-width:0;width:100%}.device-dialog input:focus,.device-dialog select:focus,.device-dialog textarea:focus{outline:2px solid #478a62;outline-offset:2px}.device-dialog .device-code-input{font-size:34px;letter-spacing:.35em;text-align:center;font-variant-numeric:tabular-nums}.device-dialog small{color:#718076;font-weight:400}.device-dialog h3{font-size:16px;font-weight:700;margin:24px 0 14px}.device-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:18px}.device-dialog .device-check{flex-direction:row;align-items:center;padding:12px 0;font-weight:500}.device-check input{width:18px;height:18px;accent-color:#1c6c46}.device-dialog footer{border-top:1px solid #e4ebe6;justify-content:flex-end}.device-dialog .device-revoke{margin-right:auto;color:#963e2d;background:#fff8f5}.device-revoke-prompt{border:1px solid #eccabd;border-radius:12px;padding:16px;background:#fff8f5}.device-revoke-prompt p{font-size:14px;margin-bottom:12px}.device-revoke-prompt button{margin-right:8px}@media(max-width:700px){.devices-heading{align-items:flex-start;flex-direction:column}.device-card{flex-wrap:wrap;padding:18px;gap:12px}.device-card-icon{padding:12px}.device-card-body{flex-basis:calc(100% - 65px)}.device-card>button{width:100%}.device-form-grid{grid-template-columns:1fr}.device-dialog header,.device-dialog footer,.device-form-content{padding:18px}.device-card-meta{gap:8px}}
.device-night-hint{font-size:13px;line-height:1.5;color:#69766d;margin:0 0 20px}

View File

@@ -4,7 +4,7 @@ import { boardDevicesApi, stopsApi } from '../services/api';
import { useCity } from '../contexts/CityContext';
import './DevicesPage.css';
const defaults = { stopId: 1, demo: false, mainSeconds: 25, infoSeconds: 8, holdNear: true, rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue', infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true, showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near', adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
const defaults = { stopId: 1, demo: false, mainSeconds: 25, infoSeconds: 8, holdNear: true, rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue', infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true, showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near', nightMode: 'auto', nightWakeMinutes: 60, adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
const darkColors = [['lime', 'Лайм'], ['amber', 'Янтарь'], ['cyan', 'Голубой'], ['mint', 'Мята'], ['lavender', 'Лаванда'], ['white', 'Белый']];
const lightColors = [['blue', 'Синий'], ['forest', 'Зелёный'], ['crimson', 'Кармин'], ['violet', 'Фиолетовый'], ['teal', 'Бирюзовый'], ['graphite', 'Графит']];
const date = value => value ? new Date(value).toLocaleString('ru-RU', { timeZone: 'Europe/Minsk', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : 'Ещё не выходило на связь';
@@ -13,7 +13,7 @@ const message = error => error.response?.data?.message || 'Не удалось
function DeviceForm({ device, stops, onClose, onSaved }) {
const dialog = useRef(null);
const [code, setCode] = useState(''), [name, setName] = useState(device?.name || '');
const [settings, setSettings] = useState(device?.settings || { ...defaults, stopId: stops[0]?.id || 1 });
const [settings, setSettings] = useState({ ...defaults, stopId: stops[0]?.id || 1, ...device?.settings });
const [error, setError] = useState(''), [busy, setBusy] = useState(false);
const [detached, setDetached] = useState(false), [routes, setRoutes] = useState([]);
useEffect(() => { let alive = true; setRoutes([]); boardDevicesApi.routes(settings.stopId).then(({ data }) => { if (alive) setRoutes(data); }).catch(() => { if (alive) setError('Не удалось загрузить маршруты площадки'); }); return () => { alive = false; }; }, [settings.stopId]);
@@ -51,7 +51,11 @@ function DeviceForm({ device, stops, onClose, onSaved }) {
</div>
<h3>Экраны</h3><div className="device-form-grid">{[['mainSeconds', 'Главный экран, сек', 5, 120], ['infoSeconds', 'Информация, сек', 5, 60]].map(([key, label, min, max]) => <label key={key}>{label}<input type="number" required min={min} max={max} value={settings[key]} onChange={e => set(key, Number(e.target.value))} /></label>)}</div>
{check('holdNear', 'Удерживать ближайший рейс за 2 минуты')}{check('showNextSameRoute', 'Следующий рейс того же направления')}{check('longWaitEnabled', 'Крупное время при долгом ожидании')}
<h3>Блоки информации</h3>{check('infoServiceEnabled', 'Первый и последний рейс')}{check('infoWeatherEnabled', 'Погода')}{check('infoQrEnabled', 'QR-код расписания')}
<h3>Ночной режим</h3><div className="device-form-grid">
{select('nightMode', 'Ночной режим', [['off', 'Выключен'], ['auto', 'Автоматически']])}
{settings.nightMode === 'auto' && select('nightWakeMinutes', 'Вернуть обычное табло', [[45, 'За 45 минут до рейса'], [60, 'За 60 минут до рейса']], true)}
</div><p className="device-night-hint">Во время ночного перерыва длиннее двух часов. Обычный экран вернётся за выбранное время до первого рейса. По полному расписанию остановки.</p>
<h3>Блоки информации</h3>{check('infoServiceEnabled', 'Дальше по маршруту')}{check('infoWeatherEnabled', 'Погода')}{check('infoQrEnabled', 'QR-код расписания')}
<h3>Реклама · предпросмотр</h3>{check('adsEnabled', 'Добавить рекламу в цикл')}
{settings.adsEnabled && <><label>Длительность, сек<input type="number" min={5} max={30} required value={settings.adSeconds} onChange={e => set('adSeconds', Number(e.target.value))} /></label><label>Заголовок<input maxLength={80} value={settings.adTitle} onChange={e => set('adTitle', e.target.value)} /></label><label>Текст<textarea maxLength={200} value={settings.adText} onChange={e => set('adText', e.target.value)} /></label></>}
{error && <p className="device-error" role="alert">{error}</p>}