Переделана навигация к датчикам, добавлена работа поиска тултипов на модели, добавлен функционал перехода из дашборда и истории тревог к датчику с тревогой на 3д модели
This commit is contained in:
@@ -163,10 +163,12 @@ const NavigationPage: React.FC = () => {
|
||||
const urlObjectId = searchParams.get('objectId')
|
||||
const urlObjectTitle = searchParams.get('objectTitle')
|
||||
const urlModelPath = searchParams.get('modelPath')
|
||||
const urlFocusSensorId = searchParams.get('focusSensorId')
|
||||
const objectId = currentObject.id || urlObjectId
|
||||
const objectTitle = currentObject.title || urlObjectTitle
|
||||
const [selectedModelPath, setSelectedModelPath] = useState<string>(urlModelPath || '')
|
||||
|
||||
|
||||
const handleModelLoaded = useCallback(() => {
|
||||
setIsModelReady(true)
|
||||
setModelError(null)
|
||||
@@ -377,6 +379,25 @@ const NavigationPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Обработка focusSensorId из URL (при переходе из таблиц событий)
|
||||
useEffect(() => {
|
||||
if (urlFocusSensorId && isModelReady && detectorsData) {
|
||||
console.log('[NavigationPage] Setting focusSensorId from URL:', urlFocusSensorId)
|
||||
setFocusedSensorId(urlFocusSensorId)
|
||||
setHighlightAllSensors(false)
|
||||
|
||||
// Автоматически открываем тултип датчика
|
||||
setTimeout(() => {
|
||||
handleSensorSelection(urlFocusSensorId)
|
||||
}, 500) // Задержка для полной инициализации
|
||||
|
||||
// Очищаем URL от параметра после применения
|
||||
const newUrl = new URL(window.location.href)
|
||||
newUrl.searchParams.delete('focusSensorId')
|
||||
window.history.replaceState({}, '', newUrl.toString())
|
||||
}
|
||||
}, [urlFocusSensorId, isModelReady, detectorsData])
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const s = (status || '').toLowerCase()
|
||||
switch (s) {
|
||||
|
||||
615
frontend/app/(protected)/navigation/page.tsx — копия 3
Normal file
615
frontend/app/(protected)/navigation/page.tsx — копия 3
Normal file
@@ -0,0 +1,615 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useCallback, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import Sidebar from '../../../components/ui/Sidebar'
|
||||
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
|
||||
import useNavigationStore from '../../store/navigationStore'
|
||||
import Monitoring from '../../../components/navigation/Monitoring'
|
||||
import FloorNavigation from '../../../components/navigation/FloorNavigation'
|
||||
import DetectorMenu from '../../../components/navigation/DetectorMenu'
|
||||
import ListOfDetectors from '../../../components/navigation/ListOfDetectors'
|
||||
import Sensors from '../../../components/navigation/Sensors'
|
||||
import AlertMenu from '../../../components/navigation/AlertMenu'
|
||||
import Notifications from '../../../components/notifications/Notifications'
|
||||
import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { ModelViewerProps } from '../../../components/model/ModelViewer'
|
||||
import * as statusColors from '../../../lib/statusColors'
|
||||
|
||||
const ModelViewer = dynamic<ModelViewerProps>(() => import('../../../components/model/ModelViewer'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="w-full h-full flex items-center justify-center bg-[#0e111a]">
|
||||
<div className="text-gray-300 animate-pulse">Загрузка 3D-модуля…</div>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
interface DetectorType {
|
||||
detector_id: number
|
||||
name: string
|
||||
serial_number: string
|
||||
object: string
|
||||
status: string
|
||||
checked: boolean
|
||||
type: string
|
||||
detector_type: string
|
||||
location: string
|
||||
floor: number
|
||||
notifications: Array<{
|
||||
id: number
|
||||
type: string
|
||||
message: string
|
||||
timestamp: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface NotificationType {
|
||||
id: number
|
||||
detector_id: number
|
||||
detector_name: string
|
||||
type: string
|
||||
status: string
|
||||
message: string
|
||||
timestamp: string
|
||||
location: string
|
||||
object: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
}
|
||||
|
||||
interface AlertType {
|
||||
id: number
|
||||
detector_id: number
|
||||
detector_name: string
|
||||
type: string
|
||||
status: string
|
||||
message: string
|
||||
timestamp: string
|
||||
location: string
|
||||
object: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
}
|
||||
|
||||
const NavigationPage: React.FC = () => {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const {
|
||||
currentObject,
|
||||
setCurrentObject,
|
||||
showMonitoring,
|
||||
showFloorNavigation,
|
||||
showNotifications,
|
||||
showListOfDetectors,
|
||||
showSensors,
|
||||
selectedDetector,
|
||||
showDetectorMenu,
|
||||
selectedNotification,
|
||||
showNotificationDetectorInfo,
|
||||
selectedAlert,
|
||||
showAlertMenu,
|
||||
closeMonitoring,
|
||||
closeFloorNavigation,
|
||||
closeNotifications,
|
||||
closeListOfDetectors,
|
||||
closeSensors,
|
||||
setSelectedDetector,
|
||||
setShowDetectorMenu,
|
||||
setSelectedNotification,
|
||||
setShowNotificationDetectorInfo,
|
||||
setSelectedAlert,
|
||||
setShowAlertMenu,
|
||||
showSensorHighlights,
|
||||
toggleSensorHighlights
|
||||
} = useNavigationStore()
|
||||
|
||||
const [detectorsData, setDetectorsData] = useState<{ detectors: Record<string, DetectorType> }>({ detectors: {} })
|
||||
const [detectorsError, setDetectorsError] = useState<string | null>(null)
|
||||
const [modelError, setModelError] = useState<string | null>(null)
|
||||
const [isModelReady, setIsModelReady] = useState(false)
|
||||
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
||||
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
||||
const sensorStatusMap = React.useMemo(() => {
|
||||
const map: Record<string, string> = {}
|
||||
Object.values(detectorsData.detectors).forEach(d => {
|
||||
if (d.serial_number && d.status) {
|
||||
map[String(d.serial_number).trim()] = d.status
|
||||
}
|
||||
})
|
||||
console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors')
|
||||
console.log('[NavigationPage] Sample sensor IDs in map:', Object.keys(map).slice(0, 5))
|
||||
return map
|
||||
}, [detectorsData])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDetector === null && selectedAlert === null) {
|
||||
setFocusedSensorId(null);
|
||||
}
|
||||
}, [selectedDetector, selectedAlert]);
|
||||
|
||||
// Управление выделением всех сенсоров при открытии/закрытии меню Sensors
|
||||
// ИСПРАВЛЕНО: Подсветка датчиков остается включенной всегда, независимо от состояния панели Sensors
|
||||
useEffect(() => {
|
||||
console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady)
|
||||
if (isModelReady) {
|
||||
// Всегда включаем подсветку всех сенсоров когда модель готова
|
||||
console.log('[NavigationPage] Setting highlightAllSensors to TRUE (always enabled)')
|
||||
setHighlightAllSensors(true)
|
||||
// Сбрасываем фокус только если панель Sensors закрыта
|
||||
if (!showSensors) {
|
||||
setFocusedSensorId(null)
|
||||
}
|
||||
}
|
||||
}, [showSensors, isModelReady])
|
||||
|
||||
// Дополнительный эффект для задержки выделения сенсоров при открытии меню
|
||||
// ИСПРАВЛЕНО: Задержка применяется только при открытии панели Sensors
|
||||
useEffect(() => {
|
||||
if (showSensors && isModelReady) {
|
||||
const timer = setTimeout(() => {
|
||||
console.log('[NavigationPage] Delayed highlightAllSensors to TRUE')
|
||||
setHighlightAllSensors(true)
|
||||
}, 500) // Задержка 500мс для полной инициализации модели
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [showSensors, isModelReady])
|
||||
|
||||
const urlObjectId = searchParams.get('objectId')
|
||||
const urlObjectTitle = searchParams.get('objectTitle')
|
||||
const urlModelPath = searchParams.get('modelPath')
|
||||
const objectId = currentObject.id || urlObjectId
|
||||
const objectTitle = currentObject.title || urlObjectTitle
|
||||
const [selectedModelPath, setSelectedModelPath] = useState<string>(urlModelPath || '')
|
||||
|
||||
const handleModelLoaded = useCallback(() => {
|
||||
setIsModelReady(true)
|
||||
setModelError(null)
|
||||
}, [])
|
||||
|
||||
const handleModelError = useCallback((error: string) => {
|
||||
console.error('[NavigationPage] Model loading error:', error)
|
||||
setModelError(error)
|
||||
setIsModelReady(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedModelPath) {
|
||||
setIsModelReady(false);
|
||||
setModelError(null);
|
||||
// Сохраняем выбранную модель в URL для восстановления при возврате
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('modelPath', selectedModelPath);
|
||||
window.history.replaceState(null, '', `?${params.toString()}`);
|
||||
}
|
||||
}, [selectedModelPath, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
||||
setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined)
|
||||
}
|
||||
}, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject])
|
||||
|
||||
// Восстановление выбранной модели из URL при загрузке страницы
|
||||
useEffect(() => {
|
||||
if (urlModelPath && !selectedModelPath) {
|
||||
setSelectedModelPath(urlModelPath);
|
||||
}
|
||||
}, [urlModelPath, selectedModelPath])
|
||||
|
||||
useEffect(() => {
|
||||
const loadDetectors = async () => {
|
||||
try {
|
||||
setDetectorsError(null)
|
||||
const res = await fetch('/api/get-detectors', { cache: 'no-store' })
|
||||
const text = await res.text()
|
||||
let payload: any
|
||||
try { payload = JSON.parse(text) } catch { payload = text }
|
||||
console.log('[NavigationPage] GET /api/get-detectors', { status: res.status, payload })
|
||||
if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить детекторов'))
|
||||
const data = payload?.data ?? payload
|
||||
const detectors = (data?.detectors ?? {}) as Record<string, DetectorType>
|
||||
console.log('[NavigationPage] Received detectors count:', Object.keys(detectors).length)
|
||||
console.log('[NavigationPage] Sample detector keys:', Object.keys(detectors).slice(0, 5))
|
||||
setDetectorsData({ detectors })
|
||||
} catch (e: any) {
|
||||
console.error('Ошибка загрузки детекторов:', e)
|
||||
setDetectorsError(e?.message || 'Ошибка при загрузке детекторов')
|
||||
}
|
||||
}
|
||||
loadDetectors()
|
||||
}, [])
|
||||
|
||||
const handleBackClick = () => {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
const handleDetectorMenuClick = (detector: DetectorType) => {
|
||||
// Для тестов. Выбор детектора.
|
||||
console.log('[NavigationPage] Selected detector click:', {
|
||||
detector_id: detector.detector_id,
|
||||
name: detector.name,
|
||||
serial_number: detector.serial_number,
|
||||
})
|
||||
|
||||
// Проверяем, что детектор имеет необходимые данные
|
||||
if (!detector || !detector.detector_id || !detector.serial_number) {
|
||||
console.warn('[NavigationPage] Invalid detector data, skipping menu display:', detector)
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedDetector?.serial_number === detector.serial_number && showDetectorMenu) {
|
||||
closeDetectorMenu()
|
||||
} else {
|
||||
setSelectedDetector(detector)
|
||||
setShowDetectorMenu(true)
|
||||
setFocusedSensorId(detector.serial_number)
|
||||
setShowAlertMenu(false)
|
||||
setSelectedAlert(null)
|
||||
// При открытии меню детектора - сбрасываем множественное выделение
|
||||
setHighlightAllSensors(false)
|
||||
}
|
||||
}
|
||||
|
||||
const closeDetectorMenu = () => {
|
||||
setShowDetectorMenu(false)
|
||||
setSelectedDetector(null)
|
||||
setFocusedSensorId(null)
|
||||
setSelectedAlert(null)
|
||||
// При закрытии меню детектора - выделяем все сенсоры снова
|
||||
setHighlightAllSensors(true)
|
||||
}
|
||||
|
||||
const handleNotificationClick = (notification: NotificationType) => {
|
||||
if (selectedNotification?.id === notification.id && showNotificationDetectorInfo) {
|
||||
setShowNotificationDetectorInfo(false)
|
||||
setSelectedNotification(null)
|
||||
} else {
|
||||
setSelectedNotification(notification)
|
||||
setShowNotificationDetectorInfo(true)
|
||||
}
|
||||
}
|
||||
|
||||
const closeNotificationDetectorInfo = () => {
|
||||
setShowNotificationDetectorInfo(false)
|
||||
setSelectedNotification(null)
|
||||
}
|
||||
|
||||
const closeAlertMenu = () => {
|
||||
setShowAlertMenu(false)
|
||||
setSelectedAlert(null)
|
||||
setFocusedSensorId(null)
|
||||
setSelectedDetector(null)
|
||||
// При закрытии меню алерта - выделяем все сенсоры снова
|
||||
setHighlightAllSensors(true)
|
||||
}
|
||||
|
||||
const handleAlertClick = (alert: AlertType) => {
|
||||
console.log('[NavigationPage] Alert clicked, focusing on detector in 3D scene:', alert)
|
||||
|
||||
const detector = Object.values(detectorsData.detectors).find(
|
||||
d => d.detector_id === alert.detector_id
|
||||
)
|
||||
|
||||
if (detector) {
|
||||
if (selectedAlert?.id === alert.id && showAlertMenu) {
|
||||
closeAlertMenu()
|
||||
} else {
|
||||
setSelectedAlert(alert)
|
||||
setShowAlertMenu(true)
|
||||
setFocusedSensorId(detector.serial_number)
|
||||
setShowDetectorMenu(false)
|
||||
setSelectedDetector(null)
|
||||
// При открытии меню алерта - сбрасываем множественное выделение
|
||||
setHighlightAllSensors(false)
|
||||
console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name)
|
||||
}
|
||||
} else {
|
||||
console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSensorSelection = (serialNumber: string | null) => {
|
||||
if (serialNumber === null) {
|
||||
setFocusedSensorId(null);
|
||||
closeDetectorMenu();
|
||||
closeAlertMenu();
|
||||
// If we're in Sensors menu and no sensor is selected, highlight all sensors
|
||||
if (showSensors) {
|
||||
setHighlightAllSensors(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusedSensorId === serialNumber) {
|
||||
setFocusedSensorId(null);
|
||||
closeDetectorMenu();
|
||||
closeAlertMenu();
|
||||
// If we're in Sensors menu and deselected the current sensor, highlight all sensors
|
||||
if (showSensors) {
|
||||
setHighlightAllSensors(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// При выборе конкретного сенсора - сбрасываем множественное выделение
|
||||
setHighlightAllSensors(false)
|
||||
|
||||
const detector = Object.values(detectorsData?.detectors || {}).find(
|
||||
(d) => d.serial_number === serialNumber
|
||||
);
|
||||
|
||||
if (detector) {
|
||||
if (showFloorNavigation || showListOfDetectors) {
|
||||
handleDetectorMenuClick(detector);
|
||||
} else if (detector.notifications && detector.notifications.length > 0) {
|
||||
const sortedNotifications = [...detector.notifications].sort((a, b) => {
|
||||
const priorityOrder: { [key: string]: number } = { critical: 0, warning: 1, info: 2 };
|
||||
return priorityOrder[a.priority.toLowerCase()] - priorityOrder[b.priority.toLowerCase()];
|
||||
});
|
||||
const notification = sortedNotifications[0];
|
||||
const alert: AlertType = {
|
||||
...notification,
|
||||
detector_id: detector.detector_id,
|
||||
detector_name: detector.name,
|
||||
location: detector.location,
|
||||
object: detector.object,
|
||||
status: detector.status,
|
||||
type: notification.type || 'info',
|
||||
};
|
||||
handleAlertClick(alert);
|
||||
} else {
|
||||
handleDetectorMenuClick(detector);
|
||||
}
|
||||
} else {
|
||||
setFocusedSensorId(null);
|
||||
closeDetectorMenu();
|
||||
closeAlertMenu();
|
||||
// If we're in Sensors menu and no valid detector found, highlight all sensors
|
||||
if (showSensors) {
|
||||
setHighlightAllSensors(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const s = (status || '').toLowerCase()
|
||||
switch (s) {
|
||||
case statusColors.STATUS_COLOR_CRITICAL:
|
||||
case 'critical':
|
||||
return 'Критический'
|
||||
case statusColors.STATUS_COLOR_WARNING:
|
||||
case 'warning':
|
||||
return 'Предупреждение'
|
||||
case statusColors.STATUS_COLOR_NORMAL:
|
||||
case 'normal':
|
||||
return 'Норма'
|
||||
default:
|
||||
return 'Неизвестно'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
||||
<AnimatedBackground />
|
||||
<div className="relative z-20">
|
||||
<Sidebar
|
||||
activeItem={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex-1 flex flex-col">
|
||||
|
||||
{showMonitoring && (
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<Monitoring
|
||||
onClose={closeMonitoring}
|
||||
onSelectModel={(path) => {
|
||||
console.log('[NavigationPage] Model selected:', path);
|
||||
setSelectedModelPath(path)
|
||||
setModelError(null)
|
||||
setIsModelReady(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFloorNavigation && (
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<FloorNavigation
|
||||
objectId={objectId || undefined}
|
||||
detectorsData={detectorsData}
|
||||
onDetectorMenuClick={handleDetectorMenuClick}
|
||||
onClose={closeFloorNavigation}
|
||||
is3DReady={isModelReady && !modelError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNotifications && (
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<Notifications
|
||||
objectId={objectId || undefined}
|
||||
detectorsData={detectorsData}
|
||||
onNotificationClick={handleNotificationClick}
|
||||
onClose={closeNotifications}
|
||||
/>
|
||||
{detectorsError && (
|
||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showListOfDetectors && (
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<ListOfDetectors
|
||||
objectId={objectId || undefined}
|
||||
detectorsData={detectorsData}
|
||||
onDetectorMenuClick={handleDetectorMenuClick}
|
||||
onClose={closeListOfDetectors}
|
||||
is3DReady={selectedModelPath ? !modelError : false}
|
||||
/>
|
||||
{detectorsError && (
|
||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSensors && (
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<Sensors
|
||||
objectId={objectId || undefined}
|
||||
detectorsData={detectorsData}
|
||||
onAlertClick={handleAlertClick}
|
||||
onClose={closeSensors}
|
||||
is3DReady={selectedModelPath ? !modelError : false}
|
||||
/>
|
||||
{detectorsError && (
|
||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNotifications && showNotificationDetectorInfo && selectedNotification && (() => {
|
||||
const detectorData = Object.values(detectorsData.detectors).find(
|
||||
detector => detector.detector_id === selectedNotification.detector_id
|
||||
);
|
||||
return detectorData ? (
|
||||
<div className="absolute left-[500px] top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<NotificationDetectorInfo
|
||||
detectorData={detectorData}
|
||||
onClose={closeNotificationDetectorInfo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{showFloorNavigation && showDetectorMenu && selectedDetector && (
|
||||
null
|
||||
)}
|
||||
|
||||
<header className="bg-[#161824] border-b border-gray-700 px-6 h-[73px] flex items-center">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleBackClick}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Назад к дашборду"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">Дашборд</span>
|
||||
<span className="text-gray-600">{'/'}</span>
|
||||
<span className="text-white">{objectTitle || 'Объект'}</span>
|
||||
<span className="text-gray-600">{'/'}</span>
|
||||
<span className="text-white">Навигация</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="h-full">
|
||||
{modelError ? (
|
||||
<>
|
||||
{console.log('[NavigationPage] Rendering error message, modelError:', modelError)}
|
||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
||||
<div className="text-red-400 text-lg font-semibold mb-4">
|
||||
Ошибка загрузки 3D модели
|
||||
</div>
|
||||
<div className="text-gray-300 mb-4">
|
||||
{modelError}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Используйте навигацию по этажам для просмотра детекторов
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : !selectedModelPath ? (
|
||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
||||
<div className="text-center p-8 flex flex-col items-center">
|
||||
<Image
|
||||
src="/icons/logo.png"
|
||||
alt="AerBIM HT Monitor"
|
||||
width={300}
|
||||
height={41}
|
||||
className="mb-6"
|
||||
/>
|
||||
<div className="text-gray-300 text-lg">
|
||||
Выберите модель для отображения
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ModelViewer
|
||||
key={selectedModelPath || 'no-model'}
|
||||
modelPath={selectedModelPath}
|
||||
onSelectModel={(path) => {
|
||||
console.log('[NavigationPage] Model selected:', path);
|
||||
setSelectedModelPath(path)
|
||||
setModelError(null)
|
||||
setIsModelReady(false)
|
||||
}}
|
||||
onModelLoaded={handleModelLoaded}
|
||||
onError={handleModelError}
|
||||
activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null}
|
||||
focusSensorId={focusedSensorId}
|
||||
highlightAllSensors={showSensorHighlights && highlightAllSensors}
|
||||
sensorStatusMap={sensorStatusMap}
|
||||
isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors}
|
||||
onSensorPick={handleSensorSelection}
|
||||
renderOverlay={({ anchor }) => (
|
||||
<>
|
||||
{selectedAlert && showAlertMenu && anchor ? (
|
||||
<AlertMenu
|
||||
alert={selectedAlert}
|
||||
isOpen={true}
|
||||
onClose={closeAlertMenu}
|
||||
getStatusText={getStatusText}
|
||||
compact={true}
|
||||
anchor={anchor}
|
||||
/>
|
||||
) : selectedDetector && showDetectorMenu && anchor ? (
|
||||
<DetectorMenu
|
||||
detector={selectedDetector}
|
||||
isOpen={true}
|
||||
onClose={closeDetectorMenu}
|
||||
getStatusText={getStatusText}
|
||||
compact={true}
|
||||
anchor={anchor}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NavigationPage
|
||||
@@ -117,6 +117,9 @@ export interface NavigationStore {
|
||||
isOnNavigationPage: () => boolean
|
||||
getCurrentRoute: () => string | null
|
||||
getActiveSidebarItem: () => number
|
||||
|
||||
// Навигация к датчику на 3D-модели
|
||||
navigateToSensor: (sensorSerialNumber: string, floor: number | null, viewType: 'building' | 'floor') => Promise<string | null>
|
||||
}
|
||||
|
||||
const useNavigationStore = create<NavigationStore>()(
|
||||
@@ -368,6 +371,105 @@ const useNavigationStore = create<NavigationStore>()(
|
||||
if (showListOfDetectors) return 7 // Список датчиков
|
||||
if (showSensors) return 8 // Сенсоры
|
||||
return 2 // Навигация (базовая)
|
||||
},
|
||||
|
||||
// Навигация к датчику на 3D-модели
|
||||
navigateToSensor: async (sensorSerialNumber: string, floor: number | null, viewType: 'building' | 'floor') => {
|
||||
const { currentObject, loadZones } = get()
|
||||
|
||||
if (!currentObject.id) {
|
||||
console.error('[navigateToSensor] No current object selected')
|
||||
return null
|
||||
}
|
||||
|
||||
// Загружаем зоны для объекта (из кэша или API)
|
||||
await loadZones(currentObject.id)
|
||||
|
||||
const { currentZones } = get()
|
||||
|
||||
if (!currentZones || currentZones.length === 0) {
|
||||
console.error('[navigateToSensor] No zones available for object', currentObject.id)
|
||||
return null
|
||||
}
|
||||
|
||||
let targetZone: Zone | undefined
|
||||
|
||||
if (viewType === 'building') {
|
||||
// Для общего вида здания - ищем самую верхнюю зону (первую в списке)
|
||||
targetZone = currentZones[0]
|
||||
console.log('[navigateToSensor] Building view - selected first zone:', targetZone?.name)
|
||||
} else if (viewType === 'floor') {
|
||||
// Для вида на этаже - ищем зону, где есть этот датчик
|
||||
// Сначала проверяем зоны с sensors массивом
|
||||
for (const zone of currentZones) {
|
||||
if (zone.sensors && Array.isArray(zone.sensors)) {
|
||||
const hasSensor = zone.sensors.some(s =>
|
||||
s.serial_number === sensorSerialNumber ||
|
||||
s.name === sensorSerialNumber
|
||||
)
|
||||
if (hasSensor) {
|
||||
targetZone = zone
|
||||
console.log('[navigateToSensor] Found sensor in zone:', zone.name, 'sensors:', zone.sensors.length)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Если не нашли по sensors, пробуем по floor
|
||||
if (!targetZone && floor !== null) {
|
||||
// Ищем зоны с соответствующим floor (кроме общего вида)
|
||||
const floorZones = currentZones.filter(z =>
|
||||
z.floor === floor &&
|
||||
z.order !== 0 &&
|
||||
z.model_path
|
||||
)
|
||||
|
||||
if (floorZones.length > 0) {
|
||||
targetZone = floorZones[0]
|
||||
console.log('[navigateToSensor] Found zone by floor:', targetZone.name, 'floor:', floor)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback на общий вид, если не нашли зону этажа
|
||||
if (!targetZone) {
|
||||
console.warn(`[navigateToSensor] No zone found with sensor ${sensorSerialNumber} or floor ${floor}, falling back to building view`)
|
||||
targetZone = currentZones[0]
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetZone || !targetZone.model_path) {
|
||||
console.error('[navigateToSensor] No valid zone with model_path found')
|
||||
return null
|
||||
}
|
||||
|
||||
// Устанавливаем состояние для навигации
|
||||
set({
|
||||
currentModelPath: targetZone.model_path,
|
||||
// Открываем Зоны контроля (Monitoring) - она автоматически закроется после загрузки модели
|
||||
showMonitoring: true,
|
||||
// Закрываем остальные меню
|
||||
showFloorNavigation: false,
|
||||
showNotifications: false,
|
||||
showListOfDetectors: false,
|
||||
// НЕ закрываем showSensors - оставляем как есть для подсветки датчиков
|
||||
// showSensors: false, <- Убрали!
|
||||
showDetectorMenu: false,
|
||||
showAlertMenu: false,
|
||||
selectedDetector: null,
|
||||
selectedAlert: null,
|
||||
})
|
||||
|
||||
console.log('[navigateToSensor] Navigation prepared:', {
|
||||
sensorSerialNumber,
|
||||
floor,
|
||||
viewType,
|
||||
modelPath: targetZone.model_path,
|
||||
zoneName: targetZone.name,
|
||||
zoneId: targetZone.id
|
||||
})
|
||||
|
||||
// Возвращаем serial_number для установки focusedSensorId в компоненте
|
||||
return sensorSerialNumber
|
||||
}
|
||||
}),
|
||||
{
|
||||
|
||||
380
frontend/app/store/navigationStore — копия 2.ts
Normal file
380
frontend/app/store/navigationStore — копия 2.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { Zone } from '@/app/types'
|
||||
|
||||
export interface DetectorType {
|
||||
detector_id: number
|
||||
name: string
|
||||
serial_number: string
|
||||
object: string
|
||||
status: string
|
||||
type: string
|
||||
detector_type: string
|
||||
location: string
|
||||
floor: number
|
||||
checked: boolean
|
||||
notifications: Array<{
|
||||
id: number
|
||||
type: string
|
||||
message: string
|
||||
timestamp: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface NotificationType {
|
||||
id: number
|
||||
detector_id: number
|
||||
detector_name: string
|
||||
type: string
|
||||
status: string
|
||||
message: string
|
||||
timestamp: string
|
||||
location: string
|
||||
object: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
}
|
||||
|
||||
interface AlertType {
|
||||
id: number
|
||||
detector_id: number
|
||||
detector_name: string
|
||||
type: string
|
||||
status: string
|
||||
message: string
|
||||
timestamp: string
|
||||
location: string
|
||||
object: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
}
|
||||
|
||||
export interface NavigationStore {
|
||||
currentObject: { id: string | undefined; title: string | undefined }
|
||||
navigationHistory: string[]
|
||||
currentSubmenu: string | null
|
||||
currentModelPath: string | null
|
||||
|
||||
// Состояния Зон
|
||||
currentZones: Zone[]
|
||||
zonesCache: Record<string, Zone[]>
|
||||
zonesLoading: boolean
|
||||
zonesError: string | null
|
||||
|
||||
showMonitoring: boolean
|
||||
showFloorNavigation: boolean
|
||||
showNotifications: boolean
|
||||
showListOfDetectors: boolean
|
||||
showSensors: boolean
|
||||
showSensorHighlights: boolean
|
||||
|
||||
selectedDetector: DetectorType | null
|
||||
showDetectorMenu: boolean
|
||||
selectedNotification: NotificationType | null
|
||||
showNotificationDetectorInfo: boolean
|
||||
selectedAlert: AlertType | null
|
||||
showAlertMenu: boolean
|
||||
|
||||
setCurrentObject: (id: string | undefined, title: string | undefined) => void
|
||||
clearCurrentObject: () => void
|
||||
addToHistory: (path: string) => void
|
||||
goBack: () => string | null
|
||||
setCurrentModelPath: (path: string) => void
|
||||
|
||||
setCurrentSubmenu: (submenu: string | null) => void
|
||||
clearSubmenu: () => void
|
||||
|
||||
// Действия с зонами
|
||||
loadZones: (objectId: string) => Promise<void>
|
||||
setZones: (zones: Zone[]) => void
|
||||
clearZones: () => void
|
||||
|
||||
openMonitoring: () => void
|
||||
closeMonitoring: () => void
|
||||
openFloorNavigation: () => void
|
||||
closeFloorNavigation: () => void
|
||||
openNotifications: () => void
|
||||
closeNotifications: () => void
|
||||
openListOfDetectors: () => void
|
||||
closeListOfDetectors: () => void
|
||||
openSensors: () => void
|
||||
closeSensors: () => void
|
||||
toggleSensorHighlights: () => void
|
||||
setSensorHighlights: (show: boolean) => void
|
||||
|
||||
closeAllMenus: () => void
|
||||
clearSelections: () => void
|
||||
|
||||
setSelectedDetector: (detector: DetectorType | null) => void
|
||||
setShowDetectorMenu: (show: boolean) => void
|
||||
setSelectedNotification: (notification: NotificationType | null) => void
|
||||
setShowNotificationDetectorInfo: (show: boolean) => void
|
||||
setSelectedAlert: (alert: AlertType | null) => void
|
||||
setShowAlertMenu: (show: boolean) => void
|
||||
|
||||
isOnNavigationPage: () => boolean
|
||||
getCurrentRoute: () => string | null
|
||||
getActiveSidebarItem: () => number
|
||||
}
|
||||
|
||||
const useNavigationStore = create<NavigationStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
currentObject: {
|
||||
id: undefined,
|
||||
title: undefined,
|
||||
},
|
||||
navigationHistory: [],
|
||||
currentSubmenu: null,
|
||||
currentModelPath: null,
|
||||
|
||||
currentZones: [],
|
||||
zonesCache: {},
|
||||
zonesLoading: false,
|
||||
zonesError: null,
|
||||
|
||||
showMonitoring: false,
|
||||
showFloorNavigation: false,
|
||||
showNotifications: false,
|
||||
showListOfDetectors: false,
|
||||
showSensors: false,
|
||||
showSensorHighlights: true,
|
||||
|
||||
selectedDetector: null,
|
||||
showDetectorMenu: false,
|
||||
selectedNotification: null,
|
||||
showNotificationDetectorInfo: false,
|
||||
selectedAlert: null,
|
||||
showAlertMenu: false,
|
||||
|
||||
setCurrentObject: (id: string | undefined, title: string | undefined) =>
|
||||
set({ currentObject: { id, title } }),
|
||||
|
||||
clearCurrentObject: () =>
|
||||
set({ currentObject: { id: undefined, title: undefined } }),
|
||||
|
||||
setCurrentModelPath: (path: string) => set({ currentModelPath: path }),
|
||||
|
||||
addToHistory: (path: string) => {
|
||||
const { navigationHistory } = get()
|
||||
const newHistory = [...navigationHistory, path]
|
||||
if (newHistory.length > 10) {
|
||||
newHistory.shift()
|
||||
}
|
||||
set({ navigationHistory: newHistory })
|
||||
},
|
||||
|
||||
goBack: () => {
|
||||
const { navigationHistory } = get()
|
||||
if (navigationHistory.length > 1) {
|
||||
const newHistory = [...navigationHistory]
|
||||
newHistory.pop()
|
||||
const previousPage = newHistory.pop()
|
||||
set({ navigationHistory: newHistory })
|
||||
return previousPage || null
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
setCurrentSubmenu: (submenu: string | null) =>
|
||||
set({ currentSubmenu: submenu }),
|
||||
|
||||
clearSubmenu: () =>
|
||||
set({ currentSubmenu: null }),
|
||||
|
||||
loadZones: async (objectId: string) => {
|
||||
const cache = get().zonesCache
|
||||
const cached = cache[objectId]
|
||||
const hasCached = Array.isArray(cached) && cached.length > 0
|
||||
if (hasCached) {
|
||||
// Показываем кэшированные зоны сразу, но обновляем в фоне
|
||||
set({ currentZones: cached, zonesLoading: true, zonesError: null })
|
||||
} else {
|
||||
set({ zonesLoading: true, zonesError: null })
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/get-zones?objectId=${encodeURIComponent(objectId)}`, { cache: 'no-store' })
|
||||
const text = await res.text()
|
||||
let payload: string | Record<string, unknown>
|
||||
try { payload = JSON.parse(text) } catch { payload = text }
|
||||
if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error as string || 'Не удалось получить зоны'))
|
||||
const zones: Zone[] = typeof payload === 'string' ? [] :
|
||||
Array.isArray(payload?.data) ? payload.data as Zone[] :
|
||||
(payload?.data && typeof payload.data === 'object' && 'zones' in payload.data ? (payload.data as { zones?: Zone[] }).zones :
|
||||
payload?.zones ? payload.zones as Zone[] : []) || []
|
||||
const normalized = zones.map((z) => ({
|
||||
...z,
|
||||
image_path: z.image_path ?? null,
|
||||
}))
|
||||
set((state) => ({
|
||||
currentZones: normalized,
|
||||
zonesCache: { ...state.zonesCache, [objectId]: normalized },
|
||||
zonesLoading: false,
|
||||
zonesError: null,
|
||||
}))
|
||||
} catch (e: unknown) {
|
||||
set({ zonesLoading: false, zonesError: (e as Error)?.message || 'Ошибка при загрузке зон' })
|
||||
}
|
||||
},
|
||||
|
||||
setZones: (zones: Zone[]) => set({ currentZones: zones }),
|
||||
clearZones: () => set({ currentZones: [] }),
|
||||
|
||||
openMonitoring: () => {
|
||||
set({
|
||||
showMonitoring: true,
|
||||
showFloorNavigation: false,
|
||||
showNotifications: false,
|
||||
showListOfDetectors: false,
|
||||
currentSubmenu: 'monitoring',
|
||||
showDetectorMenu: false,
|
||||
selectedDetector: null,
|
||||
showNotificationDetectorInfo: false,
|
||||
selectedNotification: null,
|
||||
zonesError: null // Очищаем ошибку зон при открытии мониторинга
|
||||
})
|
||||
const objId = get().currentObject.id
|
||||
if (objId) {
|
||||
// Вызываем загрузку зон сразу, но обновляем в фоне
|
||||
get().loadZones(objId)
|
||||
}
|
||||
},
|
||||
|
||||
closeMonitoring: () => set({
|
||||
showMonitoring: false,
|
||||
currentSubmenu: null
|
||||
}),
|
||||
|
||||
openFloorNavigation: () => set({
|
||||
showFloorNavigation: true,
|
||||
showMonitoring: false,
|
||||
showNotifications: false,
|
||||
showListOfDetectors: false,
|
||||
currentSubmenu: 'floors',
|
||||
showNotificationDetectorInfo: false,
|
||||
selectedNotification: null
|
||||
}),
|
||||
|
||||
closeFloorNavigation: () => set({
|
||||
showFloorNavigation: false,
|
||||
showDetectorMenu: false,
|
||||
selectedDetector: null,
|
||||
currentSubmenu: null
|
||||
}),
|
||||
|
||||
openNotifications: () => set({
|
||||
showNotifications: true,
|
||||
showMonitoring: false,
|
||||
showFloorNavigation: false,
|
||||
showListOfDetectors: false,
|
||||
currentSubmenu: 'notifications',
|
||||
showDetectorMenu: false,
|
||||
selectedDetector: null
|
||||
}),
|
||||
|
||||
closeNotifications: () => set({
|
||||
showNotifications: false,
|
||||
showNotificationDetectorInfo: false,
|
||||
selectedNotification: null,
|
||||
currentSubmenu: null
|
||||
}),
|
||||
|
||||
openListOfDetectors: () => set({
|
||||
showListOfDetectors: true,
|
||||
showMonitoring: false,
|
||||
showFloorNavigation: false,
|
||||
showNotifications: false,
|
||||
currentSubmenu: 'detectors',
|
||||
showDetectorMenu: false,
|
||||
selectedDetector: null,
|
||||
showNotificationDetectorInfo: false,
|
||||
selectedNotification: null
|
||||
}),
|
||||
|
||||
closeListOfDetectors: () => set({
|
||||
showListOfDetectors: false,
|
||||
showDetectorMenu: false,
|
||||
selectedDetector: null,
|
||||
currentSubmenu: null
|
||||
}),
|
||||
|
||||
openSensors: () => set({
|
||||
showSensors: true,
|
||||
showMonitoring: false,
|
||||
showFloorNavigation: false,
|
||||
showNotifications: false,
|
||||
showListOfDetectors: false,
|
||||
currentSubmenu: 'sensors',
|
||||
showDetectorMenu: false,
|
||||
selectedDetector: null,
|
||||
showNotificationDetectorInfo: false,
|
||||
selectedNotification: null
|
||||
}),
|
||||
|
||||
closeSensors: () => set({
|
||||
showSensors: false,
|
||||
showDetectorMenu: false,
|
||||
selectedDetector: null,
|
||||
currentSubmenu: null
|
||||
}),
|
||||
|
||||
toggleSensorHighlights: () => set((state) => ({ showSensorHighlights: !state.showSensorHighlights })),
|
||||
setSensorHighlights: (show: boolean) => set({ showSensorHighlights: show }),
|
||||
|
||||
closeAllMenus: () => {
|
||||
set({
|
||||
showMonitoring: false,
|
||||
showFloorNavigation: false,
|
||||
showNotifications: false,
|
||||
showListOfDetectors: false,
|
||||
showSensors: false,
|
||||
currentSubmenu: null,
|
||||
});
|
||||
get().clearSelections();
|
||||
},
|
||||
|
||||
clearSelections: () => set({
|
||||
selectedDetector: null,
|
||||
showDetectorMenu: false,
|
||||
selectedAlert: null,
|
||||
showAlertMenu: false,
|
||||
}),
|
||||
|
||||
setSelectedDetector: (detector: DetectorType | null) => set({ selectedDetector: detector }),
|
||||
setShowDetectorMenu: (show: boolean) => set({ showDetectorMenu: show }),
|
||||
setSelectedNotification: (notification: NotificationType | null) => set({ selectedNotification: notification }),
|
||||
setShowNotificationDetectorInfo: (show: boolean) => set({ showNotificationDetectorInfo: show }),
|
||||
setSelectedAlert: (alert: AlertType | null) => set({ selectedAlert: alert }),
|
||||
setShowAlertMenu: (show: boolean) => set({ showAlertMenu: show }),
|
||||
|
||||
isOnNavigationPage: () => {
|
||||
const { navigationHistory } = get()
|
||||
const currentRoute = navigationHistory[navigationHistory.length - 1]
|
||||
return currentRoute === '/navigation'
|
||||
},
|
||||
|
||||
getCurrentRoute: () => {
|
||||
const { navigationHistory } = get()
|
||||
return navigationHistory[navigationHistory.length - 1] || null
|
||||
},
|
||||
|
||||
getActiveSidebarItem: () => {
|
||||
const { showMonitoring, showFloorNavigation, showNotifications, showListOfDetectors, showSensors } = get()
|
||||
if (showMonitoring) return 3 // Зоны Мониторинга
|
||||
if (showFloorNavigation) return 4 // Навигация по этажам
|
||||
if (showNotifications) return 5 // Уведомления
|
||||
if (showListOfDetectors) return 7 // Список датчиков
|
||||
if (showSensors) return 8 // Сенсоры
|
||||
return 2 // Навигация (базовая)
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'navigation-store',
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
export default useNavigationStore
|
||||
|
||||
Reference in New Issue
Block a user