diff --git a/frontend/app/(protected)/navigation/page.tsx b/frontend/app/(protected)/navigation/page.tsx index a5177a8..5264c75 100644 --- a/frontend/app/(protected)/navigation/page.tsx +++ b/frontend/app/(protected)/navigation/page.tsx @@ -115,6 +115,7 @@ const NavigationPage: React.FC = () => { const [focusedSensorId, setFocusedSensorId] = useState(null) const [highlightAllSensors, setHighlightAllSensors] = useState(false) const sensorStatusMap = React.useMemo(() => { + // Создаём карту статусов всегда для отображения цветов датчиков const map: Record = {} Object.values(detectorsData.detectors).forEach(d => { if (d.serial_number && d.status) { @@ -347,27 +348,8 @@ const NavigationPage: React.FC = () => { ); 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); - } + // Всегда показываем меню детектора для всех датчиков + handleDetectorMenuClick(detector); } else { setFocusedSensorId(null); closeDetectorMenu(); diff --git a/frontend/app/(protected)/navigation/page.tsx — копия 4 b/frontend/app/(protected)/navigation/page.tsx — копия 4 new file mode 100644 index 0000000..a5177a8 --- /dev/null +++ b/frontend/app/(protected)/navigation/page.tsx — копия 4 @@ -0,0 +1,636 @@ +'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(() => import('../../../components/model/ModelViewer'), { + ssr: false, + loading: () => ( +
+
Загрузка 3D-модуля…
+
+ ), + }) + +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 }>({ detectors: {} }) + const [detectorsError, setDetectorsError] = useState(null) + const [modelError, setModelError] = useState(null) + const [isModelReady, setIsModelReady] = useState(false) + const [focusedSensorId, setFocusedSensorId] = useState(null) + const [highlightAllSensors, setHighlightAllSensors] = useState(false) + const sensorStatusMap = React.useMemo(() => { + const map: Record = {} + 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 urlFocusSensorId = searchParams.get('focusSensorId') + const objectId = currentObject.id || urlObjectId + const objectTitle = currentObject.title || urlObjectTitle + const [selectedModelPath, setSelectedModelPath] = useState(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 + 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); + } + } + }; + + // Обработка 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) { + 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 ( +
+ +
+ +
+ +
+ + {showMonitoring && ( +
+
+ { + console.log('[NavigationPage] Model selected:', path); + setSelectedModelPath(path) + setModelError(null) + setIsModelReady(false) + }} + /> +
+
+ )} + + {showFloorNavigation && ( +
+
+ +
+
+ )} + + {showNotifications && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showListOfDetectors && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showSensors && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showNotifications && showNotificationDetectorInfo && selectedNotification && (() => { + const detectorData = Object.values(detectorsData.detectors).find( + detector => detector.detector_id === selectedNotification.detector_id + ); + return detectorData ? ( +
+
+ +
+
+ ) : null; + })()} + + {showFloorNavigation && showDetectorMenu && selectedDetector && ( + null + )} + +
+
+
+ + +
+
+
+ +
+
+ {modelError ? ( + <> + {console.log('[NavigationPage] Rendering error message, modelError:', modelError)} +
+
+
+ Ошибка загрузки 3D модели +
+
+ {modelError} +
+
+ Используйте навигацию по этажам для просмотра детекторов +
+
+
+ + ) : !selectedModelPath ? ( +
+
+ AerBIM HT Monitor +
+ Выберите модель для отображения +
+
+
+ ) : ( + { + 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 ? ( + + ) : selectedDetector && showDetectorMenu && anchor ? ( + + ) : null} + + )} + /> + )} +
+
+
+
+ ) +} + +export default NavigationPage diff --git a/frontend/app/(protected)/navigation/page.tsx — копия 5 b/frontend/app/(protected)/navigation/page.tsx — копия 5 new file mode 100644 index 0000000..dd64cd0 --- /dev/null +++ b/frontend/app/(protected)/navigation/page.tsx — копия 5 @@ -0,0 +1,620 @@ +'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(() => import('../../../components/model/ModelViewer'), { + ssr: false, + loading: () => ( +
+
Загрузка 3D-модуля…
+
+ ), + }) + +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 }>({ detectors: {} }) + const [detectorsError, setDetectorsError] = useState(null) + const [modelError, setModelError] = useState(null) + const [isModelReady, setIsModelReady] = useState(false) + const [focusedSensorId, setFocusedSensorId] = useState(null) + const [highlightAllSensors, setHighlightAllSensors] = useState(false) + const [showStats, setShowStats] = useState(false) + const sensorStatusMap = React.useMemo(() => { + const map: Record = {} + 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 urlFocusSensorId = searchParams.get('focusSensorId') + const objectId = currentObject.id || urlObjectId + const objectTitle = currentObject.title || urlObjectTitle + const [selectedModelPath, setSelectedModelPath] = useState(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 + 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) { + // Всегда показываем меню детектора для всех датчиков + 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); + } + } + }; + + // Обработка 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) { + 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 ( +
+ +
+ +
+ +
+ + {showMonitoring && ( +
+
+ { + console.log('[NavigationPage] Model selected:', path); + setSelectedModelPath(path) + setModelError(null) + setIsModelReady(false) + }} + /> +
+
+ )} + + {showFloorNavigation && ( +
+
+ +
+
+ )} + + {showNotifications && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showListOfDetectors && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showSensors && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showNotifications && showNotificationDetectorInfo && selectedNotification && (() => { + const detectorData = Object.values(detectorsData.detectors).find( + detector => detector.detector_id === selectedNotification.detector_id + ); + return detectorData ? ( +
+
+ +
+
+ ) : null; + })()} + + {showFloorNavigation && showDetectorMenu && selectedDetector && ( + null + )} + +
+
+
+ + +
+
+
+ +
+
+ {modelError ? ( + <> + {console.log('[NavigationPage] Rendering error message, modelError:', modelError)} +
+
+
+ Ошибка загрузки 3D модели +
+
+ {modelError} +
+
+ Используйте навигацию по этажам для просмотра детекторов +
+
+
+ + ) : !selectedModelPath ? ( +
+
+ AerBIM HT Monitor +
+ Выберите модель для отображения +
+
+
+ ) : ( + { + 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} + showStats={showStats} + onToggleStats={() => setShowStats(!showStats)} + renderOverlay={({ anchor }) => ( + <> + {selectedAlert && showAlertMenu && anchor ? ( + + ) : selectedDetector && showDetectorMenu && anchor ? ( + + ) : null} + + )} + /> + )} +
+
+
+
+ ) +} + +export default NavigationPage diff --git a/frontend/app/(protected)/objects/page.tsx b/frontend/app/(protected)/objects/page.tsx index 30089c0..1649f97 100644 --- a/frontend/app/(protected)/objects/page.tsx +++ b/frontend/app/(protected)/objects/page.tsx @@ -3,7 +3,6 @@ import React, { useState, useEffect } from 'react' import ObjectGallery from '../../../components/objects/ObjectGallery' import { ObjectData } from '../../../components/objects/ObjectCard' -import Sidebar from '../../../components/ui/Sidebar' import AnimatedBackground from '../../../components/ui/AnimatedBackground' import { useRouter } from 'next/navigation' import Image from 'next/image' @@ -13,7 +12,6 @@ const transformRawToObjectData = (raw: any): ObjectData => { const rawId = raw?.id ?? raw?.object_id ?? raw?.uuid ?? raw?.name const object_id = typeof rawId === 'number' ? `object_${rawId}` : String(rawId ?? '') - // Если объект имеет числовой идентификатор, возвращаем его в виде строки с префиксом 'object_' const deriveTitle = (): string => { const t = (raw?.title || '').toString().trim() if (t) return t @@ -24,7 +22,6 @@ const transformRawToObjectData = (raw: any): ObjectData => { if (typeof numMatch === 'number' && !Number.isNaN(numMatch)) { return `Объект ${numMatch}` } - // Если объект не имеет числовой идентификатор, возвращаем его строковый идентификатор return idStr ? `Объект ${idStr}` : `Объект ${object_id}` } @@ -79,7 +76,6 @@ const ObjectsPage: React.FC = () => { } else if (Array.isArray(data?.objects)) { rawObjectsArray = data.objects } else if (data && typeof data === 'object') { - // если приходит как map { id: obj } rawObjectsArray = Object.values(data) } @@ -103,8 +99,9 @@ const ObjectsPage: React.FC = () => { if (loading) { return ( -
-
+
+ +

Загрузка объектов...

@@ -114,8 +111,9 @@ const ObjectsPage: React.FC = () => { if (error) { return ( -
-
+
+ +
@@ -130,79 +128,47 @@ const ObjectsPage: React.FC = () => { } return ( -
+
-
- -
- -
- {/* Приветствие и информация */} -
- {/* Логотип */} -
-
- AerBIM Logo -
-
- - {/* Приветствие */} -

- Добро пожаловать! -

- -

- Система мониторинга AerBIM Monitor -

- - {/* Версия системы */} -
-

- Версия системы: 3.0.0 -

-
- - {/* Блок с галереей объектов */} -
- {/* Заголовок галереи */} -

- Выберите объект для работы -

- - {/* Галерея объектов */} - +
+
+ AerBIM Logo
+
+ + Версия: 3.0.0 + +
+
+ + + {/* Main Content */} +
+
+ + {/* Заголовок */} +

+ Выберите объект для работы +

+ + {/* Галерея объектов */} +
- -
) } diff --git a/frontend/app/(protected)/objects/page.tsx — копия 2 b/frontend/app/(protected)/objects/page.tsx — копия 2 new file mode 100644 index 0000000..af1ffe4 --- /dev/null +++ b/frontend/app/(protected)/objects/page.tsx — копия 2 @@ -0,0 +1,208 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import ObjectGallery from '../../../components/objects/ObjectGallery' +import { ObjectData } from '../../../components/objects/ObjectCard' +import Sidebar from '../../../components/ui/Sidebar' +import AnimatedBackground from '../../../components/ui/AnimatedBackground' +import { useRouter } from 'next/navigation' +import Image from 'next/image' + +// Универсальная функция для преобразования объекта из бэкенда в ObjectData +const transformRawToObjectData = (raw: any): ObjectData => { + const rawId = raw?.id ?? raw?.object_id ?? raw?.uuid ?? raw?.name + const object_id = typeof rawId === 'number' ? `object_${rawId}` : String(rawId ?? '') + + // Если объект имеет числовой идентификатор, возвращаем его в виде строки с префиксом 'object_' + const deriveTitle = (): string => { + const t = (raw?.title || '').toString().trim() + if (t) return t + const idStr = String(rawId ?? '').toString() + const numMatch = typeof rawId === 'number' + ? rawId + : (() => { const m = idStr.match(/\d+/); return m ? Number(m[0]) : undefined })() + if (typeof numMatch === 'number' && !Number.isNaN(numMatch)) { + return `Объект ${numMatch}` + } + // Если объект не имеет числовой идентификатор, возвращаем его строковый идентификатор + return idStr ? `Объект ${idStr}` : `Объект ${object_id}` + } + + return { + object_id, + title: deriveTitle(), + description: raw?.description ?? `Описание объекта ${raw?.title ?? object_id}`, + image: raw?.image ?? null, + location: raw?.location ?? raw?.address ?? 'Не указано', + floors: Number(raw?.floors ?? 0), + area: String(raw?.area ?? ''), + type: raw?.type ?? 'object', + status: raw?.status ?? 'active', + } +} + +const ObjectsPage: React.FC = () => { + const [objects, setObjects] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [selectedObjectId, setSelectedObjectId] = useState(null) + const router = useRouter() + + useEffect(() => { + const loadData = async () => { + setLoading(true) + setError(null) + try { + const url = '/api/get-objects' + const res = await fetch(url, { cache: 'no-store' }) + const payloadText = await res.text() + let payload: any + try { payload = JSON.parse(payloadText) } catch { payload = payloadText } + console.log('[ObjectsPage] GET /api/get-objects', { status: res.status, payload }) + + if (!res.ok) { + const errorMessage = typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить данные объектов') + + if (errorMessage.includes('Authentication required') || res.status === 401) { + console.log('[ObjectsPage] Authentication required, redirecting to login') + router.push('/login') + return + } + + throw new Error(errorMessage) + } + + const data = (payload?.data ?? payload) as any + let rawObjectsArray: any[] = [] + if (Array.isArray(data)) { + rawObjectsArray = data + } else if (Array.isArray(data?.objects)) { + rawObjectsArray = data.objects + } else if (data && typeof data === 'object') { + // если приходит как map { id: obj } + rawObjectsArray = Object.values(data) + } + + const transformedObjects = rawObjectsArray.map(transformRawToObjectData) + setObjects(transformedObjects) + } catch (err: any) { + console.error('Ошибка при загрузке данных объектов:', err) + setError(err?.message || 'Произошла неизвестная ошибка') + } finally { + setLoading(false) + } + } + + loadData() + }, [router]) + + const handleObjectSelect = (objectId: string) => { + console.log('Object selected:', objectId) + setSelectedObjectId(objectId) + } + + if (loading) { + return ( +
+
+
+

Загрузка объектов...

+
+
+ ) + } + + if (error) { + return ( +
+
+
+ + + +
+

Ошибка загрузки данных

+

{error}

+

Если проблема повторяется, обратитесь к администратору

+
+
+ ) + } + + return ( +
+ + + {/* Sidebar скрыт на странице выбора объектов */} + +
+ {/* Приветствие и информация */} +
+ {/* Логотип */} +
+
+ AerBIM Logo +
+
+ + {/* Приветствие */} +

+ Добро пожаловать! +

+ +

+ Система мониторинга AerBIM Monitor +

+ + {/* Версия системы */} +
+

+ Версия системы: 3.0.0 +

+
+ + {/* Блок с галереей объектов */} +
+ {/* Заголовок галереи */} +

+ Выберите объект для работы +

+ + {/* Галерея объектов */} + +
+
+
+ + +
+ ) +} + +export default ObjectsPage diff --git a/frontend/components/alerts/AlertsList.tsx b/frontend/components/alerts/AlertsList.tsx index e798040..6dc20df 100644 --- a/frontend/components/alerts/AlertsList.tsx +++ b/frontend/components/alerts/AlertsList.tsx @@ -103,7 +103,6 @@ const AlertsList: React.FC = ({ alerts, onAcknowledgeToggle, in Детектор Статус Сообщение - Местоположение Приоритет Подтверждено Время @@ -133,9 +132,6 @@ const AlertsList: React.FC = ({ alerts, onAcknowledgeToggle, in {item.message} - - {item.location || '-'} - = ({ alerts, onAcknowledgeToggle, in ))} {filteredAlerts.length === 0 && ( - + Записей не найдено diff --git a/frontend/components/alerts/AlertsList.tsx — копия 2 b/frontend/components/alerts/AlertsList.tsx — копия 2 new file mode 100644 index 0000000..e798040 --- /dev/null +++ b/frontend/components/alerts/AlertsList.tsx — копия 2 @@ -0,0 +1,210 @@ +import React, { useState, useMemo } from 'react' +import { useRouter } from 'next/navigation' +import useNavigationStore from '../../app/store/navigationStore' +import * as statusColors from '../../lib/statusColors' + +interface AlertItem { + id: number + type: string + message: string + timestamp: string + acknowledged: boolean + priority: string + detector_id?: string + detector_name?: string + location?: string + object?: string + serial_number?: string + floor?: number +} + +interface AlertsListProps { + alerts: AlertItem[] + onAcknowledgeToggle: (alertId: number) => void + initialSearchTerm?: string +} + +const AlertsList: React.FC = ({ alerts, onAcknowledgeToggle, initialSearchTerm = '' }) => { + const router = useRouter() + const { navigateToSensor } = useNavigationStore() + const [searchTerm, setSearchTerm] = useState(initialSearchTerm) + + const filteredAlerts = useMemo(() => { + return alerts.filter(alert => { + const matchesSearch = searchTerm === '' || alert.detector_id?.toString() === searchTerm + return matchesSearch + }) + }, [alerts, searchTerm]) + + const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 } + const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 } + + const getStatusColor = (type: string) => { + switch (type) { + case 'critical': + return statusColors.STATUS_COLOR_CRITICAL + case 'warning': + return statusColors.STATUS_COLOR_WARNING + case 'info': + return statusColors.STATUS_COLOR_NORMAL + default: + return statusColors.STATUS_COLOR_UNKNOWN + } + } + + const handleGoTo3D = async (alert: AlertItem, viewType: 'building' | 'floor') => { + // Используем доступные идентификаторы датчика + const sensorId = alert.serial_number || alert.detector_name || alert.detector_id + + if (!sensorId) { + console.warn('[AlertsList] Alert missing sensor identifier:', alert) + return + } + + const sensorSerialNumber = await navigateToSensor( + sensorId, + alert.floor || null, + viewType + ) + + if (sensorSerialNumber) { + router.push(`/navigation?focusSensorId=${encodeURIComponent(sensorSerialNumber)}`) + } + } + + return ( +
+ {/* Поиск */} +
+
+ setSearchTerm(e.target.value)} + className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64" + /> + + + +
+
+ + {/* Таблица алертов */} +
+
+

История тревог

+ Всего: {filteredAlerts.length} +
+
+ + + + + + + + + + + + + + + {filteredAlerts.map((item) => ( + + + + + + + + + + + ))} + {filteredAlerts.length === 0 && ( + + + + )} + +
ДетекторСтатусСообщениеМестоположениеПриоритетПодтвержденоВремя3D Вид
+
{item.detector_name || 'Детектор'}
+ {item.detector_id ? ( +
ID: {item.detector_id}
+ ) : null} +
+
+
+ + {item.type === 'critical' ? 'Критический' : item.type === 'warning' ? 'Предупреждение' : 'Информация'} + +
+
+ {item.message} + + {item.location || '-'} + + + {item.priority === 'high' ? 'Высокий' : item.priority === 'medium' ? 'Средний' : 'Низкий'} + + + + {item.acknowledged ? 'Да' : 'Нет'} + + + + {new Date(item.timestamp).toLocaleString('ru-RU')} + +
+ + +
+
+ Записей не найдено +
+
+
+
+ ) +} + +export default AlertsList diff --git a/frontend/components/alerts/DetectorList.tsx b/frontend/components/alerts/DetectorList.tsx index 3d86a79..0027a0a 100644 --- a/frontend/components/alerts/DetectorList.tsx +++ b/frontend/components/alerts/DetectorList.tsx @@ -151,28 +151,6 @@ const DetectorList: React.FC = ({ objectId, selectedDetectors - @@ -185,14 +163,6 @@ const DetectorList: React.FC = ({ objectId, selectedDetectors return ( -
- 0} - onChange={(e) => { - if (e.target.checked) { - currentDetectors.forEach(detector => { - if (!selectedDetectors.includes(detector.detector_id)) { - onDetectorSelect(detector.detector_id, true) - } - }) - } else { - currentDetectors.forEach(detector => { - if (selectedDetectors.includes(detector.detector_id)) { - onDetectorSelect(detector.detector_id, false) - } - }) - } - }} - className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2" - /> - Детектор Статус Местоположение
- onDetectorSelect(detector.detector_id, e.target.checked)} - className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2" - /> - {detector.name}
diff --git a/frontend/components/alerts/DetectorList.tsx — копия 2 b/frontend/components/alerts/DetectorList.tsx — копия 2 new file mode 100644 index 0000000..3d86a79 --- /dev/null +++ b/frontend/components/alerts/DetectorList.tsx — копия 2 @@ -0,0 +1,329 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import * as statusColors from '../../lib/statusColors' + +interface Detector { + detector_id: number + name: string + location: string + status: string + object: string + floor: number + checked: boolean +} + +interface RawDetector { + detector_id: number + name: string + object: string + status: string + type: string + detector_type: string + location: string + floor: number + notifications: Array<{ + id: number + type: string + message: string + timestamp: string + acknowledged: boolean + priority: string + }> +} + +interface DetectorListProps { + objectId?: string + selectedDetectors: number[] + onDetectorSelect: (detectorId: number, selected: boolean) => void + initialSearchTerm?: string +} + +// Функция для генерации умного диапазона страниц +function getPaginationRange(currentPage: number, totalPages: number): (number | string)[] { + if (totalPages <= 7) { + // Если страниц мало - показываем все + return Array.from({ length: totalPages }, (_, i) => i + 1) + } + + // Всегда показываем первые 3 и последние 3 + const start = [1, 2, 3] + const end = [totalPages - 2, totalPages - 1, totalPages] + + if (currentPage <= 4) { + // Начало: 1 2 3 4 5 ... 11 12 13 + return [1, 2, 3, 4, 5, '...', ...end] + } + + if (currentPage >= totalPages - 3) { + // Конец: 1 2 3 ... 9 10 11 12 13 + return [...start, '...', totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages] + } + + // Середина: 1 2 3 ... 6 7 8 ... 11 12 13 + return [...start, '...', currentPage - 1, currentPage, currentPage + 1, '...', ...end] +} + +const DetectorList: React.FC = ({ objectId, selectedDetectors, onDetectorSelect, initialSearchTerm = '' }) => { + const [detectors, setDetectors] = useState([]) + const [searchTerm, setSearchTerm] = useState(initialSearchTerm) + const [currentPage, setCurrentPage] = useState(1) + const itemsPerPage = 20 + + useEffect(() => { + const loadDetectors = async () => { + try { + const res = await fetch('/api/get-detectors', { cache: 'no-store' }) + if (!res.ok) return + const payload = await res.json() + const detectorsData: Record = payload?.data?.detectors ?? {} + const rawArray: RawDetector[] = Object.values(detectorsData).filter( + (detector) => (objectId ? detector.object === objectId : true) + ) + const normalized: Detector[] = rawArray.map((d) => ({ + detector_id: d.detector_id, + name: d.name, + location: d.location, + status: d.status, + object: d.object, + floor: d.floor, + checked: false, + })) + console.log('[DetectorList] Payload:', payload) + setDetectors(normalized) + } catch (e) { + console.error('Failed to load detectors:', e) + } + } + loadDetectors() + }, [objectId]) + + const filteredDetectors = detectors.filter(detector => { + const matchesSearch = searchTerm === '' || detector.detector_id.toString() === searchTerm + + return matchesSearch + }) + + // Сброс на первую страницу при изменении поиска + useEffect(() => { + setCurrentPage(1) + }, [searchTerm]) + + // Пагинация + const totalPages = Math.ceil(filteredDetectors.length / itemsPerPage) + const startIndex = (currentPage - 1) * itemsPerPage + const endIndex = startIndex + itemsPerPage + const currentDetectors = filteredDetectors.slice(startIndex, endIndex) + const paginationRange = getPaginationRange(currentPage, totalPages) + + const handlePageChange = (page: number) => { + setCurrentPage(page) + // Скролл наверх таблицы + window.scrollTo({ top: 0, behavior: 'smooth' }) + } + + return ( +
+
+
+
+ +
+
+ setSearchTerm(e.target.value)} + className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64 text-sm font-medium" + style={{ fontFamily: 'Inter, sans-serif' }} + /> + + + +
+
+
+ + {/* Таблица детекторов */} +
+
+ + + + + + + + + + + + {currentDetectors.map((detector) => { + const isSelected = selectedDetectors.includes(detector.detector_id) + + return ( + + + + + + + + ) + })} + +
+ 0} + onChange={(e) => { + if (e.target.checked) { + currentDetectors.forEach(detector => { + if (!selectedDetectors.includes(detector.detector_id)) { + onDetectorSelect(detector.detector_id, true) + } + }) + } else { + currentDetectors.forEach(detector => { + if (selectedDetectors.includes(detector.detector_id)) { + onDetectorSelect(detector.detector_id, false) + } + }) + } + }} + className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2" + /> + ДетекторСтатусМестоположениеПроверен
+ onDetectorSelect(detector.detector_id, e.target.checked)} + className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2" + /> + {detector.name} +
+
+ + {detector.status === statusColors.STATUS_COLOR_CRITICAL + ? 'Критическое' + : detector.status === statusColors.STATUS_COLOR_WARNING + ? 'Предупреждение' + : 'Норма'} + +
+
{detector.location} + {detector.checked ? ( +
+ + + + Да +
+ ) : ( + Нет + )} +
+
+ + {/* Пагинация */} + {totalPages > 1 && ( +
+ {/* Кнопки пагинации */} +
+ {/* Кнопка "Предыдущая" */} + + + {/* Номера страниц */} + {paginationRange.map((page, index) => { + if (page === '...') { + return ( + + ... + + ) + } + + const pageNumber = page as number + const isActive = pageNumber === currentPage + + return ( + + ) + })} + + {/* Кнопка "Следующая" */} + +
+ + {/* Счётчик */} +
+ Показано {startIndex + 1}-{Math.min(endIndex, filteredDetectors.length)} из {filteredDetectors.length} датчиков +
+
+ )} +
+ + {/* Статы детекторов */} +
+
+
{filteredDetectors.length}
+
Всего
+
+
+
{filteredDetectors.filter(d => d.status === statusColors.STATUS_COLOR_NORMAL).length}
+
Норма
+
+
+
{filteredDetectors.filter(d => d.status === statusColors.STATUS_COLOR_WARNING).length}
+
Предупреждения
+
+
+
{filteredDetectors.filter(d => d.status === statusColors.STATUS_COLOR_CRITICAL).length}
+
Критические
+
+
+ + {filteredDetectors.length === 0 && ( +
+

Детекторы не найдены

+
+ )} +
+ ) +} + +export default DetectorList diff --git a/frontend/components/model/ModelViewer.tsx b/frontend/components/model/ModelViewer.tsx index e9d79b7..943f55b 100644 --- a/frontend/components/model/ModelViewer.tsx +++ b/frontend/components/model/ModelViewer.tsx @@ -69,6 +69,8 @@ const ModelViewer: React.FC = ({ onSensorPick, highlightAllSensors, sensorStatusMap, + showStats = false, + onToggleStats, }) => { const canvasRef = useRef(null) const engineRef = useRef>(null) @@ -920,6 +922,7 @@ const ModelViewer: React.FC = ({ onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights} sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights} /> + )} {/* UPDATED: Interactive overlay circles with hover effects */} diff --git a/frontend/components/model/ModelViewer.tsx — копия 4 b/frontend/components/model/ModelViewer.tsx — копия 4 index 51afe28..3487235 100644 --- a/frontend/components/model/ModelViewer.tsx — копия 4 +++ b/frontend/components/model/ModelViewer.tsx — копия 4 @@ -20,6 +20,7 @@ import { PointerEventTypes, PointerInfo, Matrix, + Ray, } from '@babylonjs/core' import '@babylonjs/loaders' @@ -55,6 +56,8 @@ export interface ModelViewerProps { onSensorPick?: (sensorId: string | null) => void highlightAllSensors?: boolean sensorStatusMap?: Record + showStats?: boolean + onToggleStats?: () => void } const ModelViewer: React.FC = ({ @@ -68,6 +71,8 @@ const ModelViewer: React.FC = ({ onSensorPick, highlightAllSensors, sensorStatusMap, + showStats = false, + onToggleStats, }) => { const canvasRef = useRef(null) const engineRef = useRef>(null) @@ -340,7 +345,8 @@ const ModelViewer: React.FC = ({ let engine: Engine try { - engine = new Engine(canvas, true, { stencil: true }) + // Оптимизация: используем FXAA вместо MSAA для снижения нагрузки на GPU + engine = new Engine(canvas, false, { stencil: true }) // false = отключаем MSAA } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) const message = `WebGL недоступен: ${errorMessage}` @@ -362,6 +368,9 @@ const ModelViewer: React.FC = ({ sceneRef.current = scene scene.clearColor = new Color4(0.1, 0.1, 0.15, 1) + + // Оптимизация: включаем FXAA (более легковесное сглаживание) + scene.imageProcessingConfiguration.fxaaEnabled = true const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene) camera.attachControl(canvas, true) @@ -689,22 +698,26 @@ const ModelViewer: React.FC = ({ const maxDimension = Math.max(size.x, size.y, size.z) const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5) - // Вычисляем оптимальные углы камеры для видимости датчика - // Позиционируем камеру спереди датчика с небольшим наклоном сверху - const directionToCamera = camera.position.subtract(center).normalize() + // Простое позиционирование камеры - всегда поворачиваемся к датчику + console.log('[ModelViewer] Calculating camera direction to sensor') - // Вычисляем целевые углы alpha и beta + // Вычисляем направление от текущей позиции камеры к датчику + const directionToSensor = center.subtract(camera.position).normalize() + + // Преобразуем в сферические координаты // alpha - горизонтальный угол (вокруг оси Y) - // beta - вертикальный угол (наклон) - let targetAlpha = Math.atan2(directionToCamera.x, directionToCamera.z) - let targetBeta = Math.acos(directionToCamera.y) + let targetAlpha = Math.atan2(directionToSensor.x, directionToSensor.z) - // Если датчик за стеной, позиционируем камеру спереди - // Используем направление от центра сцены к датчику - const sceneCenter = Vector3.Zero() - const directionFromSceneCenter = center.subtract(sceneCenter).normalize() - targetAlpha = Math.atan2(directionFromSceneCenter.x, directionFromSceneCenter.z) + Math.PI - targetBeta = Math.PI / 3 // 60 градусов - смотрим немного сверху + // beta - вертикальный угол (от вертикали) + // Используем оптимальный угол 60° для обзора + let targetBeta = Math.PI / 3 // 60° + + console.log('[ModelViewer] Calculated camera direction:', { + alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°', + beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°', + sensorPosition: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) }, + cameraPosition: { x: camera.position.x.toFixed(2), y: camera.position.y.toFixed(2), z: camera.position.z.toFixed(2) } + }) // Нормализуем alpha в диапазон [-PI, PI] while (targetAlpha > Math.PI) targetAlpha -= 2 * Math.PI @@ -715,6 +728,25 @@ const ModelViewer: React.FC = ({ scene.stopAnimation(camera) + // Логирование перед анимацией + console.log('[ModelViewer] Starting camera animation:', { + sensorId, + from: { + target: { x: camera.target.x.toFixed(2), y: camera.target.y.toFixed(2), z: camera.target.z.toFixed(2) }, + radius: camera.radius.toFixed(2), + alpha: (camera.alpha * 180 / Math.PI).toFixed(1) + '°', + beta: (camera.beta * 180 / Math.PI).toFixed(1) + '°' + }, + to: { + target: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) }, + radius: targetRadius.toFixed(2), + alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°', + beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°' + }, + alphaChange: ((targetAlpha - camera.alpha) * 180 / Math.PI).toFixed(1) + '°', + betaChange: ((targetBeta - camera.beta) * 180 / Math.PI).toFixed(1) + '°' + }) + const ease = new CubicEase() ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) const frameRate = 60 @@ -891,7 +923,49 @@ const ModelViewer: React.FC = ({ panActive={panActive} onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights} sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights} + onToggleStats={onToggleStats} + statsActive={showStats} /> + {/* Блок статистики датчиков */} + {showStats && sensorStatusMap && ( +
+
+
+ {/* Всего датчиков */} +
+ Всего: + {Object.keys(sensorStatusMap).length} +
+ +
+ + {/* Нормальный */} +
+ Норма: + + {Object.values(sensorStatusMap).filter(s => s === '#4ade80' || s.toLowerCase() === 'normal').length} + +
+ + {/* Предупреждение */} +
+ Предупр.: + + {Object.values(sensorStatusMap).filter(s => s === '#fb923c' || s.toLowerCase() === 'warning').length} + +
+ + {/* Критический */} +
+ Критич.: + + {Object.values(sensorStatusMap).filter(s => s === '#ef4444' || s.toLowerCase() === 'critical').length} + +
+
+
+
+ )} )} {/* UPDATED: Interactive overlay circles with hover effects */} diff --git a/frontend/components/model/ModelViewer.tsx — копия 5 b/frontend/components/model/ModelViewer.tsx — копия 5 deleted file mode 100644 index bccbded..0000000 --- a/frontend/components/model/ModelViewer.tsx — копия 5 +++ /dev/null @@ -1,944 +0,0 @@ -'use client' - -import React, { useEffect, useRef, useState } from 'react' - -import { - Engine, - Scene, - Vector3, - HemisphericLight, - ArcRotateCamera, - Color3, - Color4, - AbstractMesh, - Nullable, - HighlightLayer, - Animation, - CubicEase, - EasingFunction, - ImportMeshAsync, - PointerEventTypes, - PointerInfo, - Matrix, -} from '@babylonjs/core' -import '@babylonjs/loaders' - -import SceneToolbar from './SceneToolbar'; -import LoadingSpinner from '../ui/LoadingSpinner' -import useNavigationStore from '@/app/store/navigationStore' -import { - getSensorIdFromMesh, - collectSensorMeshes, - applyHighlightToMeshes, - statusToColor3, -} from './sensorHighlight' -import { - computeSensorOverlayCircles, - hexWithAlpha, -} from './sensorHighlightOverlay' - -export interface ModelViewerProps { - modelPath: string - onSelectModel: (path: string) => void; - onModelLoaded?: (modelData: { - meshes: AbstractMesh[] - boundingBox: { - min: { x: number; y: number; z: number } - max: { x: number; y: number; z: number } - } - }) => void - onError?: (error: string) => void - activeMenu?: string | null - focusSensorId?: string | null - renderOverlay?: (params: { anchor: { left: number; top: number } | null; info?: { name?: string; sensorId?: string } | null }) => React.ReactNode - isSensorSelectionEnabled?: boolean - onSensorPick?: (sensorId: string | null) => void - highlightAllSensors?: boolean - sensorStatusMap?: Record -} - -const ModelViewer: React.FC = ({ - modelPath, - onSelectModel, - onModelLoaded, - onError, - focusSensorId, - renderOverlay, - isSensorSelectionEnabled, - onSensorPick, - highlightAllSensors, - sensorStatusMap, -}) => { - const canvasRef = useRef(null) - const engineRef = useRef>(null) - const sceneRef = useRef>(null) - const [isLoading, setIsLoading] = useState(false) - const [loadingProgress, setLoadingProgress] = useState(0) - const [showModel, setShowModel] = useState(false) - const isInitializedRef = useRef(false) - const isDisposedRef = useRef(false) - const importedMeshesRef = useRef([]) - const highlightLayerRef = useRef(null) - const highlightedMeshesRef = useRef([]) - const chosenMeshRef = useRef(null) - const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null) - const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null) - const [modelReady, setModelReady] = useState(false) - const [panActive, setPanActive] = useState(false); - const [webglError, setWebglError] = useState(null) - const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState< - { sensorId: string; left: number; top: number; colorHex: string }[] - >([]) - // NEW: State for tracking hovered sensor in overlay circles - const [hoveredSensorId, setHoveredSensorId] = useState(null) - - const handlePan = () => setPanActive(!panActive); - - useEffect(() => { - const scene = sceneRef.current; - const camera = scene?.activeCamera as ArcRotateCamera; - const canvas = canvasRef.current; - - if (!scene || !camera || !canvas) { - return; - } - - let observer: any = null; - - if (panActive) { - camera.detachControl(); - - observer = scene.onPointerObservable.add((pointerInfo: PointerInfo) => { - const evt = pointerInfo.event; - - if (evt.buttons === 1) { - camera.inertialPanningX -= evt.movementX / camera.panningSensibility; - camera.inertialPanningY += evt.movementY / camera.panningSensibility; - } - else if (evt.buttons === 2) { - camera.inertialAlphaOffset -= evt.movementX / camera.angularSensibilityX; - camera.inertialBetaOffset -= evt.movementY / camera.angularSensibilityY; - } - }, PointerEventTypes.POINTERMOVE); - - } else { - camera.detachControl(); - camera.attachControl(canvas, true); - } - - return () => { - if (observer) { - scene.onPointerObservable.remove(observer); - } - if (!camera.isDisposed() && !camera.inputs.attachedToElement) { - camera.attachControl(canvas, true); - } - }; - }, [panActive, sceneRef, canvasRef]); - - const handleZoomIn = () => { - const camera = sceneRef.current?.activeCamera as ArcRotateCamera - if (camera) { - sceneRef.current?.stopAnimation(camera) - const ease = new CubicEase() - ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT) - - const frameRate = 60 - const durationMs = 300 - const totalFrames = Math.round((durationMs / 1000) * frameRate) - - const currentRadius = camera.radius - const targetRadius = Math.max(camera.lowerRadiusLimit ?? 0.1, currentRadius * 0.8) - - Animation.CreateAndStartAnimation( - 'zoomIn', - camera, - 'radius', - frameRate, - totalFrames, - currentRadius, - targetRadius, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - } - } - const handleZoomOut = () => { - const camera = sceneRef.current?.activeCamera as ArcRotateCamera - if (camera) { - sceneRef.current?.stopAnimation(camera) - const ease = new CubicEase() - ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT) - - const frameRate = 60 - const durationMs = 300 - const totalFrames = Math.round((durationMs / 1000) * frameRate) - - const currentRadius = camera.radius - const targetRadius = Math.min(camera.upperRadiusLimit ?? Infinity, currentRadius * 1.2) - - Animation.CreateAndStartAnimation( - 'zoomOut', - camera, - 'radius', - frameRate, - totalFrames, - currentRadius, - targetRadius, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - } - } - const handleTopView = () => { - const camera = sceneRef.current?.activeCamera as ArcRotateCamera; - if (camera) { - sceneRef.current?.stopAnimation(camera); - const ease = new CubicEase(); - ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT); - - const frameRate = 60; - const durationMs = 500; - const totalFrames = Math.round((durationMs / 1000) * frameRate); - - Animation.CreateAndStartAnimation( - 'topViewAlpha', - camera, - 'alpha', - frameRate, - totalFrames, - camera.alpha, - Math.PI / 2, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ); - - Animation.CreateAndStartAnimation( - 'topViewBeta', - camera, - 'beta', - frameRate, - totalFrames, - camera.beta, - 0, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ); - } - }; - - // NEW: Function to handle overlay circle click - const handleOverlayCircleClick = (sensorId: string) => { - console.log('[ModelViewer] Overlay circle clicked:', sensorId) - - // Find the mesh for this sensor - const allMeshes = importedMeshesRef.current || [] - const sensorMeshes = collectSensorMeshes(allMeshes) - const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId) - - if (!targetMesh) { - console.warn(`[ModelViewer] Mesh not found for sensor: ${sensorId}`) - return - } - - const scene = sceneRef.current - const camera = scene?.activeCamera as ArcRotateCamera - if (!scene || !camera) return - - // Calculate bounding box of the sensor mesh - const bbox = (typeof targetMesh.getHierarchyBoundingVectors === 'function') - ? targetMesh.getHierarchyBoundingVectors() - : { - min: targetMesh.getBoundingInfo().boundingBox.minimumWorld, - max: targetMesh.getBoundingInfo().boundingBox.maximumWorld - } - - const center = bbox.min.add(bbox.max).scale(0.5) - const size = bbox.max.subtract(bbox.min) - const maxDimension = Math.max(size.x, size.y, size.z) - - // Calculate optimal camera distance - const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5) - - // Stop any current animations - scene.stopAnimation(camera) - - // Setup easing - const ease = new CubicEase() - ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) - - const frameRate = 60 - const durationMs = 600 // 0.6 seconds for smooth animation - const totalFrames = Math.round((durationMs / 1000) * frameRate) - - // Animate camera target position - Animation.CreateAndStartAnimation( - 'camTarget', - camera, - 'target', - frameRate, - totalFrames, - camera.target.clone(), - center.clone(), - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - - // Animate camera radius (zoom) - Animation.CreateAndStartAnimation( - 'camRadius', - camera, - 'radius', - frameRate, - totalFrames, - camera.radius, - targetRadius, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - - // Call callback to display tooltip - onSensorPick?.(sensorId) - - console.log('[ModelViewer] Camera animation started for sensor:', sensorId) - } - - useEffect(() => { - isDisposedRef.current = false - isInitializedRef.current = false - return () => { - isDisposedRef.current = true - } - }, []) - - useEffect(() => { - if (!canvasRef.current || isInitializedRef.current) return - - const canvas = canvasRef.current - setWebglError(null) - - let hasWebGL = false - try { - const testCanvas = document.createElement('canvas') - const gl = - testCanvas.getContext('webgl2') || - testCanvas.getContext('webgl') || - testCanvas.getContext('experimental-webgl') - hasWebGL = !!gl - } catch { - hasWebGL = false - } - - if (!hasWebGL) { - const message = 'WebGL не поддерживается в текущем окружении' - setWebglError(message) - onError?.(message) - setIsLoading(false) - setModelReady(false) - return - } - - let engine: Engine - try { - // Оптимизация: используем FXAA вместо MSAA для снижения нагрузки на GPU - engine = new Engine(canvas, false, { stencil: true }) // false = отключаем MSAA - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const message = `WebGL недоступен: ${errorMessage}` - setWebglError(message) - onError?.(message) - setIsLoading(false) - setModelReady(false) - return - } - engineRef.current = engine - - engine.runRenderLoop(() => { - if (!isDisposedRef.current && sceneRef.current) { - sceneRef.current.render() - } - }) - - const scene = new Scene(engine) - sceneRef.current = scene - - scene.clearColor = new Color4(0.1, 0.1, 0.15, 1) - - // Оптимизация: включаем FXAA (более легковесное сглаживание) - scene.imageProcessingConfiguration.fxaaEnabled = true - - const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene) - camera.attachControl(canvas, true) - camera.lowerRadiusLimit = 2 - camera.upperRadiusLimit = 200 - camera.wheelDeltaPercentage = 0.01 - camera.panningSensibility = 50 - camera.angularSensibilityX = 1000 - camera.angularSensibilityY = 1000 - - const ambientLight = new HemisphericLight('ambientLight', new Vector3(0, 1, 0), scene) - ambientLight.intensity = 0.4 - ambientLight.diffuse = new Color3(0.7, 0.7, 0.8) - ambientLight.specular = new Color3(0.2, 0.2, 0.3) - ambientLight.groundColor = new Color3(0.3, 0.3, 0.4) - - const keyLight = new HemisphericLight('keyLight', new Vector3(1, 1, 0), scene) - keyLight.intensity = 0.6 - keyLight.diffuse = new Color3(1, 1, 0.9) - keyLight.specular = new Color3(1, 1, 0.9) - - const fillLight = new HemisphericLight('fillLight', new Vector3(-1, 0.5, -1), scene) - fillLight.intensity = 0.3 - fillLight.diffuse = new Color3(0.8, 0.8, 1) - - const hl = new HighlightLayer('highlight-layer', scene, { - mainTextureRatio: 1, - blurTextureSizeRatio: 1, - }) - hl.innerGlow = false - hl.outerGlow = true - hl.blurHorizontalSize = 2 - hl.blurVerticalSize = 2 - highlightLayerRef.current = hl - - const handleResize = () => { - if (!isDisposedRef.current) { - engine.resize() - } - } - window.addEventListener('resize', handleResize) - - isInitializedRef.current = true - - return () => { - isDisposedRef.current = true - isInitializedRef.current = false - window.removeEventListener('resize', handleResize) - - highlightLayerRef.current?.dispose() - highlightLayerRef.current = null - if (engineRef.current) { - engineRef.current.dispose() - engineRef.current = null - } - sceneRef.current = null - } - }, [onError]) - - useEffect(() => { - if (!modelPath || !sceneRef.current || !engineRef.current) return - - const scene = sceneRef.current - - setIsLoading(true) - setLoadingProgress(0) - setShowModel(false) - setModelReady(false) - - const loadModel = async () => { - try { - console.log('[ModelViewer] Starting to load model:', modelPath) - - // UI элемент загрузчика (есть эффект замедленности) - const progressInterval = setInterval(() => { - setLoadingProgress(prev => { - if (prev >= 90) { - clearInterval(progressInterval) - return 90 - } - return prev + Math.random() * 15 - }) - }, 100) - - // Use the correct ImportMeshAsync signature: (url, scene, onProgress) - const result = await ImportMeshAsync(modelPath, scene, (evt) => { - if (evt.lengthComputable) { - const progress = (evt.loaded / evt.total) * 100 - setLoadingProgress(progress) - console.log('[ModelViewer] Loading progress:', progress) - } - }) - - clearInterval(progressInterval) - - if (isDisposedRef.current) { - console.log('[ModelViewer] Component disposed during load') - return - } - - console.log('[ModelViewer] Model loaded successfully:', { - meshesCount: result.meshes.length, - particleSystemsCount: result.particleSystems.length, - skeletonsCount: result.skeletons.length, - animationGroupsCount: result.animationGroups.length - }) - - importedMeshesRef.current = result.meshes - - if (result.meshes.length > 0) { - const boundingBox = result.meshes[0].getHierarchyBoundingVectors() - - onModelLoaded?.({ - meshes: result.meshes, - boundingBox: { - min: { x: boundingBox.min.x, y: boundingBox.min.y, z: boundingBox.min.z }, - max: { x: boundingBox.max.x, y: boundingBox.max.y, z: boundingBox.max.z }, - }, - }) - - // Автоматическое кадрирование камеры для отображения всей модели - const camera = scene.activeCamera as ArcRotateCamera - if (camera) { - const center = boundingBox.min.add(boundingBox.max).scale(0.5) - const size = boundingBox.max.subtract(boundingBox.min) - const maxDimension = Math.max(size.x, size.y, size.z) - - // Устанавливаем оптимальное расстояние камеры - const targetRadius = maxDimension * 2.5 // Множитель для комфортного отступа - - // Плавная анимация камеры к центру модели - scene.stopAnimation(camera) - - const ease = new CubicEase() - ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) - - const frameRate = 60 - const durationMs = 800 // 0.8 секунды - const totalFrames = Math.round((durationMs / 1000) * frameRate) - - // Анимация позиции камеры - Animation.CreateAndStartAnimation( - 'frameCameraTarget', - camera, - 'target', - frameRate, - totalFrames, - camera.target.clone(), - center.clone(), - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - - // Анимация зума - Animation.CreateAndStartAnimation( - 'frameCameraRadius', - camera, - 'radius', - frameRate, - totalFrames, - camera.radius, - targetRadius, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - - console.log('[ModelViewer] Camera framed to model:', { center, targetRadius, maxDimension }) - } - } - - setLoadingProgress(100) - setShowModel(true) - setModelReady(true) - setIsLoading(false) - } catch (error) { - if (isDisposedRef.current) return - const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка' - console.error('[ModelViewer] Error loading model:', errorMessage) - const message = `Ошибка при загрузке модели: ${errorMessage}` - onError?.(message) - setIsLoading(false) - setModelReady(false) - } - } - - loadModel() - }, [modelPath, onModelLoaded, onError]) - - useEffect(() => { - if (!highlightAllSensors || focusSensorId || !modelReady) { - setAllSensorsOverlayCircles([]) - return - } - - const scene = sceneRef.current - const engine = engineRef.current - if (!scene || !engine) { - setAllSensorsOverlayCircles([]) - return - } - - const allMeshes = importedMeshesRef.current || [] - const sensorMeshes = collectSensorMeshes(allMeshes) - if (sensorMeshes.length === 0) { - setAllSensorsOverlayCircles([]) - return - } - - const engineTyped = engine as Engine - const updateCircles = () => { - const circles = computeSensorOverlayCircles({ - scene, - engine: engineTyped, - meshes: sensorMeshes, - sensorStatusMap, - }) - setAllSensorsOverlayCircles(circles) - } - - updateCircles() - const observer = scene.onBeforeRenderObservable.add(updateCircles) - return () => { - scene.onBeforeRenderObservable.remove(observer) - setAllSensorsOverlayCircles([]) - } - }, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap]) - - useEffect(() => { - if (!highlightAllSensors || focusSensorId || !modelReady) { - return - } - - const scene = sceneRef.current - if (!scene) return - - const allMeshes = importedMeshesRef.current || [] - if (allMeshes.length === 0) { - return - } - - const sensorMeshes = collectSensorMeshes(allMeshes) - - console.log('[ModelViewer] Total meshes in model:', allMeshes.length) - console.log('[ModelViewer] Sensor meshes found:', sensorMeshes.length) - - // Log first 5 sensor IDs found in meshes - const sensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean).slice(0, 5) - console.log('[ModelViewer] Sample sensor IDs from meshes:', sensorIds) - - if (sensorMeshes.length === 0) { - console.warn('[ModelViewer] No sensor meshes found in 3D model!') - return - } - - applyHighlightToMeshes( - highlightLayerRef.current, - highlightedMeshesRef, - sensorMeshes, - mesh => { - const sid = getSensorIdFromMesh(mesh) - const status = sid ? sensorStatusMap?.[sid] : undefined - return statusToColor3(status ?? null) - }, - ) - }, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap]) - - useEffect(() => { - if (!focusSensorId || !modelReady) { - for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } - highlightedMeshesRef.current = [] - highlightLayerRef.current?.removeAllMeshes() - chosenMeshRef.current = null - setOverlayPos(null) - setOverlayData(null) - setAllSensorsOverlayCircles([]) - return - } - - const sensorId = (focusSensorId ?? '').trim() - if (!sensorId) { - for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } - highlightedMeshesRef.current = [] - highlightLayerRef.current?.removeAllMeshes() - chosenMeshRef.current = null - setOverlayPos(null) - setOverlayData(null) - return - } - - const allMeshes = importedMeshesRef.current || [] - - if (allMeshes.length === 0) { - for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } - highlightedMeshesRef.current = [] - highlightLayerRef.current?.removeAllMeshes() - chosenMeshRef.current = null - setOverlayPos(null) - setOverlayData(null) - return - } - - const sensorMeshes = collectSensorMeshes(allMeshes) - const allSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)) - const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId) - - console.log('[ModelViewer] Sensor focus', { - requested: sensorId, - totalImportedMeshes: allMeshes.length, - totalSensorMeshes: sensorMeshes.length, - allSensorIds: allSensorIds, - chosen: chosen ? { id: chosen.id, name: chosen.name, uniqueId: chosen.uniqueId, parent: chosen.parent?.name } : null, - source: 'result.meshes', - }) - - const scene = sceneRef.current! - - if (chosen) { - try { - const camera = scene.activeCamera as ArcRotateCamera - const bbox = (typeof chosen.getHierarchyBoundingVectors === 'function') - ? chosen.getHierarchyBoundingVectors() - : { min: chosen.getBoundingInfo().boundingBox.minimumWorld, max: chosen.getBoundingInfo().boundingBox.maximumWorld } - const center = bbox.min.add(bbox.max).scale(0.5) - const size = bbox.max.subtract(bbox.min) - const maxDimension = Math.max(size.x, size.y, size.z) - const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5) - - // Вычисляем оптимальные углы камеры для видимости датчика - // Позиционируем камеру спереди датчика с небольшим наклоном сверху - const directionToCamera = camera.position.subtract(center).normalize() - - // Вычисляем целевые углы alpha и beta - // alpha - горизонтальный угол (вокруг оси Y) - // beta - вертикальный угол (наклон) - let targetAlpha = Math.atan2(directionToCamera.x, directionToCamera.z) - let targetBeta = Math.acos(directionToCamera.y) - - // Если датчик за стеной, позиционируем камеру спереди - // Используем направление от центра сцены к датчику - const sceneCenter = Vector3.Zero() - const directionFromSceneCenter = center.subtract(sceneCenter).normalize() - targetAlpha = Math.atan2(directionFromSceneCenter.x, directionFromSceneCenter.z) + Math.PI - targetBeta = Math.PI / 3 // 60 градусов - смотрим немного сверху - - // Нормализуем alpha в диапазон [-PI, PI] - while (targetAlpha > Math.PI) targetAlpha -= 2 * Math.PI - while (targetAlpha < -Math.PI) targetAlpha += 2 * Math.PI - - // Ограничиваем beta в разумных пределах - targetBeta = Math.max(0.1, Math.min(Math.PI - 0.1, targetBeta)) - - scene.stopAnimation(camera) - - const ease = new CubicEase() - ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) - const frameRate = 60 - const durationMs = 800 - const totalFrames = Math.round((durationMs / 1000) * frameRate) - - Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease) - Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) - Animation.CreateAndStartAnimation('camAlpha', camera, 'alpha', frameRate, totalFrames, camera.alpha, targetAlpha, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) - Animation.CreateAndStartAnimation('camBeta', camera, 'beta', frameRate, totalFrames, camera.beta, targetBeta, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) - - applyHighlightToMeshes( - highlightLayerRef.current, - highlightedMeshesRef, - [chosen], - mesh => { - const sid = getSensorIdFromMesh(mesh) - const status = sid ? sensorStatusMap?.[sid] : undefined - return statusToColor3(status ?? null) - }, - ) - chosenMeshRef.current = chosen - setOverlayData({ name: chosen.name, sensorId }) - } catch { - for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } - highlightedMeshesRef.current = [] - highlightLayerRef.current?.removeAllMeshes() - chosenMeshRef.current = null - setOverlayPos(null) - setOverlayData(null) - } - } else { - for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } - highlightedMeshesRef.current = [] - highlightLayerRef.current?.removeAllMeshes() - chosenMeshRef.current = null - setOverlayPos(null) - setOverlayData(null) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [focusSensorId, modelReady, highlightAllSensors]) - - useEffect(() => { - const scene = sceneRef.current - if (!scene || !modelReady || !isSensorSelectionEnabled) return - - const pickObserver = scene.onPointerObservable.add((pointerInfo: PointerInfo) => { - if (pointerInfo.type !== PointerEventTypes.POINTERPICK) return - const pick = pointerInfo.pickInfo - if (!pick || !pick.hit) { - onSensorPick?.(null) - return - } - - const pickedMesh = pick.pickedMesh - const sensorId = getSensorIdFromMesh(pickedMesh) - - if (sensorId) { - onSensorPick?.(sensorId) - } else { - onSensorPick?.(null) - } - }) - - return () => { - scene.onPointerObservable.remove(pickObserver) - } - }, [modelReady, isSensorSelectionEnabled, onSensorPick]) - - const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => { - if (!sceneRef.current || !mesh) return null - const scene = sceneRef.current - try { - const bbox = (typeof mesh.getHierarchyBoundingVectors === 'function') - ? mesh.getHierarchyBoundingVectors() - : { min: mesh.getBoundingInfo().boundingBox.minimumWorld, max: mesh.getBoundingInfo().boundingBox.maximumWorld } - const center = bbox.min.add(bbox.max).scale(0.5) - - const viewport = scene.activeCamera?.viewport.toGlobal(engineRef.current!.getRenderWidth(), engineRef.current!.getRenderHeight()) - if (!viewport) return null - - const projected = Vector3.Project(center, Matrix.Identity(), scene.getTransformMatrix(), viewport) - if (!projected) return null - - return { left: projected.x, top: projected.y } - } catch (error) { - console.error('[ModelViewer] Error computing overlay position:', error) - return null - } - }, []) - - useEffect(() => { - if (!chosenMeshRef.current || !overlayData) return - const pos = computeOverlayPosition(chosenMeshRef.current) - setOverlayPos(pos) - }, [overlayData, computeOverlayPosition]) - - useEffect(() => { - if (!sceneRef.current || !chosenMeshRef.current || !overlayData) return - const scene = sceneRef.current - - const updateOverlayPosition = () => { - const pos = computeOverlayPosition(chosenMeshRef.current) - setOverlayPos(pos) - } - scene.registerBeforeRender(updateOverlayPosition) - return () => scene.unregisterBeforeRender(updateOverlayPosition) - }, [overlayData, computeOverlayPosition]) - - return ( -
- {!modelPath ? ( -
-
-
- 3D модель не выбрана -
-
- Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр -
-
- Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API -
-
-
- ) : ( - <> - - {webglError ? ( -
-
-
- 3D просмотр недоступен -
-
- {webglError} -
-
- Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве -
-
-
- ) : isLoading ? ( -
- -
- ) : !modelReady ? ( -
-
-
- 3D модель не загружена -
-
- Модель не готова к отображению -
-
-
- ) : null} - - - )} - {/* UPDATED: Interactive overlay circles with hover effects */} - {allSensorsOverlayCircles.map(circle => { - const size = 36 - const radius = size / 2 - const fill = hexWithAlpha(circle.colorHex, 0.2) - const isHovered = hoveredSensorId === circle.sensorId - - return ( -
handleOverlayCircleClick(circle.sensorId)} - onMouseEnter={() => setHoveredSensorId(circle.sensorId)} - onMouseLeave={() => setHoveredSensorId(null)} - style={{ - position: 'absolute', - left: circle.left - radius, - top: circle.top - radius, - width: size, - height: size, - borderRadius: '9999px', - border: `2px solid ${circle.colorHex}`, - backgroundColor: fill, - pointerEvents: 'auto', - cursor: 'pointer', - transition: 'all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)', - transform: isHovered ? 'scale(1.4)' : 'scale(1)', - boxShadow: isHovered - ? `0 0 25px ${circle.colorHex}, inset 0 0 10px ${circle.colorHex}` - : `0 0 8px ${circle.colorHex}`, - zIndex: isHovered ? 50 : 10, - }} - title={`Датчик: ${circle.sensorId}`} - /> - ) - })} - {renderOverlay && overlayPos && overlayData - ? renderOverlay({ anchor: overlayPos, info: overlayData }) - : null - } -
- ) -} - -export default ModelViewer diff --git a/frontend/components/model/SceneToolbar.tsx b/frontend/components/model/SceneToolbar.tsx index c4809e5..2c7e1ea 100644 --- a/frontend/components/model/SceneToolbar.tsx +++ b/frontend/components/model/SceneToolbar.tsx @@ -85,7 +85,7 @@ const SceneToolbar: React.FC = ({ }, { icon: '/icons/Layers.png', - label: 'Уровни', + label: 'Общий вид', onClick: handleToggleNavMenu, active: navMenuActive, }, diff --git a/frontend/components/model/SceneToolbar.tsx — копия 3 b/frontend/components/model/SceneToolbar.tsx — копия 3 new file mode 100644 index 0000000..2c7e1ea --- /dev/null +++ b/frontend/components/model/SceneToolbar.tsx — копия 3 @@ -0,0 +1,168 @@ +import React, { useState } from 'react'; +import Image from 'next/image'; +import useNavigationStore from '@/app/store/navigationStore'; +import type { Zone } from '@/app/types'; + +interface ToolbarButton { + icon: string; + label: string; + onClick: () => void; + onMouseDown?: () => void; + onMouseUp?: () => void; + active?: boolean; + children?: ToolbarButton[]; +} + +interface SceneToolbarProps { + onZoomIn?: () => void; + onZoomOut?: () => void; + onTopView?: () => void; + onPan?: () => void; + onSelectModel?: (modelPath: string) => void; + panActive?: boolean; + navMenuActive?: boolean; + onToggleSensorHighlights?: () => void; + sensorHighlightsActive?: boolean; +} + +const SceneToolbar: React.FC = ({ + onZoomIn, + onZoomOut, + onTopView, + onPan, + onSelectModel, + panActive = false, + navMenuActive = false, + onToggleSensorHighlights, + sensorHighlightsActive = true, +}) => { + const [isZoomOpen, setIsZoomOpen] = useState(false); + const { showMonitoring, openMonitoring, closeMonitoring, currentZones, loadZones, currentObject } = useNavigationStore(); + + const handleToggleNavMenu = () => { + if (showMonitoring) { + closeMonitoring(); + } else { + openMonitoring(); + } + }; + + const defaultButtons: ToolbarButton[] = [ + { + icon: '/icons/Zoom.png', + label: 'Масштаб', + onClick: () => setIsZoomOpen(!isZoomOpen), + active: isZoomOpen, + children: [ + { + icon: '/icons/plus.svg', + label: 'Приблизить', + onClick: onZoomIn || (() => {}), + }, + { + icon: '/icons/minus.svg', + label: 'Отдалить', + onClick: onZoomOut || (() => {}), + }, + ] + }, + { + icon: '/icons/Video.png', + label: 'Вид сверху', + onClick: onTopView || (() => console.log('Top View')), + }, + { + icon: '/icons/Pointer.png', + label: 'Панорамирование', + onClick: onPan || (() => console.log('Pan')), + active: panActive, + }, + { + icon: '/icons/Eye.png', + label: 'Подсветка датчиков', + onClick: onToggleSensorHighlights || (() => console.log('Toggle Sensor Highlights')), + active: sensorHighlightsActive, + }, + { + icon: '/icons/Layers.png', + label: 'Общий вид', + onClick: handleToggleNavMenu, + active: navMenuActive, + }, + ]; + + + return ( +
+
+
+ {defaultButtons.map((button, index) => ( +
+ + {button.active && button.children && ( +
+ {button.children.map((childButton, childIndex) => ( + + ))} +
+ )} +
+ ))} +
+
+
+ ); +}; + +export default SceneToolbar; \ No newline at end of file diff --git a/frontend/components/navigation/DetectorMenu.tsx b/frontend/components/navigation/DetectorMenu.tsx index 99d3f28..30b39f8 100644 --- a/frontend/components/navigation/DetectorMenu.tsx +++ b/frontend/components/navigation/DetectorMenu.tsx @@ -3,6 +3,7 @@ import React from 'react' import { useRouter } from 'next/navigation' import useNavigationStore from '@/app/store/navigationStore' +import AreaChart from '../dashboard/AreaChart' interface DetectorType { detector_id: number @@ -53,6 +54,14 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose, const formattedTimestamp = latestTimestamp ? latestTimestamp.toLocaleString('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : 'Нет данных' + + // Данные для графика за последние 3 дня (мок данные) + const chartData: { timestamp: string; value: number }[] = [ + { timestamp: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), value: 75 }, + { timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), value: 82 }, + { timestamp: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), value: 78 }, + { timestamp: new Date().toISOString(), value: 85 }, + ] // Определение типа детектора и его отображаемого названия const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase() @@ -216,8 +225,16 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose, // Компактный режим с якорной позицией (всплывающее окно) // Используется для отображения информации при наведении на детектор в списке if (compact && anchor) { + // Проверяем границы экрана и корректируем позицию + const tooltipHeight = 450 // Примерная высота толтипа с графиком + const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800 + const bottomOverflow = anchor.top + tooltipHeight - viewportHeight + + // Если толтип выходит за нижнюю границу, сдвигаем вверх + const adjustedTop = bottomOverflow > 0 ? anchor.top - bottomOverflow - 20 : anchor.top + return ( -
+
@@ -244,6 +261,14 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose,
+ + {/* График за последние 3 дня */} +
+
График за 3 дня
+
+ +
+
) @@ -279,6 +304,14 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose, {/* Секция с детальной информацией о детекторе */} + {/* График за последние 3 дня */} +
+

График за последние 3 дня

+
+ +
+
+ {/* Кнопка закрытия панели */} +
+
+ + +
+ +
+
+ ) + } + + // Полный режим боковой панели (основной режим) + // Отображается как правая панель с полной информацией о детекторе + return ( +
+
+
+ {/* Заголовок с названием детектора */} +

