feat: add LiveMapPage for real-time vehicle tracking and ScheduleCenterPage for schedule management
- Implemented LiveMapPage with vehicle tracking using Leaflet and real-time updates. - Added vehicle icons, status labels, and filtering options for routes. - Created ScheduleCenterPage to display schedule versions, conflicts, and gaps. - Introduced responsive design for both pages with dedicated CSS styles. - Integrated API calls for fetching routes and vehicle data.
This commit is contained in:
@@ -13,6 +13,8 @@ import RouteEditorPage from './pages/RouteEditorPage';
|
||||
import StopsPage from './pages/StopsPage';
|
||||
import SchedulesPage from './pages/SchedulesPage';
|
||||
import ScheduleImportPage from './pages/ScheduleImportPage';
|
||||
import ScheduleCenterPage from './pages/ScheduleCenterPage';
|
||||
import LiveMapPage from './pages/LiveMapPage';
|
||||
import QualityPage from './pages/QualityPage';
|
||||
import HolidaysPage from './pages/HolidaysPage';
|
||||
import AlertsPage from './pages/AlertsPage';
|
||||
@@ -70,6 +72,8 @@ function AnimatedRoutes({ cityId }) {
|
||||
<Route path="/routes/:id/edit" element={<ProtectedRoute><RouteEditorPage /></ProtectedRoute>} />
|
||||
<Route path="/stops" element={<ProtectedRoute><StopsPage /></ProtectedRoute>} />
|
||||
<Route path="/schedules" element={<ProtectedRoute><SchedulesPage /></ProtectedRoute>} />
|
||||
<Route path="/live-map" element={<ProtectedRoute><LiveMapPage /></ProtectedRoute>} />
|
||||
<Route path="/schedule-center" element={<ProtectedRoute requireAdmin><ScheduleCenterPage /></ProtectedRoute>} />
|
||||
<Route path="/schedule-import" element={<ProtectedRoute requireAdmin><ScheduleImportPage /></ProtectedRoute>} />
|
||||
<Route path="/quality" element={<ProtectedRoute requireAdmin><QualityPage /></ProtectedRoute>} />
|
||||
<Route path="/holidays" element={<ProtectedRoute><HolidaysPage /></ProtectedRoute>} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, NavLink, useLocation } from 'react-router-dom';
|
||||
import { Bus, Menu, X, LayoutDashboard, MapPin, CalendarDays, Gift, Bell, Users, User, Download, ShieldCheck } from 'lucide-react';
|
||||
import { Bus, Menu, X, LayoutDashboard, MapPin, CalendarDays, Gift, Bell, Users, User, Download, ShieldCheck, CalendarRange, Radio } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import BrandLogo from './BrandLogo';
|
||||
import ProfileMenu from './ProfileMenu';
|
||||
@@ -12,7 +12,9 @@ const navigationGroups = [
|
||||
{ label: 'Транспорт', items: [
|
||||
{ to: '/routes', label: 'Маршруты', icon: Bus },
|
||||
{ to: '/stops', label: 'Остановки', icon: MapPin },
|
||||
{ to: '/live-map', label: 'Живая карта', icon: Radio },
|
||||
{ to: '/schedules', label: 'Расписания', icon: CalendarDays },
|
||||
{ to: '/schedule-center', label: 'Центр расписаний', icon: CalendarRange, adminOnly: true },
|
||||
{ to: '/schedule-import', label: 'Импорт расписаний', icon: Download, adminOnly: true },
|
||||
{ to: '/holidays', label: 'Праздники', icon: Gift },
|
||||
] },
|
||||
@@ -80,7 +82,7 @@ export default function AppLayout({ children }) {
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main id="main-content" tabIndex={-1} className={`app-main ${pathname === '/' ? 'app-main-dashboard' : ['/routes', '/schedules'].includes(pathname) ? 'app-main-wide' : ''}`}>
|
||||
<main id="main-content" tabIndex={-1} className={`app-main ${pathname === '/' ? 'app-main-dashboard' : ['/routes', '/schedules', '/schedule-center', '/live-map'].includes(pathname) ? 'app-main-wide' : ''}`}>
|
||||
<div className="app-content">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -78,15 +78,18 @@
|
||||
.city-center-map { height: 320px; overflow: hidden; border: 1px solid #e5e7eb; border-radius: 12px; }
|
||||
.city-center-coordinates { @apply text-sm text-gray-600; }
|
||||
.city-center-coordinates strong { @apply text-gray-900; }
|
||||
.city-avatar-editor { @apply ml-auto flex shrink-0 flex-col items-end gap-2; }
|
||||
.city-avatar-editor { @apply relative flex shrink-0 flex-col items-end gap-2; }
|
||||
.city-avatar-editor .btn-secondary { @apply inline-flex items-center gap-2; }
|
||||
.city-avatar-editor-error { @apply max-w-[200px] text-right text-xs text-red-700; }
|
||||
.city-avatar-editor-error { @apply absolute right-0 top-full z-10 mt-1 w-52 rounded-md bg-red-50 px-2 py-1 text-right text-xs text-red-700 shadow-sm; }
|
||||
.cities-grid { @apply grid gap-4 md:grid-cols-2; }
|
||||
.city-network-card { @apply flex flex-wrap items-center gap-4; min-height: 104px; }
|
||||
.city-network-card.is-active { border-color: #0958d9; box-shadow: inset 0 0 0 1px #0958d9; }
|
||||
.city-network-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 24px; row-gap: 16px; min-height: 144px; }
|
||||
.city-network-card.is-active { border: 2px solid #2f72e0; box-shadow: 0 0 0 2px rgb(47 114 224 / 10%); }
|
||||
.city-card-summary { @apply flex min-w-0 items-center gap-4; }
|
||||
.city-card-actions { @apply flex flex-wrap items-center justify-end gap-2; }
|
||||
.city-edit-button { @apply inline-flex items-center gap-2; }
|
||||
.city-network-card h2 { @apply text-lg font-semibold; }
|
||||
.city-network-card p { @apply mt-1 text-sm text-gray-500; }
|
||||
.city-current { @apply inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-1 text-xs font-medium text-primary-700; }
|
||||
.city-current { @apply inline-flex shrink-0 items-center gap-1 rounded-full bg-primary-50 px-2 py-1 text-xs font-medium text-primary-700; white-space: nowrap; }
|
||||
.city-isolation-note { @apply flex items-start gap-3 rounded-xl bg-primary-50 p-4 text-sm text-primary-700; }
|
||||
.city-isolation-note svg { @apply mt-0.5 shrink-0; }
|
||||
.login-surface {
|
||||
@@ -270,8 +273,14 @@
|
||||
.login-card { padding: 28px 24px; }
|
||||
.login-device-card { padding: 24px; }
|
||||
}
|
||||
@media (max-width: 1499px) {
|
||||
.city-network-card { grid-template-columns: minmax(0, 1fr); }
|
||||
.city-card-actions { justify-content: flex-start; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.app-main.app-main-wide { padding: 24px 16px; }
|
||||
.city-network-card { grid-template-columns: minmax(0, 1fr); }
|
||||
.city-card-actions { justify-content: flex-start; }
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.app-sidebar { position: fixed; inset: 0 auto 0 0; width: 264px; padding: 16px; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CheckCircle2, ImagePlus, Loader2, MapPinned, Plus } from 'lucide-react';
|
||||
import { CheckCircle2, ImagePlus, Loader2, MapPinned, Pencil, Plus } from 'lucide-react';
|
||||
import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import { citiesApi } from '../services/api';
|
||||
@@ -26,6 +26,16 @@ function slugFromName(name) {
|
||||
.join('').replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
}
|
||||
|
||||
function cityToForm(city) {
|
||||
return {
|
||||
name: city.name || '',
|
||||
slug: city.slug || '',
|
||||
emblem: city.emblem || '🏙️',
|
||||
center_lat: city.center_lat == null ? '' : String(city.center_lat),
|
||||
center_lon: city.center_lon == null ? '' : String(city.center_lon)
|
||||
};
|
||||
}
|
||||
|
||||
function isImageValid(file) {
|
||||
if (!acceptedImageTypes.has(file.type)) return 'Выберите изображение в формате JPG, PNG или WEBP.';
|
||||
if (file.size > 5 * 1024 * 1024) return 'Размер изображения не должен превышать 5 МБ.';
|
||||
@@ -112,8 +122,9 @@ function CityAvatarEditor({ city }) {
|
||||
}
|
||||
|
||||
export default function CitiesPage() {
|
||||
const { cities, activeCity, addCity } = useCity();
|
||||
const { cities, activeCity, addCity, replaceCity } = useCity();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingCity, setEditingCity] = useState(null);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [slugEdited, setSlugEdited] = useState(false);
|
||||
const [avatarFile, setAvatarFile] = useState(null);
|
||||
@@ -121,23 +132,50 @@ export default function CitiesPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const isEditing = Boolean(editingCity);
|
||||
|
||||
const selectedPosition = form.center_lat !== '' && form.center_lon !== ''
|
||||
? [Number(form.center_lat), Number(form.center_lon)]
|
||||
: null;
|
||||
const fallbackCenter = activeCity?.center_lat != null && activeCity?.center_lon != null
|
||||
? [Number(activeCity.center_lat), Number(activeCity.center_lon)]
|
||||
const mapCity = editingCity || activeCity;
|
||||
const fallbackCenter = mapCity?.center_lat != null && mapCity?.center_lon != null
|
||||
? [Number(mapCity.center_lat), Number(mapCity.center_lon)]
|
||||
: BELARUS_CENTER;
|
||||
|
||||
useEffect(() => () => {
|
||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||
}, [avatarPreview]);
|
||||
|
||||
function clearForm() {
|
||||
function clearEditor() {
|
||||
setForm(emptyForm);
|
||||
setSlugEdited(false);
|
||||
setAvatarFile(null);
|
||||
setAvatarPreview('');
|
||||
setEditingCity(null);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
clearEditor();
|
||||
setShowForm(false);
|
||||
setError('');
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
clearEditor();
|
||||
setNotice('');
|
||||
setError('');
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
function openEdit(city) {
|
||||
setEditingCity(city);
|
||||
setForm(cityToForm(city));
|
||||
setSlugEdited(true);
|
||||
setAvatarFile(null);
|
||||
setAvatarPreview('');
|
||||
setNotice('');
|
||||
setError('');
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
function updateForm(field, value) {
|
||||
@@ -175,24 +213,32 @@ export default function CitiesPage() {
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
let { data: city } = await citiesApi.create(form);
|
||||
let avatarWarning = '';
|
||||
let city;
|
||||
if (editingCity) {
|
||||
const { data } = await citiesApi.update(editingCity.id, form);
|
||||
city = data;
|
||||
} else {
|
||||
const { data } = await citiesApi.create(form);
|
||||
city = data;
|
||||
}
|
||||
|
||||
let avatarWarning = '';
|
||||
if (avatarFile) {
|
||||
try {
|
||||
const upload = await citiesApi.uploadAvatar(city.id, avatarFile);
|
||||
city = upload.data.city;
|
||||
} catch (uploadError) {
|
||||
avatarWarning = uploadError.response?.data?.error || 'Город создан, но изображение не удалось загрузить.';
|
||||
avatarWarning = uploadError.response?.data?.error || 'Изменения города сохранены, но изображение не удалось загрузить.';
|
||||
}
|
||||
}
|
||||
|
||||
addCity(city);
|
||||
clearForm();
|
||||
setShowForm(false);
|
||||
if (avatarWarning) setNotice(avatarWarning);
|
||||
if (editingCity) replaceCity(city);
|
||||
else addCity(city);
|
||||
const action = editingCity ? 'Изменения города сохранены.' : 'Город добавлен.';
|
||||
closeEditor();
|
||||
setNotice(avatarWarning || action);
|
||||
} catch (requestError) {
|
||||
setError(requestError.response?.data?.error || 'Не удалось добавить город.');
|
||||
setError(requestError.response?.data?.error || (editingCity ? 'Не удалось сохранить изменения города.' : 'Не удалось добавить город.'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -201,7 +247,7 @@ export default function CitiesPage() {
|
||||
return <div className="cities-page">
|
||||
<header className="cities-heading">
|
||||
<div><h1 className="page-title">Города</h1><p>У каждого города — свои маршруты, остановки, расписания и уведомления.</p></div>
|
||||
<button type="button" className="btn-primary inline-flex items-center gap-2" onClick={() => { setShowForm(current => !current); setError(''); setNotice(''); }}>
|
||||
<button type="button" className="btn-primary inline-flex items-center gap-2" onClick={showForm ? closeEditor : openCreate}>
|
||||
<Plus size={18} aria-hidden="true" />{showForm ? 'Закрыть форму' : 'Добавить город'}
|
||||
</button>
|
||||
</header>
|
||||
@@ -209,24 +255,29 @@ export default function CitiesPage() {
|
||||
{notice && <p className="city-form-notice" role="status">{notice}</p>}
|
||||
|
||||
{showForm && <form className="card city-form" onSubmit={submit}>
|
||||
<div><h2>Новый город</h2><p>После сохранения он станет активным. Добавьте для него маршруты, остановки и расписания отдельно.</p></div>
|
||||
<div><h2>{isEditing ? `Редактирование: ${editingCity.name}` : 'Новый город'}</h2><p>{isEditing ? 'Измените данные города и сохраните их. Данные его транспортной сети останутся без изменений.' : 'После сохранения он станет активным. Добавьте для него маршруты, остановки и расписания отдельно.'}</p></div>
|
||||
{error && <p className="city-form-error" role="alert">{error}</p>}
|
||||
<div className="city-form-grid">
|
||||
<label><span className="label">Название</span><input required className="input" value={form.name} onChange={event => updateForm('name', event.target.value)} placeholder="Например, Новополоцк" /></label>
|
||||
<label><span className="label">Код города</span><input required pattern="[a-z0-9]+(-[a-z0-9]+)*" className="input" value={form.slug} onChange={event => { setSlugEdited(true); updateForm('slug', event.target.value.toLowerCase()); }} placeholder="novopolotsk" /><span className="city-form-hint">Латинские буквы, цифры и дефисы.</span></label>
|
||||
<label><span className="label">Иконка без картинки</span><input maxLength="64" className="input" value={form.emblem} onChange={event => updateForm('emblem', event.target.value)} placeholder="🏙️" /><span className="city-form-hint">Показывается, если кастомное изображение не задано.</span></label>
|
||||
<label className="city-avatar-field"><span className="label">Картинка для герба</span><input type="file" accept="image/jpeg,image/png,image/webp" onChange={selectNewAvatar} /><span className="city-form-hint">JPG, PNG или WEBP, до 5 МБ.</span>{avatarPreview && <img className="city-avatar-preview" src={avatarPreview} alt="Предпросмотр герба города" />}</label>
|
||||
<label className="city-avatar-field"><span className="label">{isEditing ? 'Новая картинка для герба' : 'Картинка для герба'}</span><input type="file" accept="image/jpeg,image/png,image/webp" onChange={selectNewAvatar} /><span className="city-form-hint">JPG, PNG или WEBP, до 5 МБ.</span>{avatarPreview && <img className="city-avatar-preview" src={avatarPreview} alt="Предпросмотр герба города" />}</label>
|
||||
<CityCenterPicker position={selectedPosition} fallbackCenter={fallbackCenter} onSelect={selectCenter} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3"><button disabled={saving} className="btn-primary" type="submit">{saving ? 'Сохраняем…' : 'Создать город'}</button><button disabled={saving} className="btn-secondary" type="button" onClick={() => { clearForm(); setShowForm(false); setError(''); }}>Отмена</button></div>
|
||||
<div className="flex flex-wrap gap-3"><button disabled={saving} className="btn-primary" type="submit">{saving ? 'Сохраняем…' : isEditing ? 'Сохранить изменения' : 'Создать город'}</button><button disabled={saving} className="btn-secondary" type="button" onClick={closeEditor}>Отмена</button></div>
|
||||
</form>}
|
||||
|
||||
<section className="cities-grid" aria-label="Доступные города">
|
||||
{cities.map(city => <article key={city.id} className={`card city-network-card ${city.id === activeCity?.id ? 'is-active' : ''}`}>
|
||||
<CityEmblem city={city} className="is-large" />
|
||||
<div className="min-w-0 flex-1"><h2>{city.name}</h2><p>{city.center_lat != null ? 'Центр карты настроен' : 'Центр карты ещё не указан'}</p></div>
|
||||
<CityAvatarEditor city={city} />
|
||||
{city.id === activeCity?.id && <span className="city-current"><CheckCircle2 size={16} aria-hidden="true" />Активен</span>}
|
||||
<div className="city-card-summary">
|
||||
<CityEmblem city={city} className="is-large" />
|
||||
<div className="min-w-0"><h2>{city.name}</h2><p>{city.center_lat != null ? 'Центр карты настроен' : 'Центр карты ещё не указан'}</p></div>
|
||||
</div>
|
||||
<div className="city-card-actions">
|
||||
{city.id === activeCity?.id && <span className="city-current"><CheckCircle2 size={16} aria-hidden="true" />Активен</span>}
|
||||
<button type="button" className="btn-secondary city-edit-button" onClick={() => openEdit(city)}><Pencil size={16} aria-hidden="true" />Редактировать</button>
|
||||
<CityAvatarEditor city={city} />
|
||||
</div>
|
||||
</article>)}
|
||||
</section>
|
||||
<div className="city-isolation-note"><MapPinned size={20} aria-hidden="true" /><p>Переключение города в боковой панели меняет рабочий контур целиком: данные одного города не отображаются и не редактируются в другом.</p></div>
|
||||
|
||||
74
frontend/src/pages/LiveMapPage.css
Normal file
74
frontend/src/pages/LiveMapPage.css
Normal file
@@ -0,0 +1,74 @@
|
||||
.live-map-page { display: flex; flex-direction: column; gap: 20px; }
|
||||
.live-map-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
|
||||
.live-map-heading p { max-width: 760px; margin-top: 8px; color: #737373; font-size: 14px; line-height: 21px; }
|
||||
.live-map-refresh { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 8px; }
|
||||
.live-map-status { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 18px; color: #525252; font-size: 13px; line-height: 20px; }
|
||||
.live-map-status > span, .live-map-connection { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.live-map-connection { color: #2563eb; font-weight: 500; }
|
||||
.live-map-connection.is-connected { color: #15803d; }
|
||||
.live-map-connection.is-error { color: #b91c1c; }
|
||||
.live-map-connection.is-reconnecting { color: #b45309; }
|
||||
.live-map-error { display: flex; align-items: flex-start; gap: 10px; border-radius: 12px; padding: 13px 15px; color: #92400e; background: #fffbeb; font-size: 14px; line-height: 20px; }
|
||||
.live-map-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 16px; min-height: 660px; }
|
||||
.live-map-panel, .live-map-sidebar { overflow: hidden; border-radius: 12px; background: #fff; }
|
||||
.live-map-panel { display: flex; min-width: 0; flex-direction: column; }
|
||||
.live-map-filters { display: flex; gap: 8px; overflow-x: auto; padding: 14px 16px; border-bottom: 1px solid #f0f0f0; }
|
||||
.live-map-filters button { display: inline-flex; min-height: 36px; align-items: center; gap: 7px; flex: 0 0 auto; border-radius: 8px; padding: 7px 10px; color: #525252; background: #f5f5f5; font-size: 13px; line-height: 20px; }
|
||||
.live-map-filters button[aria-pressed="true"] { color: #1d4ed8; background: #eff6ff; font-weight: 600; }
|
||||
.live-map-filters span { display: inline-flex; min-width: 18px; height: 18px; align-items: center; justify-content: center; border-radius: 999px; padding: 0 5px; color: inherit; background: rgb(0 0 0 / 7%); font-size: 11px; }
|
||||
.live-map-canvas { position: relative; flex: 1; min-height: 560px; }
|
||||
.live-map-leaflet { width: 100%; height: 100%; }
|
||||
.live-map-empty { position: absolute; z-index: 500; top: 50%; left: 50%; display: flex; width: min(440px, calc(100% - 32px)); align-items: flex-start; gap: 14px; border-radius: 12px; padding: 18px; color: #737373; background: rgb(255 255 255 / 95%); box-shadow: 0 12px 30px rgb(0 0 0 / 12%); transform: translate(-50%, -50%); }
|
||||
.live-map-empty svg { flex: 0 0 auto; color: #a3a3a3; }
|
||||
.live-map-empty h2 { color: #262626; font-size: 16px; line-height: 22px; font-weight: 600; }
|
||||
.live-map-empty p { margin-top: 4px; font-size: 13px; line-height: 19px; }
|
||||
.live-map-sidebar { display: flex; flex-direction: column; min-height: 0; }
|
||||
.live-map-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-bottom: 1px solid #f0f0f0; }
|
||||
.live-map-metrics > div { display: flex; min-width: 0; align-items: flex-start; gap: 8px; padding: 14px; }
|
||||
.live-map-metrics > div + div { border-left: 1px solid #f0f0f0; }
|
||||
.live-map-metrics span { display: flex; width: 28px; height: 28px; flex: 0 0 auto; align-items: center; justify-content: center; border-radius: 7px; color: #15803d; background: #f0fdf4; }
|
||||
.live-map-metrics > div + div span { color: #6d28d9; background: #f5f3ff; }
|
||||
.live-map-metrics p { min-width: 0; color: #737373; font-size: 11px; line-height: 15px; }
|
||||
.live-map-metrics strong { display: block; margin-top: 2px; color: #262626; font-size: 19px; line-height: 24px; font-weight: 600; }
|
||||
.live-map-vehicles { display: flex; min-height: 0; flex: 1; flex-direction: column; }
|
||||
.live-map-sidebar-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 16px 12px; }
|
||||
.live-map-sidebar-heading h2 { color: #262626; font-size: 16px; line-height: 22px; font-weight: 600; }
|
||||
.live-map-sidebar-heading p { margin-top: 2px; color: #737373; font-size: 12px; line-height: 18px; }
|
||||
.live-map-sidebar-heading svg { color: #2563eb; }
|
||||
.live-map-vehicle-list { display: flex; min-height: 0; flex: 1; flex-direction: column; overflow-y: auto; padding: 0 8px 12px; }
|
||||
.live-map-vehicle { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; border-radius: 8px; padding: 10px 8px; text-align: left; }
|
||||
.live-map-vehicle:hover, .live-map-vehicle.is-selected { background: #f5f5f5; }
|
||||
.live-map-vehicle.is-selected { box-shadow: inset 0 0 0 1px #bfdbfe; background: #eff6ff; }
|
||||
.live-map-vehicle .route-badge { min-width: 36px; min-height: 36px; padding: 4px; font-size: 13px; line-height: 18px; }
|
||||
.live-map-vehicle-main { min-width: 0; }
|
||||
.live-map-vehicle-main strong, .live-map-vehicle-main small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.live-map-vehicle-main strong { color: #262626; font-size: 13px; line-height: 18px; font-weight: 600; }
|
||||
.live-map-vehicle-main small { margin-top: 2px; color: #737373; font-size: 11px; line-height: 16px; }
|
||||
.live-map-vehicle i { width: 8px; height: 8px; border-radius: 999px; background: #16a34a; }
|
||||
.live-map-vehicle i.is-crowd { background: #7c3aed; }
|
||||
.live-map-sidebar-empty { display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 24px; color: #737373; font-size: 13px; text-align: center; }
|
||||
.live-map-sidebar-empty svg { color: #d4d4d4; }
|
||||
.live-map-routes-link { min-height: 48px; padding: 14px 16px; border-top: 1px solid #f0f0f0; color: #2563eb; font-size: 14px; font-weight: 500; }
|
||||
.live-map-routes-link:hover { background: #fafafa; }
|
||||
.live-map-marker { background: none; border: 0; }
|
||||
.live-map-marker-label { display: inline-flex; min-width: 38px; max-width: 116px; height: 28px; align-items: center; gap: 5px; overflow: hidden; border: 2px solid var(--route-color); border-radius: 999px; padding: 0 8px; color: #262626; background: #fff; box-shadow: 0 4px 12px rgb(15 23 42 / 20%); font-size: 11px; line-height: 16px; font-weight: 700; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.live-map-marker-arrow { display: inline-block; flex: 0 0 auto; color: var(--route-color); font-size: 12px; line-height: 12px; }
|
||||
.live-map-popup { min-width: 190px; padding: 2px; }
|
||||
.live-map-popup > div { display: flex; align-items: center; gap: 8px; }
|
||||
.live-map-popup .route-badge { min-width: 30px; min-height: 30px; padding: 3px; font-size: 12px; line-height: 16px; }
|
||||
.live-map-popup strong { font-size: 13px; line-height: 18px; }
|
||||
.live-map-popup > p { margin-top: 8px; color: #525252; font-size: 12px; line-height: 17px; }
|
||||
.live-map-popup dl { display: grid; gap: 4px; margin-top: 9px; color: #525252; font-size: 12px; line-height: 17px; }
|
||||
.live-map-popup dl > div { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.live-map-popup dt { color: #737373; }
|
||||
.live-map-popup dd { text-align: right; }
|
||||
|
||||
@media (max-width: 1100px) { .live-map-layout { grid-template-columns: 1fr; min-height: 0; } .live-map-sidebar { min-height: 340px; } }
|
||||
@media (max-width: 639px) {
|
||||
.live-map-heading { flex-direction: column; }
|
||||
.live-map-refresh { width: 100%; justify-content: center; }
|
||||
.live-map-status { gap: 8px 14px; }
|
||||
.live-map-canvas { min-height: 500px; }
|
||||
.live-map-empty { flex-direction: column; }
|
||||
.live-map-sidebar { min-height: 300px; }
|
||||
}
|
||||
285
frontend/src/pages/LiveMapPage.jsx
Normal file
285
frontend/src/pages/LiveMapPage.jsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MapContainer, Marker, Popup, TileLayer, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import { AlertCircle, Bus, CheckCircle2, Clock3, MapPin, Radio, RefreshCw, Signal, Users } from 'lucide-react';
|
||||
import { realtimeApi, routesApi } from '../services/api';
|
||||
import { RouteRealtimeClient } from '../services/realtime';
|
||||
import { useCity } from '../contexts/CityContext';
|
||||
import { PageLoader } from '../components/AnimatedComponents';
|
||||
import RouteBadge from '../components/RouteBadge';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import './LiveMapPage.css';
|
||||
|
||||
const DEFAULT_CENTER = [55.4869, 28.7856];
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function safeColor(value) {
|
||||
return /^#[0-9a-f]{3,8}$/i.test(String(value || '')) ? value : '#2563eb';
|
||||
}
|
||||
|
||||
function vehicleLabel(vehicle) {
|
||||
return vehicle.vehicle_number || vehicle.registration || `Транспорт ${vehicle.vehicle_id}`;
|
||||
}
|
||||
|
||||
function formatAge(vehicle, now) {
|
||||
const seenAt = new Date(vehicle.last_seen_at).getTime();
|
||||
const seconds = Number.isFinite(seenAt)
|
||||
? Math.max(0, Math.floor((now - seenAt) / 1000))
|
||||
: Number(vehicle.seconds_since_update);
|
||||
if (!Number.isFinite(seconds)) return 'нет данных';
|
||||
if (seconds < 15) return 'только что';
|
||||
if (seconds < 60) return `${seconds} сек назад`;
|
||||
return `${Math.floor(seconds / 60)} мин назад`;
|
||||
}
|
||||
|
||||
function sourceLabel(vehicle) {
|
||||
return vehicle.source === 'crowdsourced' ? 'По данным пассажиров' : 'Официальный GPS';
|
||||
}
|
||||
|
||||
function createVehicleIcon(vehicle, color) {
|
||||
const heading = Number.isFinite(Number(vehicle.heading)) ? Number(vehicle.heading) : 0;
|
||||
const isCrowd = vehicle.source === 'crowdsourced';
|
||||
const markerColor = isCrowd ? '#7c3aed' : safeColor(color);
|
||||
const label = escapeHtml(`${vehicle.route_number || '—'} · ${vehicleLabel(vehicle)}`);
|
||||
|
||||
return L.divIcon({
|
||||
className: 'live-map-marker',
|
||||
html: `<div class="live-map-marker-label" style="--route-color:${markerColor}">
|
||||
<span class="live-map-marker-arrow" style="transform:rotate(${heading}deg)">▲</span>
|
||||
<span>${label}</span>
|
||||
</div>`,
|
||||
iconSize: [116, 32],
|
||||
iconAnchor: [58, 16],
|
||||
popupAnchor: [0, -18],
|
||||
});
|
||||
}
|
||||
|
||||
function MapViewport({ city, selectedVehicle }) {
|
||||
const map = useMap();
|
||||
const cityId = city?.id;
|
||||
|
||||
useEffect(() => {
|
||||
const latitude = Number(city?.center_lat);
|
||||
const longitude = Number(city?.center_lon);
|
||||
map.setView(Number.isFinite(latitude) && Number.isFinite(longitude) ? [latitude, longitude] : DEFAULT_CENTER, 13);
|
||||
}, [map, cityId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedVehicle && Number.isFinite(Number(selectedVehicle.latitude)) && Number.isFinite(Number(selectedVehicle.longitude))) {
|
||||
map.flyTo([Number(selectedVehicle.latitude), Number(selectedVehicle.longitude)], Math.max(map.getZoom(), 14), { duration: 0.45 });
|
||||
}
|
||||
}, [map, selectedVehicle?.vehicle_id]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function replaceRouteVehicles(current, routeId, nextVehicles) {
|
||||
const withoutRoute = current.filter((vehicle) => Number(vehicle.route_id) !== Number(routeId));
|
||||
return [...withoutRoute, ...(Array.isArray(nextVehicles) ? nextVehicles : [])];
|
||||
}
|
||||
|
||||
function upsertVehicle(current, nextVehicle) {
|
||||
const index = current.findIndex((vehicle) => String(vehicle.vehicle_id) === String(nextVehicle.vehicle_id));
|
||||
if (index === -1) return [...current, nextVehicle];
|
||||
return current.map((vehicle, currentIndex) => currentIndex === index ? nextVehicle : vehicle);
|
||||
}
|
||||
|
||||
function isMappable(vehicle) {
|
||||
return Number.isFinite(Number(vehicle.latitude)) && Number.isFinite(Number(vehicle.longitude));
|
||||
}
|
||||
|
||||
function realtimeStatusLabel(status) {
|
||||
if (status === 'connected') return 'Realtime подключён';
|
||||
if (status === 'connecting' || status === 'authenticating' || status === 'authenticated') return 'Подключаем realtime…';
|
||||
if (status === 'reconnecting') return 'Realtime переподключается…';
|
||||
if (status === 'error') return 'Realtime недоступен';
|
||||
return 'Ожидание realtime';
|
||||
}
|
||||
|
||||
export default function LiveMapPage() {
|
||||
const { activeCity } = useCity();
|
||||
const [routes, setRoutes] = useState([]);
|
||||
const [vehicles, setVehicles] = useState([]);
|
||||
const [selectedRouteId, setSelectedRouteId] = useState('all');
|
||||
const [selectedVehicleId, setSelectedVehicleId] = useState(null);
|
||||
const [connectionState, setConnectionState] = useState('idle');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const alive = useRef(true);
|
||||
|
||||
const refreshSnapshot = useCallback(async (currentRoutes, { manual = false } = {}) => {
|
||||
if (!currentRoutes.length) return;
|
||||
if (manual) setRefreshing(true);
|
||||
try {
|
||||
const results = await Promise.allSettled(currentRoutes.map((route) => realtimeApi.getRouteVehicles(route.id)));
|
||||
if (!alive.current) return;
|
||||
const received = results.flatMap((result) => result.status === 'fulfilled' ? [result.value.data || []] : []);
|
||||
const failed = results.some((result) => result.status === 'rejected');
|
||||
if (received.length) {
|
||||
const nextVehicles = received.flat();
|
||||
setVehicles(nextVehicles);
|
||||
setLastUpdatedAt(new Date());
|
||||
}
|
||||
if (failed) setError('Не удалось обновить часть маршрутов. Карта показывает последний доступный снимок.');
|
||||
} catch {
|
||||
if (alive.current) setError('Не удалось загрузить снимок транспорта. Повторите попытку.');
|
||||
} finally {
|
||||
if (alive.current && manual) setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
async function loadRoutes() {
|
||||
setError('');
|
||||
try {
|
||||
const { data } = await routesApi.getAll();
|
||||
if (!alive.current) return;
|
||||
setRoutes(data || []);
|
||||
await refreshSnapshot(data || []);
|
||||
} catch (requestError) {
|
||||
if (alive.current) setError(requestError.response?.data?.error || 'Не удалось загрузить маршруты города.');
|
||||
} finally {
|
||||
if (alive.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
loadRoutes();
|
||||
return () => { alive.current = false; };
|
||||
}, [refreshSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!routes.length) return undefined;
|
||||
const realtimeClient = new RouteRealtimeClient({
|
||||
routeIds: routes.map((route) => route.id),
|
||||
token: localStorage.getItem('accessToken'),
|
||||
apiKey: import.meta.env.VITE_API_KEY || 'dev_key_12345678',
|
||||
onStatusChange: (status) => {
|
||||
if (!alive.current) return;
|
||||
setConnectionState(status.state);
|
||||
if (status.state === 'connected') setError('');
|
||||
},
|
||||
onSnapshot: (nextVehicles, routeId) => {
|
||||
if (!alive.current) return;
|
||||
setVehicles((current) => replaceRouteVehicles(current, routeId, nextVehicles));
|
||||
setLastUpdatedAt(new Date());
|
||||
},
|
||||
onVehiclePosition: (vehicle) => {
|
||||
if (!alive.current) return;
|
||||
setVehicles((current) => upsertVehicle(current, vehicle));
|
||||
setLastUpdatedAt(new Date());
|
||||
},
|
||||
onError: (message) => {
|
||||
if (alive.current) setError(`${message}. Карта продолжит обновляться по снимкам.`);
|
||||
},
|
||||
});
|
||||
realtimeClient.connect();
|
||||
const snapshotTimer = window.setInterval(() => refreshSnapshot(routes), 60_000);
|
||||
return () => {
|
||||
window.clearInterval(snapshotTimer);
|
||||
realtimeClient.disconnect();
|
||||
};
|
||||
}, [refreshSnapshot, routes]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 15_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const routesById = useMemo(() => new Map(routes.map((route) => [Number(route.id), route])), [routes]);
|
||||
const mappableVehicles = vehicles.filter(isMappable);
|
||||
const visibleVehicles = mappableVehicles
|
||||
.filter((vehicle) => selectedRouteId === 'all' || Number(vehicle.route_id) === Number(selectedRouteId))
|
||||
.sort((left, right) => Number(right.is_online) - Number(left.is_online)
|
||||
|| String(left.route_number || '').localeCompare(String(right.route_number || ''), 'ru', { numeric: true })
|
||||
|| String(left.vehicle_number || left.vehicle_id).localeCompare(String(right.vehicle_number || right.vehicle_id)));
|
||||
const selectedVehicle = visibleVehicles.find((vehicle) => String(vehicle.vehicle_id) === String(selectedVehicleId)) || null;
|
||||
const officialCount = visibleVehicles.filter((vehicle) => vehicle.source !== 'crowdsourced').length;
|
||||
const crowdCount = visibleVehicles.filter((vehicle) => vehicle.source === 'crowdsourced').length;
|
||||
const cityLatitude = Number(activeCity?.center_lat);
|
||||
const cityLongitude = Number(activeCity?.center_lon);
|
||||
const center = Number.isFinite(cityLatitude) && Number.isFinite(cityLongitude)
|
||||
? [cityLatitude, cityLongitude]
|
||||
: DEFAULT_CENTER;
|
||||
|
||||
if (loading) return <PageLoader />;
|
||||
|
||||
return <div className="live-map-page">
|
||||
<header className="live-map-heading">
|
||||
<div><h1 className="page-title">Живая карта</h1><p>Положение транспорта обновляется через realtime. Если соединение временно пропало, остаётся последний доступный снимок.</p></div>
|
||||
<button type="button" className="btn-secondary live-map-refresh" onClick={() => refreshSnapshot(routes, { manual: true })} disabled={refreshing || !routes.length}>
|
||||
<RefreshCw size={16} className={refreshing ? 'animate-spin' : ''} aria-hidden="true" />{refreshing ? 'Обновляем…' : 'Обновить'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="live-map-status" aria-label="Статус живой карты">
|
||||
<div className={`live-map-connection is-${connectionState}`}><Signal size={18} aria-hidden="true" /><span>{realtimeStatusLabel(connectionState)}</span></div>
|
||||
<span><Clock3 size={16} aria-hidden="true" />{lastUpdatedAt ? `Снимок: ${lastUpdatedAt.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}` : 'Ожидаем первый снимок'}</span>
|
||||
<span><Bus size={16} aria-hidden="true" />На карте: <strong>{visibleVehicles.length}</strong></span>
|
||||
</section>
|
||||
|
||||
{error && <div className="live-map-error" role="alert"><AlertCircle size={20} aria-hidden="true" /><span>{error}</span></div>}
|
||||
|
||||
<div className="live-map-layout">
|
||||
<section className="live-map-panel" aria-labelledby="live-map-title">
|
||||
<h2 id="live-map-title" className="sr-only">Карта живого транспорта</h2>
|
||||
<div className="live-map-filters" role="group" aria-label="Фильтр маршрутов">
|
||||
<button type="button" aria-pressed={selectedRouteId === 'all'} onClick={() => { setSelectedRouteId('all'); setSelectedVehicleId(null); }}>Все <span>{mappableVehicles.length}</span></button>
|
||||
{routes.map((route) => <button type="button" key={route.id} aria-pressed={Number(selectedRouteId) === Number(route.id)} onClick={() => { setSelectedRouteId(route.id); setSelectedVehicleId(null); }}>
|
||||
№{route.route_number}<span>{mappableVehicles.filter((vehicle) => Number(vehicle.route_id) === Number(route.id)).length}</span>
|
||||
</button>)}
|
||||
</div>
|
||||
<div className="live-map-canvas">
|
||||
<MapContainer center={center} zoom={13} className="live-map-leaflet" scrollWheelZoom>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='© OpenStreetMap contributors' />
|
||||
<MapViewport city={activeCity} selectedVehicle={selectedVehicle} />
|
||||
{visibleVehicles.map((vehicle) => {
|
||||
const route = routesById.get(Number(vehicle.route_id));
|
||||
const color = vehicle.route_color || route?.color;
|
||||
return <Marker key={vehicle.vehicle_id} position={[Number(vehicle.latitude), Number(vehicle.longitude)]}
|
||||
icon={createVehicleIcon(vehicle, color)} eventHandlers={{ click: () => setSelectedVehicleId(vehicle.vehicle_id) }}>
|
||||
<Popup><div className="live-map-popup">
|
||||
<div><RouteBadge route={{ route_number: vehicle.route_number || '—', color }} /><strong>{vehicleLabel(vehicle)}</strong></div>
|
||||
<p>{route?.name || vehicle.route_name || 'Маршрут'}</p>
|
||||
<dl><div><dt>Источник</dt><dd>{sourceLabel(vehicle)}</dd></div><div><dt>Обновлено</dt><dd>{formatAge(vehicle, now)}</dd></div><div><dt>Скорость</dt><dd>{vehicle.speed_kmh != null ? `${Math.round(Number(vehicle.speed_kmh))} км/ч` : 'нет данных'}</dd></div></dl>
|
||||
</div></Popup>
|
||||
</Marker>;
|
||||
})}
|
||||
</MapContainer>
|
||||
{!visibleVehicles.length && <div className="live-map-empty"><MapPin size={40} aria-hidden="true" /><div><h2>Пока нет живых координат</h2><p>Когда GPS или подтверждённые агрегированные данные поступят, транспорт появится здесь автоматически.</p></div></div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="live-map-sidebar" aria-label="Транспорт на карте">
|
||||
<section className="live-map-metrics">
|
||||
<div><span><CheckCircle2 size={18} aria-hidden="true" /></span><p>Официальный GPS<strong>{officialCount}</strong></p></div>
|
||||
<div><span><Users size={18} aria-hidden="true" /></span><p>По данным пассажиров<strong>{crowdCount}</strong></p></div>
|
||||
</section>
|
||||
<section className="live-map-vehicles">
|
||||
<div className="live-map-sidebar-heading"><div><h2>Транспорт</h2><p>{selectedRouteId === 'all' ? 'Все маршруты' : `Маршрут №${routesById.get(Number(selectedRouteId))?.route_number || '—'}`}</p></div><Radio size={19} aria-hidden="true" /></div>
|
||||
{visibleVehicles.length ? <div className="live-map-vehicle-list">{visibleVehicles.map((vehicle) => {
|
||||
const route = routesById.get(Number(vehicle.route_id));
|
||||
const color = vehicle.route_color || route?.color;
|
||||
return <button type="button" key={vehicle.vehicle_id} className={`live-map-vehicle ${String(selectedVehicleId) === String(vehicle.vehicle_id) ? 'is-selected' : ''}`} onClick={() => setSelectedVehicleId(vehicle.vehicle_id)}>
|
||||
<RouteBadge route={{ route_number: vehicle.route_number || '—', color }} />
|
||||
<span className="live-map-vehicle-main"><strong>{vehicleLabel(vehicle)}</strong><small>{sourceLabel(vehicle)} · {formatAge(vehicle, now)}</small></span>
|
||||
<i className={vehicle.source === 'crowdsourced' ? 'is-crowd' : ''} aria-label={vehicle.is_online ? 'Онлайн' : 'Не в сети'} />
|
||||
</button>;
|
||||
})}</div> : <div className="live-map-sidebar-empty"><Bus size={34} aria-hidden="true" /><p>Нет транспорта по выбранному фильтру.</p></div>}
|
||||
</section>
|
||||
<Link className="live-map-routes-link" to="/routes">Настроить маршруты</Link>
|
||||
</aside>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
83
frontend/src/pages/ScheduleCenterPage.css
Normal file
83
frontend/src/pages/ScheduleCenterPage.css
Normal file
@@ -0,0 +1,83 @@
|
||||
.schedule-center-page { display: flex; flex-direction: column; gap: 24px; }
|
||||
.schedule-center-heading, .schedule-center-section-heading, .schedule-center-route > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.schedule-center-heading p, .schedule-center-section-heading p { max-width: 760px; margin-top: 8px; color: #737373; font-size: 14px; line-height: 21px; }
|
||||
.schedule-center-actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: 8px; }
|
||||
.schedule-center-actions .btn-secondary, .schedule-center-route-action, .schedule-center-issue-action { display: inline-flex; min-height: 40px; align-items: center; justify-content: center; gap: 8px; }
|
||||
.schedule-center-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 16px; padding: 16px 20px; }
|
||||
.schedule-center-controls > svg { color: #2563eb; }
|
||||
.schedule-center-controls label { display: flex; align-items: center; gap: 10px; color: #404040; font-size: 14px; font-weight: 500; }
|
||||
.schedule-center-controls .input { width: 178px; padding-top: 8px; padding-bottom: 8px; }
|
||||
.schedule-center-controls p { color: #737373; font-size: 13px; }
|
||||
.schedule-center-message { display: flex; align-items: center; gap: 12px; border-radius: 12px; padding: 14px 16px; color: #b91c1c; background: #fef2f2; font-size: 14px; }
|
||||
.schedule-center-message button { margin-left: auto; font-weight: 600; text-decoration: underline; }
|
||||
.schedule-center-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
|
||||
.schedule-center-summary-card { display: flex; min-height: 104px; align-items: flex-start; gap: 12px; border-radius: 12px; background: #fff; padding: 16px; }
|
||||
.schedule-center-summary-card > span { display: flex; width: 38px; height: 38px; flex: 0 0 auto; align-items: center; justify-content: center; border-radius: 8px; color: #525252; background: #f5f5f5; }
|
||||
.schedule-center-summary-card.is-active > span { color: #15803d; background: #f0fdf4; }
|
||||
.schedule-center-summary-card.is-future > span { color: #2563eb; background: #eff6ff; }
|
||||
.schedule-center-summary-card.is-critical > span { color: #dc2626; background: #fef2f2; }
|
||||
.schedule-center-summary-card.is-warning > span { color: #b45309; background: #fffbeb; }
|
||||
.schedule-center-summary-card p { color: #737373; font-size: 13px; line-height: 18px; }
|
||||
.schedule-center-summary-card strong { display: block; margin-top: 5px; color: #171717; font-size: 26px; line-height: 30px; font-weight: 600; }
|
||||
.schedule-center-calendar, .schedule-center-issues { border-radius: 12px; background: #fff; padding: 20px; }
|
||||
.schedule-center-legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; color: #525252; font-size: 12px; }
|
||||
.schedule-center-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.schedule-center-legend span::before { width: 10px; height: 10px; border-radius: 3px; content: ''; background: #16a34a; }
|
||||
.schedule-center-legend .is-upcoming::before { background: #2563eb; }
|
||||
.schedule-center-legend .has-conflict::before { border: 2px solid #dc2626; background: #fecaca; }
|
||||
.schedule-center-route-list { display: flex; flex-direction: column; margin-top: 20px; border-top: 1px solid #f0f0f0; }
|
||||
.schedule-center-route { padding: 20px 0; border-bottom: 1px solid #f0f0f0; }
|
||||
.schedule-center-route:last-child { padding-bottom: 0; border-bottom: 0; }
|
||||
.schedule-center-route-title { display: flex; min-width: 0; align-items: center; gap: 12px; }
|
||||
.schedule-center-route-title h3 { font-size: 16px; line-height: 22px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.schedule-center-route-title p { margin-top: 2px; color: #737373; font-size: 13px; line-height: 18px; }
|
||||
.schedule-center-route-action, .schedule-center-issue-action { flex: 0 0 auto; border-radius: 8px; padding: 9px 11px; color: #262626; background: #f5f5f5; font-size: 13px; font-weight: 500; white-space: nowrap; }
|
||||
.schedule-center-route-action:hover, .schedule-center-issue-action:hover { background: #ebebeb; }
|
||||
.schedule-center-date-scale { display: flex; justify-content: space-between; margin: 22px 0 6px 126px; color: #a3a3a3; font-size: 11px; line-height: 16px; }
|
||||
.schedule-center-lanes { display: flex; flex-direction: column; gap: 12px; overflow-x: auto; }
|
||||
.schedule-center-lane { display: grid; min-width: 720px; grid-template-columns: 110px minmax(0, 1fr); gap: 16px; }
|
||||
.schedule-center-lane-label { padding-top: 7px; }
|
||||
.schedule-center-lane-label strong { display: block; color: #262626; font-size: 13px; line-height: 18px; }
|
||||
.schedule-center-lane-label span { display: block; margin-top: 2px; color: #a3a3a3; font-size: 12px; line-height: 16px; }
|
||||
.schedule-center-lane-content { min-width: 0; }
|
||||
.schedule-center-track { position: relative; overflow: hidden; border-radius: 8px; background-color: #fafafa; background-image: linear-gradient(to right, #e5e5e5 1px, transparent 1px); background-size: 33.333% 100%; }
|
||||
.schedule-center-empty-track { position: absolute; inset: 0; display: grid; place-items: center; color: #a3a3a3; font-size: 12px; }
|
||||
.schedule-center-version { position: absolute; display: flex; min-width: 28px; height: 22px; align-items: center; justify-content: center; overflow: hidden; border-radius: 5px; padding: 0 6px; color: #fff; background: #16a34a; font-size: 11px; line-height: 16px; font-weight: 600; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.schedule-center-version.is-upcoming { background: #2563eb; }
|
||||
.schedule-center-version.is-past { background: #a3a3a3; }
|
||||
.schedule-center-version.is-invalid, .schedule-center-version.has-conflict { outline: 2px solid #dc2626; outline-offset: -2px; background: #dc2626; }
|
||||
.schedule-center-version-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; }
|
||||
.schedule-center-version-meta, .schedule-center-gap-meta { border-radius: 999px; padding: 3px 8px; color: #525252; background: #f5f5f5; font-size: 11px; line-height: 16px; }
|
||||
.schedule-center-version-meta.is-upcoming { color: #1d4ed8; background: #eff6ff; }
|
||||
.schedule-center-version-meta.has-conflict { color: #b91c1c; background: #fef2f2; }
|
||||
.schedule-center-gap-meta { color: #92400e; background: #fffbeb; }
|
||||
.schedule-center-issues { overflow: hidden; }
|
||||
.schedule-center-issue-list { display: flex; flex-direction: column; margin-top: 18px; border-top: 1px solid #f0f0f0; }
|
||||
.schedule-center-issue { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: start; gap: 14px; padding: 16px 0; border-bottom: 1px solid #f0f0f0; }
|
||||
.schedule-center-issue:last-child { padding-bottom: 0; border-bottom: 0; }
|
||||
.schedule-center-issue > span { display: flex; width: 36px; height: 36px; align-items: center; justify-content: center; border-radius: 8px; }
|
||||
.schedule-center-issue.is-critical > span { color: #dc2626; background: #fef2f2; }
|
||||
.schedule-center-issue.is-warning > span { color: #b45309; background: #fffbeb; }
|
||||
.schedule-center-issue-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.schedule-center-issue-heading h3 { color: #262626; font-size: 15px; line-height: 21px; font-weight: 600; }
|
||||
.schedule-center-issue-heading small { border-radius: 999px; padding: 2px 8px; color: #737373; background: #f5f5f5; font-size: 12px; line-height: 18px; }
|
||||
.schedule-center-issue p { margin-top: 5px; color: #525252; font-size: 14px; line-height: 21px; overflow-wrap: anywhere; }
|
||||
.schedule-center-clear { display: flex; min-height: 160px; align-items: center; justify-content: center; gap: 14px; color: #16a34a; }
|
||||
.schedule-center-clear h3 { color: #262626; font-size: 16px; line-height: 23px; font-weight: 600; }
|
||||
.schedule-center-clear p { max-width: 460px; margin-top: 4px; color: #737373; font-size: 14px; line-height: 21px; }
|
||||
|
||||
@media (max-width: 1199px) { .schedule-center-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
@media (max-width: 767px) {
|
||||
.schedule-center-heading, .schedule-center-section-heading, .schedule-center-route > header { flex-direction: column; }
|
||||
.schedule-center-actions, .schedule-center-actions .btn-secondary, .schedule-center-route-action { width: 100%; }
|
||||
.schedule-center-controls { align-items: flex-start; flex-direction: column; }
|
||||
.schedule-center-controls label, .schedule-center-controls .input { width: 100%; }
|
||||
.schedule-center-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.schedule-center-calendar, .schedule-center-issues { padding: 16px; }
|
||||
.schedule-center-legend { justify-content: flex-start; }
|
||||
.schedule-center-date-scale { margin-left: 0; min-width: 720px; }
|
||||
.schedule-center-lane { grid-template-columns: 92px minmax(0, 1fr); gap: 10px; }
|
||||
.schedule-center-issue { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.schedule-center-issue-action { grid-column: 2; justify-self: start; }
|
||||
.schedule-center-clear { flex-direction: column; padding: 24px 8px; text-align: center; }
|
||||
}
|
||||
204
frontend/src/pages/ScheduleCenterPage.jsx
Normal file
204
frontend/src/pages/ScheduleCenterPage.jsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AlertCircle, AlertTriangle, ArrowRight, CalendarClock, CheckCircle2, Clock3, Download, RefreshCw } from 'lucide-react';
|
||||
import { scheduleCenterApi } from '../services/api';
|
||||
import { useCity } from '../contexts/CityContext';
|
||||
import { PageLoader } from '../components/AnimatedComponents';
|
||||
import RouteBadge from '../components/RouteBadge';
|
||||
import './ScheduleCenterPage.css';
|
||||
|
||||
const DAY_STATUS = {
|
||||
active: 'Есть версия на выбранную дату',
|
||||
upcoming: 'Следующая версия ещё не вступила в силу',
|
||||
gap: 'Нет версии в периоде обзора',
|
||||
};
|
||||
|
||||
function minskDate() {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).format(new Date());
|
||||
}
|
||||
|
||||
function dateValue(date) {
|
||||
return date ? new Date(`${date}T12:00:00.000Z`) : null;
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
const value = dateValue(date);
|
||||
return value && !Number.isNaN(value.getTime())
|
||||
? value.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'Europe/Minsk' })
|
||||
: 'без даты';
|
||||
}
|
||||
|
||||
function formatPeriod(version) {
|
||||
if (!version.valid_from && !version.valid_until) return 'Бессрочно';
|
||||
return `${version.valid_from ? `с ${formatDate(version.valid_from)}` : 'без даты начала'}${version.valid_until ? ` по ${formatDate(version.valid_until)}` : ''}`;
|
||||
}
|
||||
|
||||
function dayDistance(from, to) {
|
||||
const fromValue = dateValue(from);
|
||||
const toValue = dateValue(to);
|
||||
return Math.round((toValue - fromValue) / 86400000);
|
||||
}
|
||||
|
||||
function visibleVersion(version, start, end) {
|
||||
return (!version.valid_until || version.valid_until >= start)
|
||||
&& (!version.valid_from || version.valid_from <= end);
|
||||
}
|
||||
|
||||
function versionStyle(version, index, start, end) {
|
||||
const blockStart = version.valid_from && version.valid_from > start ? version.valid_from : start;
|
||||
const blockEnd = version.valid_until && version.valid_until < end ? version.valid_until : end;
|
||||
const totalDays = dayDistance(start, end) + 1;
|
||||
const left = (dayDistance(start, blockStart) / totalDays) * 100;
|
||||
const width = Math.max(4, ((dayDistance(blockStart, blockEnd) + 1) / totalDays) * 100);
|
||||
return { left: `${left}%`, width: `${width}%`, top: `${index * 30 + 7}px` };
|
||||
}
|
||||
|
||||
function SummaryCard({ icon: Icon, label, value, tone }) {
|
||||
return <article className={`schedule-center-summary-card is-${tone}`}>
|
||||
<span><Icon size={20} aria-hidden="true" /></span>
|
||||
<div><p>{label}</p><strong>{value}</strong></div>
|
||||
</article>;
|
||||
}
|
||||
|
||||
function TimelineLane({ dayType, start, end }) {
|
||||
const versions = dayType.versions.filter((version) => visibleVersion(version, start, end));
|
||||
|
||||
return <section className="schedule-center-lane" aria-label={`${dayType.label}: ${DAY_STATUS[dayType.status]}`}>
|
||||
<div className="schedule-center-lane-label">
|
||||
<strong>{dayType.label}</strong><span>{dayType.shortLabel}</span>
|
||||
</div>
|
||||
<div className="schedule-center-lane-content">
|
||||
<div className="schedule-center-track" style={{ minHeight: `${Math.max(48, versions.length * 30 + 12)}px` }}>
|
||||
{!versions.length && <span className="schedule-center-empty-track">Нет версии</span>}
|
||||
{versions.map((version, index) => <span key={version.id}
|
||||
className={`schedule-center-version is-${version.state}${version.has_conflict ? ' has-conflict' : ''}`}
|
||||
style={versionStyle(version, index, start, end)}
|
||||
title={`Версия №${version.id}: ${formatPeriod(version)}`}>
|
||||
№{version.id}
|
||||
</span>)}
|
||||
</div>
|
||||
<div className="schedule-center-version-list">
|
||||
{versions.map((version) => <span key={version.id} className={`schedule-center-version-meta is-${version.state}${version.has_conflict ? ' has-conflict' : ''}`}>
|
||||
<strong>№{version.id}</strong> · {formatPeriod(version)} · {version.departure_count} рейс{version.departure_count === 1 ? '' : version.departure_count > 1 && version.departure_count < 5 ? 'а' : 'ов'}
|
||||
</span>)}
|
||||
{dayType.gaps.map((gap) => <span key={`${gap.starts_on}-${gap.ends_on}`} className="schedule-center-gap-meta">
|
||||
Пробел: {formatDate(gap.starts_on)}{gap.ends_on !== gap.starts_on && ` — ${formatDate(gap.ends_on)}`}
|
||||
</span>)}
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function Issue({ issue }) {
|
||||
const isCritical = issue.severity === 'critical';
|
||||
const Icon = isCritical ? AlertCircle : AlertTriangle;
|
||||
return <article className={`schedule-center-issue is-${issue.severity}`}>
|
||||
<span><Icon size={20} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<div className="schedule-center-issue-heading"><h3>{issue.title}</h3><small>{issue.day_type_label}</small></div>
|
||||
<p>{issue.route_label}: {issue.description}</p>
|
||||
</div>
|
||||
<Link to={`/schedules?route=${issue.route_id}`} className="schedule-center-issue-action">
|
||||
Исправить<ArrowRight size={16} aria-hidden="true" />
|
||||
</Link>
|
||||
</article>;
|
||||
}
|
||||
|
||||
export default function ScheduleCenterPage() {
|
||||
const { activeCity } = useCity();
|
||||
const [targetDate, setTargetDate] = useState(minskDate);
|
||||
const [report, setReport] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const requestId = useRef(0);
|
||||
|
||||
const loadReport = useCallback(async ({ refresh = false } = {}) => {
|
||||
const request = ++requestId.current;
|
||||
setError('');
|
||||
if (refresh) setRefreshing(true);
|
||||
else setLoading(true);
|
||||
try {
|
||||
const { data } = await scheduleCenterApi.getOverview(targetDate);
|
||||
if (request === requestId.current) setReport(data);
|
||||
} catch (requestError) {
|
||||
if (request === requestId.current) {
|
||||
setError(requestError.response?.data?.error || 'Не удалось загрузить центр расписаний. Повторите попытку.');
|
||||
}
|
||||
} finally {
|
||||
if (request === requestId.current) {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
}, [targetDate]);
|
||||
|
||||
useEffect(() => {
|
||||
loadReport();
|
||||
return () => { requestId.current += 1; };
|
||||
}, [loadReport]);
|
||||
|
||||
const summary = report?.summary || { routes: 0, active: 0, upcoming: 0, conflicts: 0, gaps: 0 };
|
||||
|
||||
return <div className="schedule-center-page">
|
||||
<header className="schedule-center-heading">
|
||||
<div>
|
||||
<h1 className="page-title">Центр расписаний</h1>
|
||||
<p>Показывает, какие версии действуют на выбранную дату, что вступит в силу дальше и где периоды пересекаются или остаются без покрытия.</p>
|
||||
</div>
|
||||
<div className="schedule-center-actions">
|
||||
{activeCity?.schedule_source_url && <Link to="/schedule-import" className="btn-secondary"><Download size={16} aria-hidden="true" />Импорт</Link>}
|
||||
<button type="button" className="btn-secondary" onClick={() => loadReport({ refresh: true })} disabled={loading || refreshing}>
|
||||
<RefreshCw size={16} className={refreshing ? 'animate-spin' : ''} aria-hidden="true" />{refreshing ? 'Обновляем…' : 'Обновить'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="schedule-center-controls card" aria-label="Период обзора">
|
||||
<CalendarClock size={20} aria-hidden="true" />
|
||||
<label>Дата проверки<input className="input" type="date" value={targetDate} onChange={(event) => setTargetDate(event.target.value)} /></label>
|
||||
<p>Показан период с {formatDate(targetDate)} по {report ? formatDate(report.window_end) : '…'}.</p>
|
||||
</section>
|
||||
|
||||
{loading && !report ? <PageLoader /> : <>
|
||||
{error && <div className="schedule-center-message" role="alert"><AlertCircle size={20} aria-hidden="true" /><span>{error}</span><button type="button" onClick={() => loadReport()}>Повторить</button></div>}
|
||||
|
||||
{report && <>
|
||||
<section className="schedule-center-summary" aria-label="Сводка расписаний">
|
||||
<SummaryCard icon={CalendarClock} label="Маршрутов в обзоре" value={summary.routes} tone="neutral" />
|
||||
<SummaryCard icon={CheckCircle2} label="Действующих версий" value={summary.active} tone="active" />
|
||||
<SummaryCard icon={Clock3} label="Вступят в силу позже" value={summary.upcoming} tone="future" />
|
||||
<SummaryCard icon={AlertCircle} label="Пересечений периодов" value={summary.conflicts} tone="critical" />
|
||||
<SummaryCard icon={AlertTriangle} label="Пробелов в покрытии" value={summary.gaps} tone="warning" />
|
||||
</section>
|
||||
|
||||
<section className="schedule-center-calendar" aria-labelledby="schedule-center-calendar-title">
|
||||
<div className="schedule-center-section-heading">
|
||||
<div><h2 id="schedule-center-calendar-title">Календарь версий</h2><p>Каждая полоска — отдельная версия. Красная рамка означает пересечение с другой версией того же типа дня.</p></div>
|
||||
<div className="schedule-center-legend" aria-label="Обозначения"><span className="is-active">Действует</span><span className="is-upcoming">Будущая</span><span className="has-conflict">Пересечение</span></div>
|
||||
</div>
|
||||
<div className="schedule-center-route-list">
|
||||
{report.routes.map((route) => <article className="schedule-center-route" key={route.route_id}>
|
||||
<header>
|
||||
<div className="schedule-center-route-title"><RouteBadge route={route} /><div><h3>{route.route_name}</h3><p>Версии для Пн–Пт, Сб и Вс</p></div></div>
|
||||
<Link to={`/schedules?route=${route.route_id}`} className="schedule-center-route-action">Открыть редактор<ArrowRight size={16} aria-hidden="true" /></Link>
|
||||
</header>
|
||||
<div className="schedule-center-date-scale" aria-hidden="true"><span>{formatDate(report.target_date)}</span><span>+ 30 дней</span><span>+ 60 дней</span><span>{formatDate(report.window_end)}</span></div>
|
||||
<div className="schedule-center-lanes">
|
||||
{route.day_types.map((dayType) => <TimelineLane key={dayType.value} dayType={dayType} start={report.target_date} end={report.window_end} />)}
|
||||
</div>
|
||||
</article>)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="schedule-center-issues" aria-labelledby="schedule-center-issues-title">
|
||||
<div className="schedule-center-section-heading"><div><h2 id="schedule-center-issues-title">Требует внимания</h2><p>Центр ничего не исправляет сам: откройте редактор только для нужного маршрута.</p></div></div>
|
||||
{!report.issues.length ? <div className="schedule-center-clear"><CheckCircle2 size={40} aria-hidden="true" /><div><h3>Периоды согласованы</h3><p>На следующие 90 дней нет пересечений и пробелов в версиях расписаний.</p></div></div>
|
||||
: <div className="schedule-center-issue-list">{report.issues.map((issue) => <Issue key={issue.id} issue={issue} />)}</div>}
|
||||
</section>
|
||||
</>}
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
@@ -102,6 +102,7 @@ export const routesApi = {
|
||||
export const citiesApi = {
|
||||
getAll: () => api.get('/cities'),
|
||||
create: (data) => api.post('/cities', data),
|
||||
update: (cityId, data) => api.put(`/cities/${cityId}`, data),
|
||||
uploadAvatar: (cityId, file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
@@ -149,6 +150,10 @@ export const qualityApi = {
|
||||
getReport: () => api.get('/quality')
|
||||
};
|
||||
|
||||
export const scheduleCenterApi = {
|
||||
getOverview: (date) => api.get('/schedule-center', { params: { date } })
|
||||
};
|
||||
|
||||
// Schedules API (расписания)
|
||||
export const schedulesApi = {
|
||||
getRouteSchedules: (routeId) => api.get(`/routes/${routeId}/schedules`),
|
||||
|
||||
@@ -31,6 +31,7 @@ function buildRealtimeUrl() {
|
||||
export class RouteRealtimeClient {
|
||||
constructor({
|
||||
routeId,
|
||||
routeIds,
|
||||
token,
|
||||
apiKey = API_KEY,
|
||||
onStatusChange,
|
||||
@@ -38,7 +39,10 @@ export class RouteRealtimeClient {
|
||||
onVehiclePosition,
|
||||
onError
|
||||
}) {
|
||||
this.routeId = routeId;
|
||||
this.routeIds = [...new Set([...(Array.isArray(routeIds) ? routeIds : []), routeId]
|
||||
.map((value) => Number.parseInt(value, 10))
|
||||
.filter((value) => Number.isInteger(value) && value > 0))];
|
||||
this.routeId = this.routeIds[0] || null;
|
||||
this.token = token;
|
||||
this.apiKey = apiKey;
|
||||
this.onStatusChange = onStatusChange;
|
||||
@@ -52,7 +56,7 @@ export class RouteRealtimeClient {
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (!this.routeId || this.socket) {
|
||||
if (!this.routeIds.length || this.socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -112,15 +116,14 @@ export class RouteRealtimeClient {
|
||||
}));
|
||||
}
|
||||
|
||||
subscribeToRoute() {
|
||||
subscribeToRoutes() {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.send(JSON.stringify({
|
||||
type: 'subscribe:route',
|
||||
routeId: this.routeId
|
||||
}));
|
||||
this.routeIds.forEach((routeId) => {
|
||||
this.socket.send(JSON.stringify({ type: 'subscribe:route', routeId }));
|
||||
});
|
||||
}
|
||||
|
||||
handleMessage(rawMessage) {
|
||||
@@ -132,7 +135,7 @@ export class RouteRealtimeClient {
|
||||
break;
|
||||
case 'auth:ok':
|
||||
this.updateStatus('authenticated', payload);
|
||||
this.subscribeToRoute();
|
||||
this.subscribeToRoutes();
|
||||
break;
|
||||
case 'subscription:ok':
|
||||
this.updateStatus('connected', payload);
|
||||
@@ -141,7 +144,7 @@ export class RouteRealtimeClient {
|
||||
this.updateStatus('disconnected', payload);
|
||||
break;
|
||||
case 'snapshot:route':
|
||||
this.onSnapshot?.(payload.vehicles || []);
|
||||
this.onSnapshot?.(payload.vehicles || [], payload.routeId);
|
||||
break;
|
||||
case 'vehicle:position':
|
||||
if (payload.vehicle) {
|
||||
|
||||
Reference in New Issue
Block a user