From b2836af333ccadc01be19fe6349b9977de8973fb Mon Sep 17 00:00:00 2001 From: german Date: Wed, 8 Jul 2026 22:52:21 +0300 Subject: [PATCH] =?UTF-8?q?egorin:=20=D0=BC=D0=B0=D0=BA=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B3=D0=BE=D1=80=D1=8D=D0=BB=D0=B5=D0=BA=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D0=B5=D1=82=D0=B5=D0=B9=20=E2=80=94=20=D0=A2=D0=9F=20+?= =?UTF-8?q?=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=B0=D1=80=D0=BA=20=D0=B1?= =?UTF-8?q?=D1=80=D0=B8=D0=B3=D0=B0=D0=B4=20(=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D1=88=D0=BA=D0=B0)=20=D0=BD=D0=B0=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=B5,=20=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81?= =?UTF-8?q?=20=D0=B4=D0=BE=D1=81=D0=BA=D0=BE=D0=B9=20=D0=BE=D0=B1=D1=8A?= =?UTF-8?q?=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9=20(=D0=BC=D0=B8?= =?UTF-8?q?=D0=B3=D0=B0=D0=BD=D0=B8=D0=B5+=D1=87=D0=B5=D0=BA=D0=B1=D0=BE?= =?UTF-8?q?=D0=BA=D1=81=D1=8B)=20=D0=B8=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D1=8F,=202=D0=93?= =?UTF-8?q?=D0=98=D0=A1=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/RadioMiniWindow.tsx | 57 +++++++++++++++++++- src/core/auth.ts | 2 +- src/core/radio.ts | 68 +++++++++++++++++++++++ src/data/mock.ts | 86 +++++++++++++++++------------- src/data/trips.ts | 10 ++-- src/index.css | 15 ++++++ src/modules/gps/MapView.tsx | 3 +- src/modules/gps/MapViewGL.tsx | 25 ++++++++- src/modules/smart/useSmartData.ts | 2 +- 9 files changed, 218 insertions(+), 50 deletions(-) diff --git a/src/components/RadioMiniWindow.tsx b/src/components/RadioMiniWindow.tsx index aa89b95..a8091af 100644 --- a/src/components/RadioMiniWindow.tsx +++ b/src/components/RadioMiniWindow.tsx @@ -1,5 +1,5 @@ import { createPortal } from 'react-dom'; -import { Radio, X, Volume2, VolumeX, Play } from 'lucide-react'; +import { Radio, X, Volume2, VolumeX, Play, Megaphone, ListChecks, Trash2, CheckSquare, Square, Volume1 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useRadio, speak, beep } from '@/core/radio'; @@ -8,9 +8,11 @@ const DOT: Record = { critical: 'bg-crit', warning: 'bg-warn', i // Мини-окно «Рация»: включение/отключение оповещения + эфир. Открывается кнопкой в шапке. export function RadioMiniWindow({ open, onClose }: { open: boolean; onClose: () => void }) { - const { on, ether, toggle } = useRadio(); + const { on, ether, toggle, board, readList, toggleTask, removeBoard, enqueueRead, removeRead, clearRead, readAll } = useRadio(); if (!open) return null; + const openTasks = board.filter((b) => b.kind === 'task' && !b.done).length; + const test = () => { beep(on ? 880 : 440, 0.14, 0.25); if (on) speak('Проверка связи. Рация в эфире.'); @@ -64,6 +66,57 @@ export function RadioMiniWindow({ open, onClose }: { open: boolean; onClose: () ))} + + {/* Доска объявлений */} +
+
+ Доска объявлений + {openTasks > 0 && {openTasks} задач} +
+
+ {board.map((b) => ( +
+ {b.kind === 'task' ? ( + + ) : ( + + )} +
+
{b.text}
+
+
+ + +
+
+ ))} +
+
+ + {/* Список чтения — голосовые уведомления */} +
+
+ Список чтения · {readList.length} +
+ + +
+
+
+ {readList.length === 0 &&
Список пуст. Добавьте объявление кнопкой 🔈.
} + {readList.map((t, i) => ( +
+ +
{t}
+ +
+ ))} +
+
, document.body, ); diff --git a/src/core/auth.ts b/src/core/auth.ts index a5c5945..99912f4 100644 --- a/src/core/auth.ts +++ b/src/core/auth.ts @@ -59,7 +59,7 @@ export const USERS: User[] = [ id: 'energetik-ro', name: 'Сидоров А.', role: 'Энергетик · часть, только просмотр', - access: { energy: { caps: ['view'], objects: ['cab-07'] } }, + access: { energy: { caps: ['view'], objects: ['tp-07'] } }, }, { id: 'energetik-rw', diff --git a/src/core/radio.ts b/src/core/radio.ts index 97c6c25..63f5c3c 100644 --- a/src/core/radio.ts +++ b/src/core/radio.ts @@ -68,11 +68,51 @@ export function radioReport(ev: MonitorEvent) { setTimeout(() => speak(`${prefix}${ev.callsign}. ${ev.text}`), ev.severity === 'critical' ? 500 : 200); } +// Доска объявлений: объявления и задачи бригад. Задачи закрываются чекбоксом (зачёркивание). +export interface BoardItem { + id: string; + text: string; + kind: 'announce' | 'task'; + urgent?: boolean; // мигающее уведомление + done?: boolean; // задача закрыта → текст перечёркнут + ts: string; +} + +const nowISO = () => new Date().toISOString(); +const uid = () => 'b' + Math.random().toString(36).slice(2, 9); + +const BOARD_SEED: BoardItem[] = [ + { id: 'brd-1', text: 'АВАРИЯ: обесточен ТП-14 (мкр. Бештау). Бригада ВЛ-1 в пути.', kind: 'announce', urgent: true, ts: nowISO() }, + { id: 'brd-2', text: 'Плановое отключение ТП-7 сегодня 14:00–16:00 (замена трансформатора).', kind: 'announce', ts: nowISO() }, + { id: 'brd-3', text: 'Заменить изолятор на опоре №42, ВЛ-10кВ «Центр».', kind: 'task', ts: nowISO() }, + { id: 'brd-4', text: 'Автовышке АГП-22: обрезка веток у ЛЭП, ул. Лесная.', kind: 'task', urgent: true, ts: nowISO() }, + { id: 'brd-5', text: 'Снять показания счётчиков ТП-3, ТП-9.', kind: 'task', done: true, ts: nowISO() }, +]; +const READ_SEED: string[] = [ + 'Внимание бригадам. На ТП-14 пропало сетевое питание, потребители переведены на резерв.', + 'Диспетчер: автовышке АГП-22 подтвердить прибытие на улицу Лесная.', +]; + +function loadBoard(): BoardItem[] { + try { const j = localStorage.getItem('sm-radio-board'); if (j) return JSON.parse(j); } catch { /* ignore */ } + return BOARD_SEED; +} +function saveBoard(v: BoardItem[]) { try { localStorage.setItem('sm-radio-board', JSON.stringify(v)); } catch { /* ignore */ } } + interface RadioState { on: boolean; ether: MonitorEvent[]; // лента «эфир» + board: BoardItem[]; // доска объявлений + readList: string[]; // список чтения (очередь голосовых уведомлений) toggle: () => void; push: (ev: MonitorEvent) => void; + toggleTask: (id: string) => void; // закрыть/открыть задачу (зачёркивание) + addBoard: (text: string, kind?: BoardItem['kind'], urgent?: boolean) => void; + removeBoard: (id: string) => void; + enqueueRead: (text: string) => void; // добавить голосовое уведомление в список чтения + removeRead: (i: number) => void; + clearRead: () => void; + readAll: () => void; // озвучить весь список чтения по очереди } const initOn = (() => { @@ -86,6 +126,34 @@ const initOn = (() => { export const useRadio = create((set, get) => ({ on: initOn, ether: [], + board: loadBoard(), + readList: READ_SEED, + toggleTask: (id) => + set((s) => { + const board = s.board.map((b) => (b.id === id ? { ...b, done: !b.done } : b)); + saveBoard(board); + return { board }; + }), + addBoard: (text, kind = 'announce', urgent = false) => + set((s) => { + const board = [{ id: uid(), text, kind, urgent, ts: nowISO() }, ...s.board].slice(0, 60); + saveBoard(board); + return { board }; + }), + removeBoard: (id) => + set((s) => { + const board = s.board.filter((b) => b.id !== id); + saveBoard(board); + return { board }; + }), + enqueueRead: (text) => set((s) => ({ readList: [...s.readList, text].slice(0, 40) })), + removeRead: (i) => set((s) => ({ readList: s.readList.filter((_, idx) => idx !== i) })), + clearRead: () => set({ readList: [] }), + readAll: () => { + ctx(); // прайм аудио на жесте + const list = get().readList; + list.forEach((t, i) => setTimeout(() => speak(t), i * 200)); // speechSynthesis сам ставит в очередь + }, toggle: () => { const on = !get().on; try { diff --git a/src/data/mock.ts b/src/data/mock.ts index e95cd5c..936b7cb 100644 --- a/src/data/mock.ts +++ b/src/data/mock.ts @@ -63,70 +63,80 @@ export const wbChannels: WbChannel[] = [ { id: 'mr6c_214/IN1', device: 'WB-MR6C #214', name: 'Датчик двери', kind: 'input', on: false }, ]; -// --- Transport / GPS: объекты Navtelecom (FLEX/NTCB) --- +// --- Городские электросети: бригадный автопарк (GPS) + трансформаторные подстанции (энерго) --- +const iso = (minAgo = 0) => new Date(Date.now() - minAgo * 60000).toISOString(); export const entities: MonitoredEntity[] = [ + // Бригадный автопарк — 5 машин (включая автовышку) { - id: 'veh-123', - clientId: 'tempelhoff', - name: 'ГАЗель-123', - type: 'transport', - online: true, - telemetry: { speed: 64, sat: 11, fuel: 58, voltage: 13.8 }, - lat: 44.05, - lon: 43.06, - updatedAt: new Date().toISOString(), + id: 'veh-agp22', clientId: 'tempelhoff', name: 'Автовышка АГП-22', type: 'transport', online: true, + telemetry: { speed: 28, sat: 12, fuel: 64, voltage: 13.9 }, meta: { boom: 'поднята' }, lat: 44.046, lon: 43.062, updatedAt: iso(0), }, { - id: 'veh-204', - clientId: 'tempelhoff', - name: 'МТЗ-82 трактор', - type: 'transport', - online: true, - telemetry: { speed: 0, sat: 9, fuel: 31, voltage: 12.9 }, - lat: 44.02, - lon: 43.11, - updatedAt: new Date().toISOString(), + id: 'veh-vl1', clientId: 'tempelhoff', name: 'Бригада ВЛ-1 (ГАЗель)', type: 'transport', online: true, + telemetry: { speed: 0, sat: 10, fuel: 47, voltage: 12.8 }, lat: 44.081, lon: 43.001, updatedAt: iso(1), }, { - id: 'cab-07', - clientId: 'tempelhoff', - name: 'Щит КТП-7', - type: 'energy', - online: false, - telemetry: { pwr_ext: 0, pwr_int: 3.9, temp: 24 }, - lat: 44.08, - lon: 43.0, - updatedAt: new Date(Date.now() - 9 * 60000).toISOString(), + id: 'veh-avar', clientId: 'tempelhoff', name: 'Аварийная бригада (УАЗ)', type: 'transport', online: true, + telemetry: { speed: 54, sat: 11, fuel: 38, voltage: 13.7 }, lat: 44.058, lon: 43.041, updatedAt: iso(0), + }, + { + id: 'veh-kabel', clientId: 'tempelhoff', name: 'Кабельщики (ГАЗ-3309)', type: 'transport', online: true, + telemetry: { speed: 12, sat: 9, fuel: 57, voltage: 13.6 }, lat: 44.033, lon: 43.089, updatedAt: iso(0), + }, + { + id: 'veh-master', clientId: 'tempelhoff', name: 'Мастер участка (Нива)', type: 'transport', online: true, + telemetry: { speed: 41, sat: 12, fuel: 72, voltage: 13.8 }, lat: 44.069, lon: 43.073, updatedAt: iso(0), + }, + // Трансформаторные подстанции — узлы энергомониторинга + { + id: 'tp-14', clientId: 'tempelhoff', name: 'ТП-14 «Бештау»', type: 'energy', online: false, + telemetry: { load: 0, u: 0, i: 0, temp: 23 }, meta: { feeder: 'ВЛ-10 «Север»' }, lat: 44.082, lon: 43.002, updatedAt: iso(9), + }, + { + id: 'tp-07', clientId: 'tempelhoff', name: 'ТП-7 «Центр»', type: 'energy', online: true, + telemetry: { load: 184, u: 402, i: 265, temp: 41 }, meta: { feeder: 'ВЛ-10 «Центр»' }, lat: 44.048, lon: 43.058, updatedAt: iso(0), + }, + { + id: 'tp-03', clientId: 'tempelhoff', name: 'ТП-3 «Западный»', type: 'energy', online: true, + telemetry: { load: 96, u: 398, i: 139, temp: 33 }, meta: { feeder: 'ВЛ-10 «Запад»' }, lat: 44.041, lon: 43.02, updatedAt: iso(0), + }, + { + id: 'tp-09', clientId: 'tempelhoff', name: 'ТП-9 «Южный»', type: 'energy', online: true, + telemetry: { load: 142, u: 401, i: 205, temp: 37 }, meta: { feeder: 'ВЛ-10 «Юг»' }, lat: 44.018, lon: 43.07, updatedAt: iso(0), + }, + { + id: 'rp-02', clientId: 'tempelhoff', name: 'РП-2 (распределительный пункт)', type: 'energy', online: true, + telemetry: { load: 310, u: 6300, i: 29, temp: 29 }, meta: { feeder: 'ПС 110/10 «Город»' }, lat: 44.062, lon: 43.045, updatedAt: iso(0), }, ]; export const events: MonitorEvent[] = [ { id: 'e1', - entityId: 'cab-07', - callsign: 'Щит КТП-7', + entityId: 'tp-14', + callsign: 'ТП-14 «Бештау»', severity: 'critical', type: 'power_loss', - text: 'Пропадание сетевого питания, переход на ИБП.', + text: 'Пропадание питания на ВЛ-10 «Север». Потребители обесточены, выехала ВЛ-1.', ts: new Date(Date.now() - 9 * 60000).toISOString(), ackRequired: true, }, { id: 'e2', - entityId: 'veh-204', - callsign: 'МТЗ-82 трактор', + entityId: 'tp-07', + callsign: 'ТП-7 «Центр»', severity: 'warning', - type: 'fuel_low', - text: 'Низкий уровень топлива (31%).', + type: 'overload', + text: 'Перегрев трансформатора 41 °C при нагрузке 184 кВт.', ts: new Date(Date.now() - 42 * 60000).toISOString(), }, { id: 'e3', - entityId: 'veh-123', - callsign: 'ГАЗель-123', + entityId: 'veh-agp22', + callsign: 'Автовышка АГП-22', severity: 'info', type: 'geofence_in', - text: 'Вход в геозону «База».', + text: 'Прибытие на объект: ул. Лесная (обрезка веток у ЛЭП).', ts: new Date(Date.now() - 78 * 60000).toISOString(), }, ]; diff --git a/src/data/trips.ts b/src/data/trips.ts index d1b3fd1..6665512 100644 --- a/src/data/trips.ts +++ b/src/data/trips.ts @@ -16,11 +16,11 @@ export interface Trip { const h = (n: number) => new Date(Date.now() - n * 3600000).toISOString(); export const trips: Trip[] = [ - { id: 't1', entityId: 'veh-123', start: h(6), end: h(5.2), from: 'База «Тэмпельхоф»', to: 'Пятигорск, рынок', distanceKm: 18.4, durationMin: 48, maxSpeed: 78, avgSpeed: 41, route: [[44.07, 43.05], [44.061, 43.058], [44.052, 43.064], [44.043, 43.071]] }, - { id: 't2', entityId: 'veh-123', start: h(4.5), end: h(3.7), from: 'Пятигорск, рынок', to: 'Ессентуки, склад', distanceKm: 22.1, durationMin: 49, maxSpeed: 84, avgSpeed: 38, route: [[44.043, 43.071], [44.05, 43.0], [44.05, 42.93], [44.045, 42.86]] }, - { id: 't3', entityId: 'veh-123', start: h(2), end: h(1.4), from: 'Ессентуки, склад', to: 'База «Тэмпельхоф»', distanceKm: 31.6, durationMin: 37, maxSpeed: 91, avgSpeed: 51, route: [[44.045, 42.86], [44.052, 42.95], [44.062, 43.0], [44.07, 43.05]] }, - { id: 't4', entityId: 'veh-204', start: h(8), end: h(6.5), from: 'Поле №3', to: 'МТМ (ремонт)', distanceKm: 6.2, durationMin: 90, maxSpeed: 24, avgSpeed: 9, route: [[44.02, 43.11], [44.025, 43.1], [44.03, 43.095]] }, - { id: 't5', entityId: 'veh-204', start: h(3), end: h(2.2), from: 'МТМ', to: 'Поле №5', distanceKm: 8.8, durationMin: 48, maxSpeed: 28, avgSpeed: 12, route: [[44.03, 43.095], [44.038, 43.085], [44.045, 43.08]] }, + { id: 't1', entityId: 'veh-avar', start: h(6), end: h(5.2), from: 'База «Тэмпельхоф»', to: 'Пятигорск, рынок', distanceKm: 18.4, durationMin: 48, maxSpeed: 78, avgSpeed: 41, route: [[44.07, 43.05], [44.061, 43.058], [44.052, 43.064], [44.043, 43.071]] }, + { id: 't2', entityId: 'veh-avar', start: h(4.5), end: h(3.7), from: 'Пятигорск, рынок', to: 'Ессентуки, склад', distanceKm: 22.1, durationMin: 49, maxSpeed: 84, avgSpeed: 38, route: [[44.043, 43.071], [44.05, 43.0], [44.05, 42.93], [44.045, 42.86]] }, + { id: 't3', entityId: 'veh-avar', start: h(2), end: h(1.4), from: 'Ессентуки, склад', to: 'База «Тэмпельхоф»', distanceKm: 31.6, durationMin: 37, maxSpeed: 91, avgSpeed: 51, route: [[44.045, 42.86], [44.052, 42.95], [44.062, 43.0], [44.07, 43.05]] }, + { id: 't4', entityId: 'veh-agp22', start: h(8), end: h(6.5), from: 'Поле №3', to: 'МТМ (ремонт)', distanceKm: 6.2, durationMin: 90, maxSpeed: 24, avgSpeed: 9, route: [[44.02, 43.11], [44.025, 43.1], [44.03, 43.095]] }, + { id: 't5', entityId: 'veh-agp22', start: h(3), end: h(2.2), from: 'МТМ', to: 'Поле №5', distanceKm: 8.8, durationMin: 48, maxSpeed: 28, avgSpeed: 12, route: [[44.03, 43.095], [44.038, 43.085], [44.045, 43.08]] }, ]; export function tripsOf(entityId: string): Trip[] { diff --git a/src/index.css b/src/index.css index 4a51757..8d7c0d9 100644 --- a/src/index.css +++ b/src/index.css @@ -61,6 +61,21 @@ } } +/* Мигающее уведомление на доске объявлений */ +.sm-blink { + animation: smblink 1.05s steps(1, end) infinite; +} +@keyframes smblink { + 0%, 55% { + box-shadow: inset 3px 0 0 0 rgb(var(--crit, 239 68 68)); + background-color: rgba(239, 68, 68, 0.14); + } + 56%, 100% { + box-shadow: inset 3px 0 0 0 transparent; + background-color: rgba(239, 68, 68, 0.04); + } +} + @media (prefers-reduced-motion: reduce) { * { animation-duration: 0.001ms !important; diff --git a/src/modules/gps/MapView.tsx b/src/modules/gps/MapView.tsx index 95eff55..0cadc43 100644 --- a/src/modules/gps/MapView.tsx +++ b/src/modules/gps/MapView.tsx @@ -92,7 +92,8 @@ export function MapView({ if (!elRef.current || mapRef.current) return; const map = L.map(elRef.current, { center: [44.05, 43.06], zoom: 11, zoomControl: true }); const bases = baseLayers(); - bases[theme === 'dark' ? 'Тёмная' : 'Светлая'].addTo(map); + // По умолчанию — 2ГИС на всех разделах (переключение слоёв остаётся в контроле) + (bases['2ГИС'] ?? bases[theme === 'dark' ? 'Тёмная' : 'Светлая']).addTo(map); L.control.layers(bases, undefined, { position: 'topright', collapsed: true }).addTo(map); mapRef.current = map; diff --git a/src/modules/gps/MapViewGL.tsx b/src/modules/gps/MapViewGL.tsx index 33d01bd..d597cf8 100644 --- a/src/modules/gps/MapViewGL.tsx +++ b/src/modules/gps/MapViewGL.tsx @@ -27,8 +27,29 @@ interface Props { events?: Array<{ id: string; lat: number; lon: number; color: string; char: string; label: string; sub?: string }>; } -const STYLE = (dark: boolean) => - `https://basemaps.cartocdn.com/gl/${dark ? 'dark-matter' : 'positron'}-gl-style/style.json`; +// По умолчанию — 2ГИС (растровый источник) на всех разделах. dark/light не влияет на тайлы 2ГИС. +const STYLE = (_dark: boolean): any => ({ + version: 8, + glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', + sources: { + '2gis': { + type: 'raster', + tiles: [ + 'https://tile0.maps.2gis.com/tiles?x={x}&y={y}&z={z}', + 'https://tile1.maps.2gis.com/tiles?x={x}&y={y}&z={z}', + 'https://tile2.maps.2gis.com/tiles?x={x}&y={y}&z={z}', + 'https://tile3.maps.2gis.com/tiles?x={x}&y={y}&z={z}', + ], + tileSize: 256, + attribution: '© 2ГИС', + maxzoom: 18, + }, + }, + layers: [ + { id: 'bg', type: 'background', paint: { 'background-color': '#0b0f14' } }, + { id: '2gis', type: 'raster', source: '2gis' }, + ], +}); function objColor(e: MonitoredEntity, colors?: Record): string { if (colors?.[e.id]) return colors[e.id]; diff --git a/src/modules/smart/useSmartData.ts b/src/modules/smart/useSmartData.ts index d88a0fc..9b11a10 100644 --- a/src/modules/smart/useSmartData.ts +++ b/src/modules/smart/useSmartData.ts @@ -27,7 +27,7 @@ export function useSmartData(user: User): { channels: WbChannel[]; events: Monit [ { id: 'door-' + new Date().toISOString(), - entityId: 'cab-07', + entityId: 'tp-07', callsign: 'Датчик двери · WB-MR6C #214', severity: (open ? 'warning' : 'info') as MonitorEvent['severity'], type: open ? 'door_open' : 'door_closed',