94 lines
14 KiB
JavaScript
94 lines
14 KiB
JavaScript
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>;
|
||
}
|