Files
ridebus-admin/frontend/src/pages/DevicesPage.jsx

94 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react';
import { Monitor, Plus, RefreshCw, X, Link2, Save, Unplug, CheckCircle2, Clock3 } from 'lucide-react';
import { boardDevicesApi, stopsApi } from '../services/api';
import { useCity } from '../contexts/CityContext';
import './DevicesPage.css';
import { BoardPassengerSettings, passengerDefaults, cityFareDefaults } from '../components/BoardPassengerSettings';
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, journeyTranslitEnabled: false, 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' }) : 'Ещё не выходило на связь';
const message = error => error.response?.data?.message || 'Не удалось выполнить запрос. Попробуйте ещё раз';
function DeviceForm({ device, stops, cityId, onClose, onSaved }) {
const dialog = useRef(null);
const [code, setCode] = useState(''), [name, setName] = useState(device?.name || '');
const [settings, setSettings] = useState({ ...defaults, ...passengerDefaults, fareByType: cityFareDefaults(cityId), 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]);
useEffect(() => { const previous = document.activeElement; dialog.current?.showModal(); return () => { dialog.current?.close(); previous?.focus?.(); }; }, []);
const set = (key, value) => setSettings(previous => ({ ...previous, [key]: value }));
async function save(event) {
event.preventDefault(); if (busy) return; setBusy(true); setError('');
try {
const payload = { name, settings, ...(device ? { revision: device.revision } : { code }) };
if (device) await boardDevicesApi.update(device.id, payload); else await boardDevicesApi.claim(payload);
onSaved(device ? 'Настройки сохранены' : 'Устройство подключено');
} catch (e) { setError(message(e)); } finally { setBusy(false); }
}
async function revoke() {
setBusy(true); setError('');
try { await boardDevicesApi.revoke(device.id); onSaved('Устройство отвязано. Табло узнает об этом при следующем подключении'); }
catch (e) { setError(message(e)); setBusy(false); }
}
const select = (key, label, values, numeric = false) => <label>{label}<select value={settings[key]} onChange={e => set(key, numeric ? Number(e.target.value) : e.target.value)}>{values.map(([value, text]) => <option key={value} value={value}>{text}</option>)}</select></label>;
const check = (key, label) => <label className="device-check"><input type="checkbox" checked={settings[key]} onChange={e => set(key, e.target.checked)} />{label}</label>;
return <dialog ref={dialog} className="device-dialog" aria-labelledby="device-dialog-title" onCancel={e => { e.preventDefault(); if (!busy) onClose(); }}>
<form onSubmit={save}>
<header><div><span className="device-kicker">RIDEBUS BOARD</span><h2 id="device-dialog-title">{device ? 'Настройки устройства' : 'Подключить табло'}</h2></div><button type="button" aria-label="Закрыть" disabled={busy} onClick={onClose}><X size={22} /></button></header>
<div className="device-form-content">
{!device && <label>Код на экране табло<input autoFocus className="device-code-input" inputMode="numeric" autoComplete="off" pattern="[0-9]{4}" maxLength={4} required value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 4))} placeholder="0000" /><small>Четыре цифры. Код действует 5 минут</small></label>}
<label>Название устройства<input autoFocus={Boolean(device)} maxLength={100} required value={name} onChange={e => setName(e.target.value)} placeholder="Швейная фабрика · улица" /></label>
<label>Остановка и площадка<select value={settings.stopId} onChange={e => setSettings(previous => ({ ...previous, stopId: Number(e.target.value), routeFilter: '' }))}>{stops.map(stop => <option key={stop.id} value={stop.id}>{stop.name}{stop.description ? ` · ${stop.description}` : ''}</option>)}</select></label>
{select('routeFilter', 'Маршрут', [['', 'Все маршруты остановки'], ...routes.map(route => [String(route.id), `№${route.route_number} · ${route.name}`])])}
<h3>Оформление и читаемость</h3><div className="device-form-grid">
{select('theme', 'Тема', [['dark', 'Тёмная'], ['light', 'Светлая']])}
{settings.theme === 'light' ? select('lightColorScheme', 'Цветовая схема', lightColors) : select('colorScheme', 'Цветовая схема', darkColors)}
{select('displayProfile', 'Профиль экрана', [['tv', 'Телевизор'], ['street', 'Уличное табло']])}
{select('viewingDistance', 'Расстояние просмотра', [['near', 'Близко'], ['medium', 'Средне'], ['far', 'Далеко']])}
{select('rowCount', 'Следующие рейсы', [2, 3, 4, 5, 6].map(n => [n, n]), true)}
</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><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-код расписания')}{check('journeyTranslitEnabled', 'Транслит названий остановок')}
{check('lastTripNoticeEnabled', 'Напоминание о последнем рейсе')}{check('weatherHintEnabled', 'Вероятность осадков')}{check('infoPlacesEnabled', 'До важных мест')}{check('infoFareEnabled', 'Оплата поездки')}
<BoardPassengerSettings settings={settings} stops={stops} routes={routes} cityId={cityId} set={set} />
<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>}
{detached && <div className="device-revoke-prompt"><p>Отвязать «{device.name}»? Табло прекратит показ при следующей связи с сервером. Для повторного подключения понадобится новый код.</p><button type="button" disabled={busy} onClick={revoke}>Да, отвязать</button><button type="button" disabled={busy} onClick={() => setDetached(false)}>Отмена</button></div>}
</div>
<footer>{device && <button type="button" className="device-revoke" disabled={busy} onClick={() => setDetached(true)}><Unplug size={17} />Отвязать</button>}<button type="submit" className="device-primary" disabled={busy || !stops.length}>{device ? <Save size={18} /> : <Link2 size={18} />}{busy ? 'Сохраняем…' : device ? 'Сохранить настройки' : 'Подключить'}</button></footer>
</form>
</dialog>;
}
export default function DevicesPage() {
const { activeCity } = useCity();
const [devices, setDevices] = useState([]), [stops, setStops] = useState([]);
const [editor, setEditor] = useState(null), [error, setError] = useState(''), [loading, setLoading] = useState(true), [notice, setNotice] = useState('');
const request = useRef(0);
const load = useCallback(async () => {
const id = ++request.current;
try { const { data } = await boardDevicesApi.list(); if (id === request.current) { setDevices(data); setError(''); } }
catch (e) { if (id === request.current) setError(message(e)); }
finally { if (id === request.current) setLoading(false); }
}, []);
useEffect(() => { void load(); const interval = setInterval(load, 15000); return () => { clearInterval(interval); request.current++; }; }, [load]);
useEffect(() => { let alive = true; stopsApi.getAll().then(s => { if (alive) setStops(s.data); }).catch(() => { if (alive) setError('Не удалось загрузить остановки и маршруты'); }); return () => { alive = false; }; }, []);
return <section className="devices-page">
<header className="devices-heading"><div><span className="device-kicker">RIDEBUS BOARD · {activeCity.name}</span><h1>Устройства</h1><p>Табло остановок и телевизоры. Подключение по коду с экрана.</p></div><div><button onClick={load} aria-label="Обновить устройства"><RefreshCw size={18} /></button><button className="device-primary" disabled={!stops.length} onClick={() => { setNotice(''); setEditor({}); }}><Plus size={18} />Добавить устройство</button></div></header>
{notice && <p className="device-notice" role="status"><CheckCircle2 size={18} />{notice}</p>}{error && <p className="device-error" role="alert">{error}</p>}
{!loading && !devices.length && <div className="devices-empty"><Monitor size={42} /><h2>Подключите первое табло</h2><p>Откройте RideBus Board на телевизоре и введите появившийся код здесь.</p><button className="device-primary" disabled={!stops.length} onClick={() => setEditor({})}>Подключить по коду</button></div>}
{loading && <p>Загружаем устройства…</p>}
<div className="device-list">{devices.map(device => <article key={device.id} className="device-card" data-revoked={Boolean(device.revokedAt)}><div className="device-card-icon"><Monitor size={26} /></div><div className="device-card-body"><div className="device-card-title"><h2>{device.name}</h2><span className={`device-state ${device.online ? 'online' : ''}`}>{device.revokedAt ? 'Отвязано' : device.online ? 'В сети' : 'Нет связи'}</span></div><p>{device.stopName}{device.stopDirection ? ` · ${device.stopDirection}` : ''}</p><div className="device-card-meta"><span><Clock3 size={15} />{date(device.lastSeenAt)}</span><span>{device.deviceInfo.width && device.deviceInfo.height ? `${device.deviceInfo.width}×${device.deviceInfo.height}` : 'Размер экрана неизвестен'}</span><span>{device.revokedAt ? 'Доступ отозван' : device.appliedRevision === device.revision ? 'Настройки применены' : 'Ожидают применения'} · v{device.revision}</span></div></div><button disabled={Boolean(device.revokedAt) || !stops.length} onClick={() => { setNotice(''); setEditor(device); }}>Настроить</button></article>)}</div>
{editor && <DeviceForm device={editor.id ? editor : null} stops={stops} cityId={activeCity.id} onClose={() => setEditor(null)} onSaved={text => { setEditor(null); setNotice(text); void load(); }} />}
</section>;
}