+ {detector.name} +

+ {/* Кнопки действий: Отчет и История */} +
+ + +
+
+ + {/* Секция с детальной информацией о детекторе */} + + + {/* Кнопка закрытия панели */} + +
+
+ ) +} + +export default DetectorMenu \ No newline at end of file diff --git a/frontend/components/objects/ObjectCard.tsx b/frontend/components/objects/ObjectCard.tsx index cd28085..8c2026e 100644 --- a/frontend/components/objects/ObjectCard.tsx +++ b/frontend/components/objects/ObjectCard.tsx @@ -82,7 +82,7 @@ const ObjectCard: React.FC = ({ object, onSelect, isSelected = return (
= ({ object, onSelect, isSelected = {/* Изображение объекта */} -
+
{object.title} void + isSelected?: boolean +} + +// Иконка редактирования +const EditIcon = ({ className }: { className?: string }) => ( + + + +) + +const ObjectCard: React.FC = ({ object, onSelect, isSelected = false }) => { + const navigationService = useNavigationService() + + const handleCardClick = () => { + if (onSelect) { + onSelect(object.object_id) + } + // Навигация к дашборду с выбранным объектом + navigationService.selectObjectAndGoToDashboard(object.object_id, object.title) + } + + const handleEditClick = (e: React.MouseEvent) => { + e.stopPropagation() + console.log('Edit object:', object.object_id) + // Логика редактирования объекта + } + + // Возврат к тестовому изображению, если src отсутствует/некорректен; нормализация относительных путей + const resolveImageSrc = (src?: string | null): string => { + if (!src || typeof src !== 'string') return '/images/test_image.png' + let s = src.trim() + if (!s) return '/images/test_image.png' + + // Нормализуем обратные слеши в стиле Windows + s = s.replace(/\\/g, '/') + const lower = s.toLowerCase() + + // Обрабатываем явный плейсхолдер test_image.png только как заглушку + if (lower === 'test_image.png' || lower.endsWith('/test_image.png') || lower.includes('/public/images/test_image.png')) { + return '/images/test_image.png' + } + + // Абсолютные URL + if (s.startsWith('http://') || s.startsWith('https://')) return s + + // Пути, относительные к сайту + if (s.startsWith('/')) { + // Преобразуем /public/images/... в /images/... + if (/\/public\/images\//i.test(s)) { + return s.replace(/\/public\/images\//i, '/images/') + } + return s + } + + // Нормализуем относительные имена ресурсов до путей сайта под /images + // Убираем ведущий 'public/', если он присутствует + s = s.replace(/^public\//i, '') + return s.startsWith('images/') ? `/${s}` : `/images/${s}` + } + + const imgSrc = resolveImageSrc(object.image) + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + handleCardClick() + } + }} + > +
+
+

+ {object.title} +

+

+ {object.description} +

+
+ +
+ + {/* Изображение объекта */} +
+ {object.title} { + // Заглушка при ошибке загрузки изображения + const target = e.target as HTMLImageElement + target.src = '/images/test_image.png' + }} + /> +
+
+ ) +} + +export default ObjectCard +export type { ObjectData, ObjectCardProps } \ No newline at end of file diff --git a/frontend/components/objects/ObjectGallery.tsx b/frontend/components/objects/ObjectGallery.tsx index 3cfbc2d..3697d7f 100644 --- a/frontend/components/objects/ObjectGallery.tsx +++ b/frontend/components/objects/ObjectGallery.tsx @@ -10,12 +10,6 @@ interface ObjectGalleryProps { selectedObjectId?: string | null className?: string } - -const BackIcon = ({ className }: { className?: string }) => ( - - - -) const ObjectGallery: React.FC = ({ objects, @@ -31,72 +25,36 @@ const ObjectGallery: React.FC = ({ } } - const handleBackClick = () => { - console.log('Back clicked') - } - return ( -
-
-
-
- - -
-

- {title} -

+
+ {/* Галерея объектов */} + {objects.length > 0 ? ( +
+ {objects.map((object) => ( + + ))} +
+ ) : ( +
+
+
+ + +
- - -
- - {/* Галерея объектов */} - {objects.length > 0 ? ( -
- {objects.map((object) => ( - - ))} -
- ) : ( -
-
-
- - - -
-

Объекты не найдены

-

Нет доступных объектов

-
-
- )} +

Объекты не найдены

+

Нет доступных объектов

+
- + )}
) } export default ObjectGallery -export type { ObjectGalleryProps } \ No newline at end of file +export type { ObjectGalleryProps } diff --git a/frontend/components/objects/ObjectGallery.tsx — копия b/frontend/components/objects/ObjectGallery.tsx — копия new file mode 100644 index 0000000..3cfbc2d --- /dev/null +++ b/frontend/components/objects/ObjectGallery.tsx — копия @@ -0,0 +1,102 @@ +'use client' + +import React from 'react' +import ObjectCard, { ObjectData } from './ObjectCard' + +interface ObjectGalleryProps { + objects: ObjectData[] + title?: string + onObjectSelect?: (objectId: string) => void + selectedObjectId?: string | null + className?: string +} + +const BackIcon = ({ className }: { className?: string }) => ( + + + +) + +const ObjectGallery: React.FC = ({ + objects, + title = 'Объекты', + onObjectSelect, + selectedObjectId, + className = '' +}) => { + + const handleObjectSelect = (objectId: string) => { + if (onObjectSelect) { + onObjectSelect(objectId) + } + } + + const handleBackClick = () => { + console.log('Back clicked') + } + + return ( +
+
+
+
+ + +
+

+ {title} +

+
+ + +
+ + {/* Галерея объектов */} + {objects.length > 0 ? ( +
+ {objects.map((object) => ( + + ))} +
+ ) : ( +
+
+
+ + + +
+

Объекты не найдены

+

Нет доступных объектов

+
+
+ )} +
+
+
+ ) +} + +export default ObjectGallery +export type { ObjectGalleryProps } \ No newline at end of file diff --git a/frontend/components/ui/Sidebar.tsx b/frontend/components/ui/Sidebar.tsx index 84ff855..889e69a 100644 --- a/frontend/components/ui/Sidebar.tsx +++ b/frontend/components/ui/Sidebar.tsx @@ -74,6 +74,10 @@ const Monitor = ({ className }: { className?: string }) => ( const Building = ({ className }: { className?: string }) => ( ) + +const Building3D = ({ className }: { className?: string }) => ( + +) // основные routes const mainNavigationItems: NavigationItem[] = [ @@ -82,6 +86,11 @@ const mainNavigationItems: NavigationItem[] = [ icon: BookOpen, label: 'Дашборд' }, + { + id: 10, + icon: Building3D, + label: 'Объекты' + }, { id: 2, icon: Bot, diff --git a/frontend/components/ui/Sidebar.tsx — копия 3 b/frontend/components/ui/Sidebar.tsx — копия 3 new file mode 100644 index 0000000..84ff855 --- /dev/null +++ b/frontend/components/ui/Sidebar.tsx — копия 3 @@ -0,0 +1,504 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import { useRouter, usePathname } from 'next/navigation' +import Image from 'next/image' +import useUIStore from '../../app/store/uiStore' +import useNavigationStore from '../../app/store/navigationStore' +import { useNavigationService } from '@/services/navigationService' +import useUserStore from '../../app/store/userStore' +import { signOut } from 'next-auth/react' + +interface NavigationItem { + id: number + label: string + icon: React.ComponentType<{ className?: string }> +} + +interface SidebarProps { + navigationItems?: NavigationItem[] + logoSrc?: string + userInfo?: { + name: string + role: string + avatar?: string + } + activeItem?: number | null + onCustomItemClick?: (itemId: number) => boolean +} + +const IconWrapper = ({ src, alt, className }: { src: string; alt: string; className?: string }) => ( +
+ {alt} +
+) + +const BookOpen = ({ className }: { className?: string }) => ( + +) + +const Bot = ({ className }: { className?: string }) => ( + +) + +const SquareTerminal = ({ className }: { className?: string }) => ( + +) + +const CircleDot = ({ className }: { className?: string }) => ( + +) + +const BellDot = ({ className }: { className?: string }) => ( + +) + +const History = ({ className }: { className?: string }) => ( + +) + +const Settings2 = ({ className }: { className?: string }) => ( + +) + +const Monitor = ({ className }: { className?: string }) => ( + +) + +const Building = ({ className }: { className?: string }) => ( + +) + +// основные routes +const mainNavigationItems: NavigationItem[] = [ + { + id: 1, + icon: BookOpen, + label: 'Дашборд' + }, + { + id: 2, + icon: Bot, + label: 'Навигация по зданию' + }, + { + id: 8, + icon: History, + label: 'История тревог' + }, + { + id: 9, + icon: Settings2, + label: 'Отчеты' + } +] + +// суб-меню под "Навигация по зданию" +const navigationSubItems: NavigationItem[] = [ + { + id: 3, + icon: Monitor, + label: 'Зоны Мониторинга' + }, + { + id: 4, + icon: Building, + label: 'Навигация по этажам' + }, + { + id: 5, + icon: BellDot, + label: 'Уведомления' + }, + { + id: 6, + icon: CircleDot, + label: 'Сенсоры' + }, + { + id: 7, + icon: SquareTerminal, + label: 'Список датчиков' + } +] + + +const Sidebar: React.FC = ({ + logoSrc, + userInfo = { + name: '—', + role: '—' + }, + activeItem: propActiveItem, + onCustomItemClick +}) => { + const navigationService = useNavigationService() + const router = useRouter() + const pathname = usePathname() + const [internalActiveItem, setInternalActiveItem] = useState(null) + const [isHydrated, setIsHydrated] = useState(false) + const [manuallyToggled, setManuallyToggled] = useState(false) + const activeItem = propActiveItem !== undefined ? propActiveItem : internalActiveItem + const { + isSidebarCollapsed: isCollapsed, + toggleSidebar, + isNavigationSubMenuExpanded: showNavigationSubItems, + setNavigationSubMenuExpanded: setShowNavigationSubItems, + toggleNavigationSubMenu + } = useUIStore() + const { user, logout } = useUserStore() + + const roleLabelMap: Record = { + engineer: 'Инженер', + operator: 'Оператор', + admin: 'Администратор', + } + const fullName = [user?.name, user?.surname].filter(Boolean).join(' ').trim() + + const uiUserInfo = { + name: fullName || user?.login || userInfo?.name || '—', + role: roleLabelMap[(user?.account_type ?? '').toLowerCase()] || userInfo?.role || '—', + avatar: user?.image || userInfo?.avatar, + } + + const handleLogout = async () => { + try { + await fetch('/api/auth/logout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }) + } catch (e) { + console.error('Logout request failed:', e) + } finally { + logout() + await signOut({ redirect: true, callbackUrl: '/login' }) + } + } + const { + openMonitoring, + openFloorNavigation, + openNotifications, + openSensors, + openListOfDetectors, + closeSensors, + closeListOfDetectors, + closeMonitoring, + closeFloorNavigation, + closeNotifications, + showMonitoring, + showFloorNavigation, + showNotifications, + showSensors, + showListOfDetectors + } = useNavigationStore() + + useEffect(() => { + setIsHydrated(true) + }, []) + + // Чек если суб-меню активны + const isNavigationSubItemActive = activeItem && [3, 4, 5, 6, 7].includes(activeItem) + const shouldShowNavigationAsActive = activeItem === 2 || isNavigationSubItemActive + + // Авто-расткрытие меню, если суб-меню стало активным (только если не было ручного переключения) + useEffect(() => { + if (isNavigationSubItemActive && !showNavigationSubItems && !manuallyToggled) { + setShowNavigationSubItems(true) + } + }, [isNavigationSubItemActive, showNavigationSubItems, manuallyToggled, setShowNavigationSubItems]) + + const handleItemClick = (itemId: number) => { + let handled = false + + // Управление суб-меню через navigationStore (суб-меню - работают как отдельные элементы, но не страницы) + switch (itemId) { + case 2: + if (pathname !== '/navigation') { + router.push('/navigation') + } + handled = true + break + case 3: // Monitoring + if (pathname !== '/navigation') { + router.push('/navigation') + setTimeout(() => openMonitoring(), 100) + } else if (showMonitoring) { + closeMonitoring() + } else { + openMonitoring() + } + handled = true + break + case 4: // Floor Navigation + if (pathname !== '/navigation') { + router.push('/navigation') + setTimeout(() => openFloorNavigation(), 100) + } else if (showFloorNavigation) { + closeFloorNavigation() + } else { + openFloorNavigation() + } + handled = true + break + case 5: // Notifications + if (pathname !== '/navigation') { + router.push('/navigation') + setTimeout(() => openNotifications(), 100) + } else if (showNotifications) { + closeNotifications() + } else { + openNotifications() + } + handled = true + break + case 6: // Sensors + if (pathname !== '/navigation') { + router.push('/navigation') + setTimeout(() => openSensors(), 100) + } else if (showSensors) { + closeSensors() + } else { + openSensors() + } + handled = true + break + case 7: // Detector List + if (pathname !== '/navigation') { + router.push('/navigation') + setTimeout(() => openListOfDetectors(), 100) + } else if (showListOfDetectors) { + closeListOfDetectors() + } else { + openListOfDetectors() + } + handled = true + break + default: + // Для остального используем routes + if (navigationService) { + handled = navigationService.handleSidebarItemClick(itemId, pathname) + } + break + } + + if (handled) { + if (propActiveItem === undefined) { + setInternalActiveItem(itemId) + } + + if (onCustomItemClick) { + onCustomItemClick(itemId) + } + } + } + + return ( + + ) +} + +export default Sidebar \ No newline at end of file diff --git a/frontend/services/navigationService.ts b/frontend/services/navigationService.ts index 90618d4..25fbeb6 100644 --- a/frontend/services/navigationService.ts +++ b/frontend/services/navigationService.ts @@ -15,7 +15,8 @@ export const SIDEBAR_ITEM_MAP = { 1: { type: 'route', value: MainRoutes.DASHBOARD }, 2: { type: 'route', value: MainRoutes.NAVIGATION }, 8: { type: 'route', value: MainRoutes.ALERTS }, - 9: { type: 'route', value: MainRoutes.REPORTS } + 9: { type: 'route', value: MainRoutes.REPORTS }, + 10: { type: 'route', value: MainRoutes.OBJECTS } } as const export class NavigationService { diff --git a/frontend/services/navigationService — копия.ts b/frontend/services/navigationService — копия.ts new file mode 100644 index 0000000..90618d4 --- /dev/null +++ b/frontend/services/navigationService — копия.ts @@ -0,0 +1,94 @@ +import React from 'react' +import { useRouter } from 'next/navigation' +import useNavigationStore from '@/app/store/navigationStore' +import type { NavigationStore } from '@/app/store/navigationStore' + +export enum MainRoutes { + DASHBOARD = '/dashboard', + NAVIGATION = '/navigation', + ALERTS = '/alerts', + REPORTS = '/reports', + OBJECTS = '/objects' +} + +export const SIDEBAR_ITEM_MAP = { + 1: { type: 'route', value: MainRoutes.DASHBOARD }, + 2: { type: 'route', value: MainRoutes.NAVIGATION }, + 8: { type: 'route', value: MainRoutes.ALERTS }, + 9: { type: 'route', value: MainRoutes.REPORTS } +} as const + +export class NavigationService { + private router!: ReturnType + private navigationStore!: NavigationStore + private initialized = false + + init(router: ReturnType, navigationStore: NavigationStore) { + if (this.initialized) { + return // Предотвращаем повторную инициализацию + } + this.router = router + this.navigationStore = navigationStore + this.initialized = true + } + + isInitialized(): boolean { + return this.initialized + } + + navigateToRoute(route: MainRoutes) { + // Убираем подменю перед переходом на другую страницу + if (route !== MainRoutes.NAVIGATION) { + this.navigationStore.setCurrentSubmenu(null) + } + + this.router.push(route) + } + + handleSidebarItemClick(itemId: number, currentPath: string): boolean { + if (!this.initialized) { + console.error('NavigationService not initialized!') + return false + } + + const mapping = SIDEBAR_ITEM_MAP[itemId as keyof typeof SIDEBAR_ITEM_MAP] + + if (!mapping) { + return false + } + + if (mapping.type === 'route') { + this.navigateToRoute(mapping.value as MainRoutes) + return true + } + + return false + } + + goBack() { + this.navigationStore.goBack() + } + + selectObjectAndGoToDashboard(objectId: string, objectTitle: string) { + this.navigationStore.setCurrentObject(objectId, objectTitle) + // Проверяем, что подменю закрыто перед навигацией + this.navigationStore.setCurrentSubmenu(null) + const url = `${MainRoutes.DASHBOARD}?objectId=${encodeURIComponent(objectId)}&objectTitle=${encodeURIComponent(objectTitle)}` + this.router.push(url) + } +} + +export const navigationService = new NavigationService() + +export function useNavigationService() { + const router = useRouter() + const navigationStore = useNavigationStore() + + React.useMemo(() => { + if (!navigationService.isInitialized()) { + navigationService.init(router, navigationStore) + } + }, [router, navigationStore]) + + return navigationService +} \ No newline at end of file