обновление бизнес логики
This commit is contained in:
@@ -173,7 +173,7 @@ const NavigationPage: React.FC = () => {
|
|||||||
const urlFocusSensorId = searchParams.get('focusSensorId')
|
const urlFocusSensorId = searchParams.get('focusSensorId')
|
||||||
const objectId = currentObject.id || urlObjectId
|
const objectId = currentObject.id || urlObjectId
|
||||||
const objectTitle = currentObject.title || urlObjectTitle
|
const objectTitle = currentObject.title || urlObjectTitle
|
||||||
const [selectedModelPath, setSelectedModelPath] = useState<string>(urlModelPath || '')
|
const [selectedModelPath, setSelectedModelPath] = useState<string>('')
|
||||||
|
|
||||||
|
|
||||||
const handleModelLoaded = useCallback(() => {
|
const handleModelLoaded = useCallback(() => {
|
||||||
@@ -191,12 +191,8 @@ const NavigationPage: React.FC = () => {
|
|||||||
if (selectedModelPath) {
|
if (selectedModelPath) {
|
||||||
setIsModelReady(false);
|
setIsModelReady(false);
|
||||||
setModelError(null);
|
setModelError(null);
|
||||||
// Сохраняем выбранную модель в URL для восстановления при возврате
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
|
||||||
params.set('modelPath', selectedModelPath);
|
|
||||||
window.history.replaceState(null, '', `?${params.toString()}`);
|
|
||||||
}
|
}
|
||||||
}, [selectedModelPath, searchParams]);
|
}, [selectedModelPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
||||||
@@ -204,12 +200,45 @@ const NavigationPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject])
|
}, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject])
|
||||||
|
|
||||||
// Восстановление выбранной модели из URL при загрузке страницы
|
// Восстановление выбранной модели из URL при загрузке страницы (только если переход с focusSensorId)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (urlModelPath && !selectedModelPath) {
|
// Восстанавливаем modelPath только если есть focusSensorId (переход к конкретному датчику)
|
||||||
|
if (urlModelPath && urlFocusSensorId && !selectedModelPath) {
|
||||||
setSelectedModelPath(urlModelPath);
|
setSelectedModelPath(urlModelPath);
|
||||||
}
|
}
|
||||||
}, [urlModelPath, selectedModelPath])
|
}, [urlModelPath, urlFocusSensorId, selectedModelPath])
|
||||||
|
|
||||||
|
// Автоматическая загрузка модели с order=0 при пустом selectedModelPath
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDefaultModel = async () => {
|
||||||
|
// Если модель уже выбрана или нет objectId - пропускаем
|
||||||
|
if (selectedModelPath || !objectId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[NavigationPage] Auto-loading model with order=0 for object:', objectId);
|
||||||
|
const response = await fetch(`/api/get-zones?object_id=${objectId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && Array.isArray(data.data)) {
|
||||||
|
// Сортируем по order и берём первую
|
||||||
|
const sorted = data.data.slice().sort((a: any, b: any) => {
|
||||||
|
const oa = typeof a.order === 'number' ? a.order : 0;
|
||||||
|
const ob = typeof b.order === 'number' ? b.order : 0;
|
||||||
|
return oa - ob;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sorted.length > 0 && sorted[0].model_path) {
|
||||||
|
console.log('[NavigationPage] Auto-selected model with order=0:', sorted[0].model_path);
|
||||||
|
setSelectedModelPath(sorted[0].model_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[NavigationPage] Failed to load default model:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDefaultModel();
|
||||||
|
}, [selectedModelPath, objectId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDetectors = async () => {
|
const loadDetectors = async () => {
|
||||||
|
|||||||
@@ -1,636 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useEffect, useCallback, useState } from 'react'
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import Sidebar from '../../../components/ui/Sidebar'
|
|
||||||
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
|
|
||||||
import useNavigationStore from '../../store/navigationStore'
|
|
||||||
import Monitoring from '../../../components/navigation/Monitoring'
|
|
||||||
import FloorNavigation from '../../../components/navigation/FloorNavigation'
|
|
||||||
import DetectorMenu from '../../../components/navigation/DetectorMenu'
|
|
||||||
import ListOfDetectors from '../../../components/navigation/ListOfDetectors'
|
|
||||||
import Sensors from '../../../components/navigation/Sensors'
|
|
||||||
import AlertMenu from '../../../components/navigation/AlertMenu'
|
|
||||||
import Notifications from '../../../components/notifications/Notifications'
|
|
||||||
import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo'
|
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
import type { ModelViewerProps } from '../../../components/model/ModelViewer'
|
|
||||||
import * as statusColors from '../../../lib/statusColors'
|
|
||||||
|
|
||||||
const ModelViewer = dynamic<ModelViewerProps>(() => import('../../../components/model/ModelViewer'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="w-full h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-gray-300 animate-pulse">Загрузка 3D-модуля…</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
interface DetectorType {
|
|
||||||
detector_id: number
|
|
||||||
name: string
|
|
||||||
serial_number: string
|
|
||||||
object: string
|
|
||||||
status: string
|
|
||||||
checked: boolean
|
|
||||||
type: string
|
|
||||||
detector_type: string
|
|
||||||
location: string
|
|
||||||
floor: number
|
|
||||||
notifications: Array<{
|
|
||||||
id: number
|
|
||||||
type: string
|
|
||||||
message: string
|
|
||||||
timestamp: string
|
|
||||||
acknowledged: boolean
|
|
||||||
priority: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NotificationType {
|
|
||||||
id: number
|
|
||||||
detector_id: number
|
|
||||||
detector_name: string
|
|
||||||
type: string
|
|
||||||
status: string
|
|
||||||
message: string
|
|
||||||
timestamp: string
|
|
||||||
location: string
|
|
||||||
object: string
|
|
||||||
acknowledged: boolean
|
|
||||||
priority: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AlertType {
|
|
||||||
id: number
|
|
||||||
detector_id: number
|
|
||||||
detector_name: string
|
|
||||||
type: string
|
|
||||||
status: string
|
|
||||||
message: string
|
|
||||||
timestamp: string
|
|
||||||
location: string
|
|
||||||
object: string
|
|
||||||
acknowledged: boolean
|
|
||||||
priority: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const NavigationPage: React.FC = () => {
|
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const {
|
|
||||||
currentObject,
|
|
||||||
setCurrentObject,
|
|
||||||
showMonitoring,
|
|
||||||
showFloorNavigation,
|
|
||||||
showNotifications,
|
|
||||||
showListOfDetectors,
|
|
||||||
showSensors,
|
|
||||||
selectedDetector,
|
|
||||||
showDetectorMenu,
|
|
||||||
selectedNotification,
|
|
||||||
showNotificationDetectorInfo,
|
|
||||||
selectedAlert,
|
|
||||||
showAlertMenu,
|
|
||||||
closeMonitoring,
|
|
||||||
closeFloorNavigation,
|
|
||||||
closeNotifications,
|
|
||||||
closeListOfDetectors,
|
|
||||||
closeSensors,
|
|
||||||
setSelectedDetector,
|
|
||||||
setShowDetectorMenu,
|
|
||||||
setSelectedNotification,
|
|
||||||
setShowNotificationDetectorInfo,
|
|
||||||
setSelectedAlert,
|
|
||||||
setShowAlertMenu,
|
|
||||||
showSensorHighlights,
|
|
||||||
toggleSensorHighlights
|
|
||||||
} = useNavigationStore()
|
|
||||||
|
|
||||||
const [detectorsData, setDetectorsData] = useState<{ detectors: Record<string, DetectorType> }>({ detectors: {} })
|
|
||||||
const [detectorsError, setDetectorsError] = useState<string | null>(null)
|
|
||||||
const [modelError, setModelError] = useState<string | null>(null)
|
|
||||||
const [isModelReady, setIsModelReady] = useState(false)
|
|
||||||
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
|
||||||
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
|
||||||
const sensorStatusMap = React.useMemo(() => {
|
|
||||||
const map: Record<string, string> = {}
|
|
||||||
Object.values(detectorsData.detectors).forEach(d => {
|
|
||||||
if (d.serial_number && d.status) {
|
|
||||||
map[String(d.serial_number).trim()] = d.status
|
|
||||||
}
|
|
||||||
})
|
|
||||||
console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors')
|
|
||||||
console.log('[NavigationPage] Sample sensor IDs in map:', Object.keys(map).slice(0, 5))
|
|
||||||
return map
|
|
||||||
}, [detectorsData])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedDetector === null && selectedAlert === null) {
|
|
||||||
setFocusedSensorId(null);
|
|
||||||
}
|
|
||||||
}, [selectedDetector, selectedAlert]);
|
|
||||||
|
|
||||||
// Управление выделением всех сенсоров при открытии/закрытии меню Sensors
|
|
||||||
// ИСПРАВЛЕНО: Подсветка датчиков остается включенной всегда, независимо от состояния панели Sensors
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady)
|
|
||||||
if (isModelReady) {
|
|
||||||
// Всегда включаем подсветку всех сенсоров когда модель готова
|
|
||||||
console.log('[NavigationPage] Setting highlightAllSensors to TRUE (always enabled)')
|
|
||||||
setHighlightAllSensors(true)
|
|
||||||
// Сбрасываем фокус только если панель Sensors закрыта
|
|
||||||
if (!showSensors) {
|
|
||||||
setFocusedSensorId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [showSensors, isModelReady])
|
|
||||||
|
|
||||||
// Дополнительный эффект для задержки выделения сенсоров при открытии меню
|
|
||||||
// ИСПРАВЛЕНО: Задержка применяется только при открытии панели Sensors
|
|
||||||
useEffect(() => {
|
|
||||||
if (showSensors && isModelReady) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
console.log('[NavigationPage] Delayed highlightAllSensors to TRUE')
|
|
||||||
setHighlightAllSensors(true)
|
|
||||||
}, 500) // Задержка 500мс для полной инициализации модели
|
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [showSensors, isModelReady])
|
|
||||||
|
|
||||||
const urlObjectId = searchParams.get('objectId')
|
|
||||||
const urlObjectTitle = searchParams.get('objectTitle')
|
|
||||||
const urlModelPath = searchParams.get('modelPath')
|
|
||||||
const urlFocusSensorId = searchParams.get('focusSensorId')
|
|
||||||
const objectId = currentObject.id || urlObjectId
|
|
||||||
const objectTitle = currentObject.title || urlObjectTitle
|
|
||||||
const [selectedModelPath, setSelectedModelPath] = useState<string>(urlModelPath || '')
|
|
||||||
|
|
||||||
|
|
||||||
const handleModelLoaded = useCallback(() => {
|
|
||||||
setIsModelReady(true)
|
|
||||||
setModelError(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleModelError = useCallback((error: string) => {
|
|
||||||
console.error('[NavigationPage] Model loading error:', error)
|
|
||||||
setModelError(error)
|
|
||||||
setIsModelReady(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedModelPath) {
|
|
||||||
setIsModelReady(false);
|
|
||||||
setModelError(null);
|
|
||||||
// Сохраняем выбранную модель в URL для восстановления при возврате
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
|
||||||
params.set('modelPath', selectedModelPath);
|
|
||||||
window.history.replaceState(null, '', `?${params.toString()}`);
|
|
||||||
}
|
|
||||||
}, [selectedModelPath, searchParams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
|
||||||
setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined)
|
|
||||||
}
|
|
||||||
}, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject])
|
|
||||||
|
|
||||||
// Восстановление выбранной модели из URL при загрузке страницы
|
|
||||||
useEffect(() => {
|
|
||||||
if (urlModelPath && !selectedModelPath) {
|
|
||||||
setSelectedModelPath(urlModelPath);
|
|
||||||
}
|
|
||||||
}, [urlModelPath, selectedModelPath])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadDetectors = async () => {
|
|
||||||
try {
|
|
||||||
setDetectorsError(null)
|
|
||||||
const res = await fetch('/api/get-detectors', { cache: 'no-store' })
|
|
||||||
const text = await res.text()
|
|
||||||
let payload: any
|
|
||||||
try { payload = JSON.parse(text) } catch { payload = text }
|
|
||||||
console.log('[NavigationPage] GET /api/get-detectors', { status: res.status, payload })
|
|
||||||
if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить детекторов'))
|
|
||||||
const data = payload?.data ?? payload
|
|
||||||
const detectors = (data?.detectors ?? {}) as Record<string, DetectorType>
|
|
||||||
console.log('[NavigationPage] Received detectors count:', Object.keys(detectors).length)
|
|
||||||
console.log('[NavigationPage] Sample detector keys:', Object.keys(detectors).slice(0, 5))
|
|
||||||
setDetectorsData({ detectors })
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('Ошибка загрузки детекторов:', e)
|
|
||||||
setDetectorsError(e?.message || 'Ошибка при загрузке детекторов')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadDetectors()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleBackClick = () => {
|
|
||||||
router.push('/dashboard')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDetectorMenuClick = (detector: DetectorType) => {
|
|
||||||
// Для тестов. Выбор детектора.
|
|
||||||
console.log('[NavigationPage] Selected detector click:', {
|
|
||||||
detector_id: detector.detector_id,
|
|
||||||
name: detector.name,
|
|
||||||
serial_number: detector.serial_number,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Проверяем, что детектор имеет необходимые данные
|
|
||||||
if (!detector || !detector.detector_id || !detector.serial_number) {
|
|
||||||
console.warn('[NavigationPage] Invalid detector data, skipping menu display:', detector)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedDetector?.serial_number === detector.serial_number && showDetectorMenu) {
|
|
||||||
closeDetectorMenu()
|
|
||||||
} else {
|
|
||||||
setSelectedDetector(detector)
|
|
||||||
setShowDetectorMenu(true)
|
|
||||||
setFocusedSensorId(detector.serial_number)
|
|
||||||
setShowAlertMenu(false)
|
|
||||||
setSelectedAlert(null)
|
|
||||||
// При открытии меню детектора - сбрасываем множественное выделение
|
|
||||||
setHighlightAllSensors(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeDetectorMenu = () => {
|
|
||||||
setShowDetectorMenu(false)
|
|
||||||
setSelectedDetector(null)
|
|
||||||
setFocusedSensorId(null)
|
|
||||||
setSelectedAlert(null)
|
|
||||||
// При закрытии меню детектора - выделяем все сенсоры снова
|
|
||||||
setHighlightAllSensors(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNotificationClick = (notification: NotificationType) => {
|
|
||||||
if (selectedNotification?.id === notification.id && showNotificationDetectorInfo) {
|
|
||||||
setShowNotificationDetectorInfo(false)
|
|
||||||
setSelectedNotification(null)
|
|
||||||
} else {
|
|
||||||
setSelectedNotification(notification)
|
|
||||||
setShowNotificationDetectorInfo(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeNotificationDetectorInfo = () => {
|
|
||||||
setShowNotificationDetectorInfo(false)
|
|
||||||
setSelectedNotification(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeAlertMenu = () => {
|
|
||||||
setShowAlertMenu(false)
|
|
||||||
setSelectedAlert(null)
|
|
||||||
setFocusedSensorId(null)
|
|
||||||
setSelectedDetector(null)
|
|
||||||
// При закрытии меню алерта - выделяем все сенсоры снова
|
|
||||||
setHighlightAllSensors(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAlertClick = (alert: AlertType) => {
|
|
||||||
console.log('[NavigationPage] Alert clicked, focusing on detector in 3D scene:', alert)
|
|
||||||
|
|
||||||
const detector = Object.values(detectorsData.detectors).find(
|
|
||||||
d => d.detector_id === alert.detector_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if (detector) {
|
|
||||||
if (selectedAlert?.id === alert.id && showAlertMenu) {
|
|
||||||
closeAlertMenu()
|
|
||||||
} else {
|
|
||||||
setSelectedAlert(alert)
|
|
||||||
setShowAlertMenu(true)
|
|
||||||
setFocusedSensorId(detector.serial_number)
|
|
||||||
setShowDetectorMenu(false)
|
|
||||||
setSelectedDetector(null)
|
|
||||||
// При открытии меню алерта - сбрасываем множественное выделение
|
|
||||||
setHighlightAllSensors(false)
|
|
||||||
console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSensorSelection = (serialNumber: string | null) => {
|
|
||||||
if (serialNumber === null) {
|
|
||||||
setFocusedSensorId(null);
|
|
||||||
closeDetectorMenu();
|
|
||||||
closeAlertMenu();
|
|
||||||
// If we're in Sensors menu and no sensor is selected, highlight all sensors
|
|
||||||
if (showSensors) {
|
|
||||||
setHighlightAllSensors(true);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (focusedSensorId === serialNumber) {
|
|
||||||
setFocusedSensorId(null);
|
|
||||||
closeDetectorMenu();
|
|
||||||
closeAlertMenu();
|
|
||||||
// If we're in Sensors menu and deselected the current sensor, highlight all sensors
|
|
||||||
if (showSensors) {
|
|
||||||
setHighlightAllSensors(true);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// При выборе конкретного сенсора - сбрасываем множественное выделение
|
|
||||||
setHighlightAllSensors(false)
|
|
||||||
|
|
||||||
const detector = Object.values(detectorsData?.detectors || {}).find(
|
|
||||||
(d) => d.serial_number === serialNumber
|
|
||||||
);
|
|
||||||
|
|
||||||
if (detector) {
|
|
||||||
if (showFloorNavigation || showListOfDetectors) {
|
|
||||||
handleDetectorMenuClick(detector);
|
|
||||||
} else if (detector.notifications && detector.notifications.length > 0) {
|
|
||||||
const sortedNotifications = [...detector.notifications].sort((a, b) => {
|
|
||||||
const priorityOrder: { [key: string]: number } = { critical: 0, warning: 1, info: 2 };
|
|
||||||
return priorityOrder[a.priority.toLowerCase()] - priorityOrder[b.priority.toLowerCase()];
|
|
||||||
});
|
|
||||||
const notification = sortedNotifications[0];
|
|
||||||
const alert: AlertType = {
|
|
||||||
...notification,
|
|
||||||
detector_id: detector.detector_id,
|
|
||||||
detector_name: detector.name,
|
|
||||||
location: detector.location,
|
|
||||||
object: detector.object,
|
|
||||||
status: detector.status,
|
|
||||||
type: notification.type || 'info',
|
|
||||||
};
|
|
||||||
handleAlertClick(alert);
|
|
||||||
} else {
|
|
||||||
handleDetectorMenuClick(detector);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setFocusedSensorId(null);
|
|
||||||
closeDetectorMenu();
|
|
||||||
closeAlertMenu();
|
|
||||||
// If we're in Sensors menu and no valid detector found, highlight all sensors
|
|
||||||
if (showSensors) {
|
|
||||||
setHighlightAllSensors(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Обработка 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 (
|
|
||||||
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
|
||||||
<AnimatedBackground />
|
|
||||||
<div className="relative z-20">
|
|
||||||
<Sidebar
|
|
||||||
activeItem={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex-1 flex flex-col">
|
|
||||||
|
|
||||||
{showMonitoring && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Monitoring
|
|
||||||
onClose={closeMonitoring}
|
|
||||||
onSelectModel={(path) => {
|
|
||||||
console.log('[NavigationPage] Model selected:', path);
|
|
||||||
setSelectedModelPath(path)
|
|
||||||
setModelError(null)
|
|
||||||
setIsModelReady(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showFloorNavigation && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<FloorNavigation
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onDetectorMenuClick={handleDetectorMenuClick}
|
|
||||||
onClose={closeFloorNavigation}
|
|
||||||
is3DReady={isModelReady && !modelError}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNotifications && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Notifications
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onNotificationClick={handleNotificationClick}
|
|
||||||
onClose={closeNotifications}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showListOfDetectors && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<ListOfDetectors
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onDetectorMenuClick={handleDetectorMenuClick}
|
|
||||||
onClose={closeListOfDetectors}
|
|
||||||
is3DReady={selectedModelPath ? !modelError : false}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showSensors && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Sensors
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onAlertClick={handleAlertClick}
|
|
||||||
onClose={closeSensors}
|
|
||||||
is3DReady={selectedModelPath ? !modelError : false}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNotifications && showNotificationDetectorInfo && selectedNotification && (() => {
|
|
||||||
const detectorData = Object.values(detectorsData.detectors).find(
|
|
||||||
detector => detector.detector_id === selectedNotification.detector_id
|
|
||||||
);
|
|
||||||
return detectorData ? (
|
|
||||||
<div className="absolute left-[500px] top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<NotificationDetectorInfo
|
|
||||||
detectorData={detectorData}
|
|
||||||
onClose={closeNotificationDetectorInfo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{showFloorNavigation && showDetectorMenu && selectedDetector && (
|
|
||||||
null
|
|
||||||
)}
|
|
||||||
|
|
||||||
<header className="bg-[#161824] border-b border-gray-700 px-6 h-[73px] flex items-center">
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button
|
|
||||||
onClick={handleBackClick}
|
|
||||||
className="text-gray-400 hover:text-white transition-colors"
|
|
||||||
aria-label="Назад к дашборду"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<nav className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-gray-400">Дашборд</span>
|
|
||||||
<span className="text-gray-600">{'/'}</span>
|
|
||||||
<span className="text-white">{objectTitle || 'Объект'}</span>
|
|
||||||
<span className="text-gray-600">{'/'}</span>
|
|
||||||
<span className="text-white">Навигация</span>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<div className="h-full">
|
|
||||||
{modelError ? (
|
|
||||||
<>
|
|
||||||
{console.log('[NavigationPage] Rendering error message, modelError:', modelError)}
|
|
||||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
|
||||||
<div className="text-red-400 text-lg font-semibold mb-4">
|
|
||||||
Ошибка загрузки 3D модели
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-300 mb-4">
|
|
||||||
{modelError}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
Используйте навигацию по этажам для просмотра детекторов
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : !selectedModelPath ? (
|
|
||||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-center p-8 flex flex-col items-center">
|
|
||||||
<Image
|
|
||||||
src="/icons/logo.png"
|
|
||||||
alt="AerBIM HT Monitor"
|
|
||||||
width={300}
|
|
||||||
height={41}
|
|
||||||
className="mb-6"
|
|
||||||
/>
|
|
||||||
<div className="text-gray-300 text-lg">
|
|
||||||
Выберите модель для отображения
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ModelViewer
|
|
||||||
key={selectedModelPath || 'no-model'}
|
|
||||||
modelPath={selectedModelPath}
|
|
||||||
onSelectModel={(path) => {
|
|
||||||
console.log('[NavigationPage] Model selected:', path);
|
|
||||||
setSelectedModelPath(path)
|
|
||||||
setModelError(null)
|
|
||||||
setIsModelReady(false)
|
|
||||||
}}
|
|
||||||
onModelLoaded={handleModelLoaded}
|
|
||||||
onError={handleModelError}
|
|
||||||
activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null}
|
|
||||||
focusSensorId={focusedSensorId}
|
|
||||||
highlightAllSensors={showSensorHighlights && highlightAllSensors}
|
|
||||||
sensorStatusMap={sensorStatusMap}
|
|
||||||
isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors}
|
|
||||||
onSensorPick={handleSensorSelection}
|
|
||||||
renderOverlay={({ anchor }) => (
|
|
||||||
<>
|
|
||||||
{selectedAlert && showAlertMenu && anchor ? (
|
|
||||||
<AlertMenu
|
|
||||||
alert={selectedAlert}
|
|
||||||
isOpen={true}
|
|
||||||
onClose={closeAlertMenu}
|
|
||||||
getStatusText={getStatusText}
|
|
||||||
compact={true}
|
|
||||||
anchor={anchor}
|
|
||||||
/>
|
|
||||||
) : selectedDetector && showDetectorMenu && anchor ? (
|
|
||||||
<DetectorMenu
|
|
||||||
detector={selectedDetector}
|
|
||||||
isOpen={true}
|
|
||||||
onClose={closeDetectorMenu}
|
|
||||||
getStatusText={getStatusText}
|
|
||||||
compact={true}
|
|
||||||
anchor={anchor}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NavigationPage
|
|
||||||
@@ -1,618 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useEffect, useCallback, useState } from 'react'
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import Sidebar from '../../../components/ui/Sidebar'
|
|
||||||
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
|
|
||||||
import useNavigationStore from '../../store/navigationStore'
|
|
||||||
import Monitoring from '../../../components/navigation/Monitoring'
|
|
||||||
import FloorNavigation from '../../../components/navigation/FloorNavigation'
|
|
||||||
import DetectorMenu from '../../../components/navigation/DetectorMenu'
|
|
||||||
import ListOfDetectors from '../../../components/navigation/ListOfDetectors'
|
|
||||||
import Sensors from '../../../components/navigation/Sensors'
|
|
||||||
import AlertMenu from '../../../components/navigation/AlertMenu'
|
|
||||||
import Notifications from '../../../components/notifications/Notifications'
|
|
||||||
import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo'
|
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
import type { ModelViewerProps } from '../../../components/model/ModelViewer'
|
|
||||||
import * as statusColors from '../../../lib/statusColors'
|
|
||||||
|
|
||||||
const ModelViewer = dynamic<ModelViewerProps>(() => import('../../../components/model/ModelViewer'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="w-full h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-gray-300 animate-pulse">Загрузка 3D-модуля…</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
interface DetectorType {
|
|
||||||
detector_id: number
|
|
||||||
name: string
|
|
||||||
serial_number: string
|
|
||||||
object: string
|
|
||||||
status: string
|
|
||||||
checked: boolean
|
|
||||||
type: string
|
|
||||||
detector_type: string
|
|
||||||
location: string
|
|
||||||
floor: number
|
|
||||||
notifications: Array<{
|
|
||||||
id: number
|
|
||||||
type: string
|
|
||||||
message: string
|
|
||||||
timestamp: string
|
|
||||||
acknowledged: boolean
|
|
||||||
priority: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NotificationType {
|
|
||||||
id: number
|
|
||||||
detector_id: number
|
|
||||||
detector_name: string
|
|
||||||
type: string
|
|
||||||
status: string
|
|
||||||
message: string
|
|
||||||
timestamp: string
|
|
||||||
location: string
|
|
||||||
object: string
|
|
||||||
acknowledged: boolean
|
|
||||||
priority: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AlertType {
|
|
||||||
id: number
|
|
||||||
detector_id: number
|
|
||||||
detector_name: string
|
|
||||||
type: string
|
|
||||||
status: string
|
|
||||||
message: string
|
|
||||||
timestamp: string
|
|
||||||
location: string
|
|
||||||
object: string
|
|
||||||
acknowledged: boolean
|
|
||||||
priority: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const NavigationPage: React.FC = () => {
|
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const {
|
|
||||||
currentObject,
|
|
||||||
setCurrentObject,
|
|
||||||
showMonitoring,
|
|
||||||
showFloorNavigation,
|
|
||||||
showNotifications,
|
|
||||||
showListOfDetectors,
|
|
||||||
showSensors,
|
|
||||||
selectedDetector,
|
|
||||||
showDetectorMenu,
|
|
||||||
selectedNotification,
|
|
||||||
showNotificationDetectorInfo,
|
|
||||||
selectedAlert,
|
|
||||||
showAlertMenu,
|
|
||||||
closeMonitoring,
|
|
||||||
closeFloorNavigation,
|
|
||||||
closeNotifications,
|
|
||||||
closeListOfDetectors,
|
|
||||||
closeSensors,
|
|
||||||
setSelectedDetector,
|
|
||||||
setShowDetectorMenu,
|
|
||||||
setSelectedNotification,
|
|
||||||
setShowNotificationDetectorInfo,
|
|
||||||
setSelectedAlert,
|
|
||||||
setShowAlertMenu,
|
|
||||||
showSensorHighlights,
|
|
||||||
toggleSensorHighlights
|
|
||||||
} = useNavigationStore()
|
|
||||||
|
|
||||||
const [detectorsData, setDetectorsData] = useState<{ detectors: Record<string, DetectorType> }>({ detectors: {} })
|
|
||||||
const [detectorsError, setDetectorsError] = useState<string | null>(null)
|
|
||||||
const [modelError, setModelError] = useState<string | null>(null)
|
|
||||||
const [isModelReady, setIsModelReady] = useState(false)
|
|
||||||
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
|
||||||
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
|
||||||
const sensorStatusMap = React.useMemo(() => {
|
|
||||||
// Создаём карту статусов всегда для отображения цветов датчиков
|
|
||||||
const map: Record<string, string> = {}
|
|
||||||
Object.values(detectorsData.detectors).forEach(d => {
|
|
||||||
if (d.serial_number && d.status) {
|
|
||||||
map[String(d.serial_number).trim()] = d.status
|
|
||||||
}
|
|
||||||
})
|
|
||||||
console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors')
|
|
||||||
console.log('[NavigationPage] Sample sensor IDs in map:', Object.keys(map).slice(0, 5))
|
|
||||||
return map
|
|
||||||
}, [detectorsData])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedDetector === null && selectedAlert === null) {
|
|
||||||
setFocusedSensorId(null);
|
|
||||||
}
|
|
||||||
}, [selectedDetector, selectedAlert]);
|
|
||||||
|
|
||||||
// Управление выделением всех сенсоров при открытии/закрытии меню Sensors
|
|
||||||
// ИСПРАВЛЕНО: Подсветка датчиков остается включенной всегда, независимо от состояния панели Sensors
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady)
|
|
||||||
if (isModelReady) {
|
|
||||||
// Всегда включаем подсветку всех сенсоров когда модель готова
|
|
||||||
console.log('[NavigationPage] Setting highlightAllSensors to TRUE (always enabled)')
|
|
||||||
setHighlightAllSensors(true)
|
|
||||||
// Сбрасываем фокус только если панель Sensors закрыта
|
|
||||||
if (!showSensors) {
|
|
||||||
setFocusedSensorId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [showSensors, isModelReady])
|
|
||||||
|
|
||||||
// Дополнительный эффект для задержки выделения сенсоров при открытии меню
|
|
||||||
// ИСПРАВЛЕНО: Задержка применяется только при открытии панели Sensors
|
|
||||||
useEffect(() => {
|
|
||||||
if (showSensors && isModelReady) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
console.log('[NavigationPage] Delayed highlightAllSensors to TRUE')
|
|
||||||
setHighlightAllSensors(true)
|
|
||||||
}, 500) // Задержка 500мс для полной инициализации модели
|
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [showSensors, isModelReady])
|
|
||||||
|
|
||||||
const urlObjectId = searchParams.get('objectId')
|
|
||||||
const urlObjectTitle = searchParams.get('objectTitle')
|
|
||||||
const urlModelPath = searchParams.get('modelPath')
|
|
||||||
const urlFocusSensorId = searchParams.get('focusSensorId')
|
|
||||||
const objectId = currentObject.id || urlObjectId
|
|
||||||
const objectTitle = currentObject.title || urlObjectTitle
|
|
||||||
const [selectedModelPath, setSelectedModelPath] = useState<string>(urlModelPath || '')
|
|
||||||
|
|
||||||
|
|
||||||
const handleModelLoaded = useCallback(() => {
|
|
||||||
setIsModelReady(true)
|
|
||||||
setModelError(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleModelError = useCallback((error: string) => {
|
|
||||||
console.error('[NavigationPage] Model loading error:', error)
|
|
||||||
setModelError(error)
|
|
||||||
setIsModelReady(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedModelPath) {
|
|
||||||
setIsModelReady(false);
|
|
||||||
setModelError(null);
|
|
||||||
// Сохраняем выбранную модель в URL для восстановления при возврате
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
|
||||||
params.set('modelPath', selectedModelPath);
|
|
||||||
window.history.replaceState(null, '', `?${params.toString()}`);
|
|
||||||
}
|
|
||||||
}, [selectedModelPath, searchParams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
|
||||||
setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined)
|
|
||||||
}
|
|
||||||
}, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject])
|
|
||||||
|
|
||||||
// Восстановление выбранной модели из URL при загрузке страницы
|
|
||||||
useEffect(() => {
|
|
||||||
if (urlModelPath && !selectedModelPath) {
|
|
||||||
setSelectedModelPath(urlModelPath);
|
|
||||||
}
|
|
||||||
}, [urlModelPath, selectedModelPath])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadDetectors = async () => {
|
|
||||||
try {
|
|
||||||
setDetectorsError(null)
|
|
||||||
const res = await fetch('/api/get-detectors', { cache: 'no-store' })
|
|
||||||
const text = await res.text()
|
|
||||||
let payload: any
|
|
||||||
try { payload = JSON.parse(text) } catch { payload = text }
|
|
||||||
console.log('[NavigationPage] GET /api/get-detectors', { status: res.status, payload })
|
|
||||||
if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить детекторов'))
|
|
||||||
const data = payload?.data ?? payload
|
|
||||||
const detectors = (data?.detectors ?? {}) as Record<string, DetectorType>
|
|
||||||
console.log('[NavigationPage] Received detectors count:', Object.keys(detectors).length)
|
|
||||||
console.log('[NavigationPage] Sample detector keys:', Object.keys(detectors).slice(0, 5))
|
|
||||||
setDetectorsData({ detectors })
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('Ошибка загрузки детекторов:', e)
|
|
||||||
setDetectorsError(e?.message || 'Ошибка при загрузке детекторов')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadDetectors()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleBackClick = () => {
|
|
||||||
router.push('/dashboard')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDetectorMenuClick = (detector: DetectorType) => {
|
|
||||||
// Для тестов. Выбор детектора.
|
|
||||||
console.log('[NavigationPage] Selected detector click:', {
|
|
||||||
detector_id: detector.detector_id,
|
|
||||||
name: detector.name,
|
|
||||||
serial_number: detector.serial_number,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Проверяем, что детектор имеет необходимые данные
|
|
||||||
if (!detector || !detector.detector_id || !detector.serial_number) {
|
|
||||||
console.warn('[NavigationPage] Invalid detector data, skipping menu display:', detector)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedDetector?.serial_number === detector.serial_number && showDetectorMenu) {
|
|
||||||
closeDetectorMenu()
|
|
||||||
} else {
|
|
||||||
setSelectedDetector(detector)
|
|
||||||
setShowDetectorMenu(true)
|
|
||||||
setFocusedSensorId(detector.serial_number)
|
|
||||||
setShowAlertMenu(false)
|
|
||||||
setSelectedAlert(null)
|
|
||||||
// При открытии меню детектора - сбрасываем множественное выделение
|
|
||||||
setHighlightAllSensors(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeDetectorMenu = () => {
|
|
||||||
setShowDetectorMenu(false)
|
|
||||||
setSelectedDetector(null)
|
|
||||||
setFocusedSensorId(null)
|
|
||||||
setSelectedAlert(null)
|
|
||||||
// При закрытии меню детектора - выделяем все сенсоры снова
|
|
||||||
setHighlightAllSensors(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNotificationClick = (notification: NotificationType) => {
|
|
||||||
if (selectedNotification?.id === notification.id && showNotificationDetectorInfo) {
|
|
||||||
setShowNotificationDetectorInfo(false)
|
|
||||||
setSelectedNotification(null)
|
|
||||||
} else {
|
|
||||||
setSelectedNotification(notification)
|
|
||||||
setShowNotificationDetectorInfo(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeNotificationDetectorInfo = () => {
|
|
||||||
setShowNotificationDetectorInfo(false)
|
|
||||||
setSelectedNotification(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeAlertMenu = () => {
|
|
||||||
setShowAlertMenu(false)
|
|
||||||
setSelectedAlert(null)
|
|
||||||
setFocusedSensorId(null)
|
|
||||||
setSelectedDetector(null)
|
|
||||||
// При закрытии меню алерта - выделяем все сенсоры снова
|
|
||||||
setHighlightAllSensors(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAlertClick = (alert: AlertType) => {
|
|
||||||
console.log('[NavigationPage] Alert clicked, focusing on detector in 3D scene:', alert)
|
|
||||||
|
|
||||||
const detector = Object.values(detectorsData.detectors).find(
|
|
||||||
d => d.detector_id === alert.detector_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if (detector) {
|
|
||||||
if (selectedAlert?.id === alert.id && showAlertMenu) {
|
|
||||||
closeAlertMenu()
|
|
||||||
} else {
|
|
||||||
setSelectedAlert(alert)
|
|
||||||
setShowAlertMenu(true)
|
|
||||||
setFocusedSensorId(detector.serial_number)
|
|
||||||
setShowDetectorMenu(false)
|
|
||||||
setSelectedDetector(null)
|
|
||||||
// При открытии меню алерта - сбрасываем множественное выделение
|
|
||||||
setHighlightAllSensors(false)
|
|
||||||
console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSensorSelection = (serialNumber: string | null) => {
|
|
||||||
if (serialNumber === null) {
|
|
||||||
setFocusedSensorId(null);
|
|
||||||
closeDetectorMenu();
|
|
||||||
closeAlertMenu();
|
|
||||||
// If we're in Sensors menu and no sensor is selected, highlight all sensors
|
|
||||||
if (showSensors) {
|
|
||||||
setHighlightAllSensors(true);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (focusedSensorId === serialNumber) {
|
|
||||||
setFocusedSensorId(null);
|
|
||||||
closeDetectorMenu();
|
|
||||||
closeAlertMenu();
|
|
||||||
// If we're in Sensors menu and deselected the current sensor, highlight all sensors
|
|
||||||
if (showSensors) {
|
|
||||||
setHighlightAllSensors(true);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// При выборе конкретного сенсора - сбрасываем множественное выделение
|
|
||||||
setHighlightAllSensors(false)
|
|
||||||
|
|
||||||
const detector = Object.values(detectorsData?.detectors || {}).find(
|
|
||||||
(d) => d.serial_number === serialNumber
|
|
||||||
);
|
|
||||||
|
|
||||||
if (detector) {
|
|
||||||
// Всегда показываем меню детектора для всех датчиков
|
|
||||||
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 (
|
|
||||||
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
|
||||||
<AnimatedBackground />
|
|
||||||
<div className="relative z-20">
|
|
||||||
<Sidebar
|
|
||||||
activeItem={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex-1 flex flex-col">
|
|
||||||
|
|
||||||
{showMonitoring && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Monitoring
|
|
||||||
onClose={closeMonitoring}
|
|
||||||
onSelectModel={(path) => {
|
|
||||||
console.log('[NavigationPage] Model selected:', path);
|
|
||||||
setSelectedModelPath(path)
|
|
||||||
setModelError(null)
|
|
||||||
setIsModelReady(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showFloorNavigation && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<FloorNavigation
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onDetectorMenuClick={handleDetectorMenuClick}
|
|
||||||
onClose={closeFloorNavigation}
|
|
||||||
is3DReady={isModelReady && !modelError}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNotifications && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Notifications
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onNotificationClick={handleNotificationClick}
|
|
||||||
onClose={closeNotifications}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showListOfDetectors && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<ListOfDetectors
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onDetectorMenuClick={handleDetectorMenuClick}
|
|
||||||
onClose={closeListOfDetectors}
|
|
||||||
is3DReady={selectedModelPath ? !modelError : false}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showSensors && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Sensors
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onAlertClick={handleAlertClick}
|
|
||||||
onClose={closeSensors}
|
|
||||||
is3DReady={selectedModelPath ? !modelError : false}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNotifications && showNotificationDetectorInfo && selectedNotification && (() => {
|
|
||||||
const detectorData = Object.values(detectorsData.detectors).find(
|
|
||||||
detector => detector.detector_id === selectedNotification.detector_id
|
|
||||||
);
|
|
||||||
return detectorData ? (
|
|
||||||
<div className="absolute left-[500px] top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<NotificationDetectorInfo
|
|
||||||
detectorData={detectorData}
|
|
||||||
onClose={closeNotificationDetectorInfo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{showFloorNavigation && showDetectorMenu && selectedDetector && (
|
|
||||||
null
|
|
||||||
)}
|
|
||||||
|
|
||||||
<header className="bg-[#161824] border-b border-gray-700 px-6 h-[73px] flex items-center">
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button
|
|
||||||
onClick={handleBackClick}
|
|
||||||
className="text-gray-400 hover:text-white transition-colors"
|
|
||||||
aria-label="Назад к дашборду"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<nav className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-gray-400">Дашборд</span>
|
|
||||||
<span className="text-gray-600">{'/'}</span>
|
|
||||||
<span className="text-white">{objectTitle || 'Объект'}</span>
|
|
||||||
<span className="text-gray-600">{'/'}</span>
|
|
||||||
<span className="text-white">Навигация</span>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<div className="h-full">
|
|
||||||
{modelError ? (
|
|
||||||
<>
|
|
||||||
{console.log('[NavigationPage] Rendering error message, modelError:', modelError)}
|
|
||||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
|
||||||
<div className="text-red-400 text-lg font-semibold mb-4">
|
|
||||||
Ошибка загрузки 3D модели
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-300 mb-4">
|
|
||||||
{modelError}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
Используйте навигацию по этажам для просмотра детекторов
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : !selectedModelPath ? (
|
|
||||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-center p-8 flex flex-col items-center">
|
|
||||||
<Image
|
|
||||||
src="/icons/logo.png"
|
|
||||||
alt="AerBIM HT Monitor"
|
|
||||||
width={300}
|
|
||||||
height={41}
|
|
||||||
className="mb-6"
|
|
||||||
/>
|
|
||||||
<div className="text-gray-300 text-lg">
|
|
||||||
Выберите модель для отображения
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ModelViewer
|
|
||||||
key={selectedModelPath || 'no-model'}
|
|
||||||
modelPath={selectedModelPath}
|
|
||||||
onSelectModel={(path) => {
|
|
||||||
console.log('[NavigationPage] Model selected:', path);
|
|
||||||
setSelectedModelPath(path)
|
|
||||||
setModelError(null)
|
|
||||||
setIsModelReady(false)
|
|
||||||
}}
|
|
||||||
onModelLoaded={handleModelLoaded}
|
|
||||||
onError={handleModelError}
|
|
||||||
activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null}
|
|
||||||
focusSensorId={focusedSensorId}
|
|
||||||
highlightAllSensors={showSensorHighlights && highlightAllSensors}
|
|
||||||
sensorStatusMap={sensorStatusMap}
|
|
||||||
isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors}
|
|
||||||
onSensorPick={handleSensorSelection}
|
|
||||||
renderOverlay={({ anchor }) => (
|
|
||||||
<>
|
|
||||||
{selectedAlert && showAlertMenu && anchor ? (
|
|
||||||
<AlertMenu
|
|
||||||
alert={selectedAlert}
|
|
||||||
isOpen={true}
|
|
||||||
onClose={closeAlertMenu}
|
|
||||||
getStatusText={getStatusText}
|
|
||||||
compact={true}
|
|
||||||
anchor={anchor}
|
|
||||||
/>
|
|
||||||
) : selectedDetector && showDetectorMenu && anchor ? (
|
|
||||||
<DetectorMenu
|
|
||||||
detector={selectedDetector}
|
|
||||||
isOpen={true}
|
|
||||||
onClose={closeDetectorMenu}
|
|
||||||
getStatusText={getStatusText}
|
|
||||||
compact={true}
|
|
||||||
anchor={anchor}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NavigationPage
|
|
||||||
@@ -21,6 +21,8 @@ interface SceneToolbarProps {
|
|||||||
onSelectModel?: (modelPath: string) => void;
|
onSelectModel?: (modelPath: string) => void;
|
||||||
panActive?: boolean;
|
panActive?: boolean;
|
||||||
navMenuActive?: boolean;
|
navMenuActive?: boolean;
|
||||||
|
onToggleSensorHighlights?: () => void;
|
||||||
|
sensorHighlightsActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SceneToolbar: React.FC<SceneToolbarProps> = ({
|
const SceneToolbar: React.FC<SceneToolbarProps> = ({
|
||||||
@@ -31,6 +33,8 @@ const SceneToolbar: React.FC<SceneToolbarProps> = ({
|
|||||||
onSelectModel,
|
onSelectModel,
|
||||||
panActive = false,
|
panActive = false,
|
||||||
navMenuActive = false,
|
navMenuActive = false,
|
||||||
|
onToggleSensorHighlights,
|
||||||
|
sensorHighlightsActive = true,
|
||||||
}) => {
|
}) => {
|
||||||
const [isZoomOpen, setIsZoomOpen] = useState(false);
|
const [isZoomOpen, setIsZoomOpen] = useState(false);
|
||||||
const { showMonitoring, openMonitoring, closeMonitoring, currentZones, loadZones, currentObject } = useNavigationStore();
|
const { showMonitoring, openMonitoring, closeMonitoring, currentZones, loadZones, currentObject } = useNavigationStore();
|
||||||
@@ -43,88 +47,45 @@ const SceneToolbar: React.FC<SceneToolbarProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHomeClick = async () => {
|
|
||||||
if (!onSelectModel) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let zones: Zone[] = Array.isArray(currentZones) ? currentZones : [];
|
|
||||||
|
|
||||||
// Если зоны ещё не загружены, откройте Monitoring и загрузите зоны для текущего объекта
|
|
||||||
if ((!zones || zones.length === 0) && currentObject?.id) {
|
|
||||||
if (!showMonitoring) {
|
|
||||||
openMonitoring();
|
|
||||||
}
|
|
||||||
await loadZones(currentObject.id);
|
|
||||||
zones = useNavigationStore.getState().currentZones || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(zones) || zones.length === 0) {
|
|
||||||
console.warn('No zones available to select a model from.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sorted = zones.slice().sort((a: Zone, b: Zone) => {
|
|
||||||
const oa = typeof a.order === 'number' ? a.order : 0;
|
|
||||||
const ob = typeof b.order === 'number' ? b.order : 0;
|
|
||||||
if (oa !== ob) return oa - ob;
|
|
||||||
return (a.name || '').localeCompare(b.name || '');
|
|
||||||
});
|
|
||||||
|
|
||||||
const top = sorted[0];
|
|
||||||
let chosenPath: string | null = top?.model_path && String(top.model_path).trim() ? top.model_path! : null;
|
|
||||||
if (!chosenPath) {
|
|
||||||
const nextWithModel = sorted.find((z) => z.model_path && String(z.model_path).trim());
|
|
||||||
chosenPath = nextWithModel?.model_path ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chosenPath) {
|
|
||||||
onSelectModel(chosenPath);
|
|
||||||
} else {
|
|
||||||
console.warn('No zone has a valid model_path to open.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error selecting top zone model:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultButtons: ToolbarButton[] = [
|
const defaultButtons: ToolbarButton[] = [
|
||||||
{
|
{
|
||||||
icon: '/icons/Zoom.png',
|
icon: '/icons/Zoom.png',
|
||||||
label: 'Zoom',
|
label: 'Масштаб',
|
||||||
onClick: () => setIsZoomOpen(!isZoomOpen),
|
onClick: () => setIsZoomOpen(!isZoomOpen),
|
||||||
active: isZoomOpen,
|
active: isZoomOpen,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
icon: '/icons/plus.svg',
|
icon: '/icons/plus.svg',
|
||||||
label: 'Zoom In',
|
label: 'Приблизить',
|
||||||
onClick: onZoomIn || (() => {}),
|
onClick: onZoomIn || (() => {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '/icons/minus.svg',
|
icon: '/icons/minus.svg',
|
||||||
label: 'Zoom Out',
|
label: 'Отдалить',
|
||||||
onClick: onZoomOut || (() => {}),
|
onClick: onZoomOut || (() => {}),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '/icons/Video.png',
|
icon: '/icons/Video.png',
|
||||||
label: "Top View",
|
label: 'Вид сверху',
|
||||||
onClick: onTopView || (() => console.log('Top View')),
|
onClick: onTopView || (() => console.log('Top View')),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '/icons/Pointer.png',
|
icon: '/icons/Pointer.png',
|
||||||
label: 'Pan',
|
label: 'Панорамирование',
|
||||||
onClick: onPan || (() => console.log('Pan')),
|
onClick: onPan || (() => console.log('Pan')),
|
||||||
active: panActive,
|
active: panActive,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '/icons/Warehouse.png',
|
icon: '/icons/Eye.png',
|
||||||
label: 'Home',
|
label: 'Подсветка датчиков',
|
||||||
onClick: handleHomeClick,
|
onClick: onToggleSensorHighlights || (() => console.log('Toggle Sensor Highlights')),
|
||||||
|
active: sensorHighlightsActive,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '/icons/Layers.png',
|
icon: '/icons/Layers.png',
|
||||||
label: 'Levels',
|
label: 'Общий вид',
|
||||||
onClick: handleToggleNavMenu,
|
onClick: handleToggleNavMenu,
|
||||||
active: navMenuActive,
|
active: navMenuActive,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
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<SceneToolbarProps> = ({
|
|
||||||
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 handleHomeClick = async () => {
|
|
||||||
if (!onSelectModel) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let zones: Zone[] = Array.isArray(currentZones) ? currentZones : [];
|
|
||||||
|
|
||||||
// Если зоны ещё не загружены, откройте Monitoring и загрузите зоны для текущего объекта
|
|
||||||
if ((!zones || zones.length === 0) && currentObject?.id) {
|
|
||||||
if (!showMonitoring) {
|
|
||||||
openMonitoring();
|
|
||||||
}
|
|
||||||
await loadZones(currentObject.id);
|
|
||||||
zones = useNavigationStore.getState().currentZones || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(zones) || zones.length === 0) {
|
|
||||||
console.warn('No zones available to select a model from.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sorted = zones.slice().sort((a: Zone, b: Zone) => {
|
|
||||||
const oa = typeof a.order === 'number' ? a.order : 0;
|
|
||||||
const ob = typeof b.order === 'number' ? b.order : 0;
|
|
||||||
if (oa !== ob) return oa - ob;
|
|
||||||
return (a.name || '').localeCompare(b.name || '');
|
|
||||||
});
|
|
||||||
|
|
||||||
const top = sorted[0];
|
|
||||||
let chosenPath: string | null = top?.model_path && String(top.model_path).trim() ? top.model_path! : null;
|
|
||||||
if (!chosenPath) {
|
|
||||||
const nextWithModel = sorted.find((z) => z.model_path && String(z.model_path).trim());
|
|
||||||
chosenPath = nextWithModel?.model_path ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chosenPath) {
|
|
||||||
onSelectModel(chosenPath);
|
|
||||||
} else {
|
|
||||||
console.warn('No zone has a valid model_path to open.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error selecting top zone model:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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/Warehouse.png',
|
|
||||||
label: 'Домой',
|
|
||||||
onClick: handleHomeClick,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '/icons/Layers.png',
|
|
||||||
label: 'Уровни',
|
|
||||||
onClick: handleToggleNavMenu,
|
|
||||||
active: navMenuActive,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed right-5 top-1/2 transform -translate-y-1/2 z-50">
|
|
||||||
<div className="flex flex-col gap-0">
|
|
||||||
<div
|
|
||||||
className="flex flex-col items-center gap-2 py-4 bg-[#161824] rounded-[15px] border border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.3)]"
|
|
||||||
style={{ minHeight: '320px' }}
|
|
||||||
>
|
|
||||||
{defaultButtons.map((button, index) => (
|
|
||||||
<div key={index} className="flex flex-col items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={button.onClick}
|
|
||||||
className={`
|
|
||||||
relative group flex items-center justify-center w-16 h-12 rounded-lg transition-all duration-200
|
|
||||||
hover:bg-blue-600/20 hover:scale-110 hover:shadow-lg
|
|
||||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50
|
|
||||||
${button.active
|
|
||||||
? 'bg-blue-600/30 text-blue-400 shadow-md'
|
|
||||||
: 'bg-transparent text-gray-300 hover:text-blue-400'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
title={button.label}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={button.icon}
|
|
||||||
alt={button.label}
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="w-5 h-5 transition-transform duration-200 group-hover:scale-110"
|
|
||||||
/>
|
|
||||||
<div className="absolute right-full mr-3 top-1/2 transform -translate-y-1/2
|
|
||||||
opacity-0 group-hover:opacity-100 transition-opacity duration-200
|
|
||||||
pointer-events-none z-60">
|
|
||||||
<div className="bg-gray-900 text-white text-xs px-2 py-1 rounded
|
|
||||||
whitespace-nowrap shadow-lg border border-gray-700">
|
|
||||||
{button.label}
|
|
||||||
</div>
|
|
||||||
<div className="absolute left-full top-1/2 transform -translate-y-1/2
|
|
||||||
w-0 h-0 border-t-4 border-t-transparent
|
|
||||||
border-b-4 border-b-transparent
|
|
||||||
border-l-4 border-l-gray-900">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{button.active && button.children && (
|
|
||||||
<div className="flex flex-col gap-2 mt-2">
|
|
||||||
{button.children.map((childButton, childIndex) => (
|
|
||||||
<button
|
|
||||||
key={childIndex}
|
|
||||||
onClick={childButton.onClick}
|
|
||||||
onMouseDown={childButton.onMouseDown}
|
|
||||||
onMouseUp={childButton.onMouseUp}
|
|
||||||
className="relative group flex items-center justify-center w-12 h-10 bg-gray-800/50 rounded-md transition-all duration-200 hover:bg-blue-600/30"
|
|
||||||
title={childButton.label}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={childButton.icon}
|
|
||||||
alt={childButton.label}
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className="w-4 h-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SceneToolbar;
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
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<SceneToolbarProps> = ({
|
|
||||||
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 (
|
|
||||||
<div className="fixed right-5 top-1/2 transform -translate-y-1/2 z-50">
|
|
||||||
<div className="flex flex-col gap-0">
|
|
||||||
<div
|
|
||||||
className="flex flex-col items-center gap-2 py-4 bg-[#161824] rounded-[15px] border border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.3)]"
|
|
||||||
style={{ minHeight: '320px' }}
|
|
||||||
>
|
|
||||||
{defaultButtons.map((button, index) => (
|
|
||||||
<div key={index} className="flex flex-col items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={button.onClick}
|
|
||||||
className={`
|
|
||||||
relative group flex items-center justify-center w-16 h-12 rounded-lg transition-all duration-200
|
|
||||||
hover:bg-blue-600/20 hover:scale-110 hover:shadow-lg
|
|
||||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50
|
|
||||||
${button.active
|
|
||||||
? 'bg-blue-600/30 text-blue-400 shadow-md'
|
|
||||||
: 'bg-transparent text-gray-300 hover:text-blue-400'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
title={button.label}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={button.icon}
|
|
||||||
alt={button.label}
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="w-5 h-5 transition-transform duration-200 group-hover:scale-110"
|
|
||||||
/>
|
|
||||||
<div className="absolute right-full mr-3 top-1/2 transform -translate-y-1/2
|
|
||||||
opacity-0 group-hover:opacity-100 transition-opacity duration-200
|
|
||||||
pointer-events-none z-60">
|
|
||||||
<div className="bg-gray-900 text-white text-xs px-2 py-1 rounded
|
|
||||||
whitespace-nowrap shadow-lg border border-gray-700">
|
|
||||||
{button.label}
|
|
||||||
</div>
|
|
||||||
<div className="absolute left-full top-1/2 transform -translate-y-1/2
|
|
||||||
w-0 h-0 border-t-4 border-t-transparent
|
|
||||||
border-b-4 border-b-transparent
|
|
||||||
border-l-4 border-l-gray-900">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{button.active && button.children && (
|
|
||||||
<div className="flex flex-col gap-2 mt-2">
|
|
||||||
{button.children.map((childButton, childIndex) => (
|
|
||||||
<button
|
|
||||||
key={childIndex}
|
|
||||||
onClick={childButton.onClick}
|
|
||||||
onMouseDown={childButton.onMouseDown}
|
|
||||||
onMouseUp={childButton.onMouseUp}
|
|
||||||
className="relative group flex items-center justify-center w-12 h-10 bg-gray-800/50 rounded-md transition-all duration-200 hover:bg-blue-600/30"
|
|
||||||
title={childButton.label}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={childButton.icon}
|
|
||||||
alt={childButton.label}
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className="w-4 h-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SceneToolbar;
|
|
||||||
@@ -60,20 +60,12 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|||||||
loadZones(objId);
|
loadZones(objId);
|
||||||
}, [currentObject?.id, loadZones]);
|
}, [currentObject?.id, loadZones]);
|
||||||
|
|
||||||
// Автоматический выбор модели, если currentModelPath установлен (переход из таблицы)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentModelPath || autoSelectedRef || !onSelectModel) return;
|
|
||||||
|
|
||||||
console.log('[Monitoring] Auto-selecting model from currentModelPath:', currentModelPath);
|
|
||||||
setAutoSelectedRef(true);
|
|
||||||
onSelectModel(currentModelPath);
|
|
||||||
}, [currentModelPath, autoSelectedRef, onSelectModel]);
|
|
||||||
|
|
||||||
// Сброс флага при изменении объекта
|
// Сброс флага при изменении объекта
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAutoSelectedRef(false);
|
setAutoSelectedRef(false);
|
||||||
}, [currentObject?.id])
|
}, [currentObject?.id])
|
||||||
|
|
||||||
|
// Сортировка зон по order
|
||||||
const sortedZones: Zone[] = React.useMemo(() => {
|
const sortedZones: Zone[] = React.useMemo(() => {
|
||||||
const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
||||||
const oa = typeof a.order === 'number' ? a.order : 0;
|
const oa = typeof a.order === 'number' ? a.order : 0;
|
||||||
@@ -85,6 +77,19 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|||||||
return sorted;
|
return sorted;
|
||||||
}, [currentZones]);
|
}, [currentZones]);
|
||||||
|
|
||||||
|
// Автоматический выбор модели с order=0 при загрузке зон
|
||||||
|
useEffect(() => {
|
||||||
|
// Если уже был автовыбор - пропускаем
|
||||||
|
if (autoSelectedRef || !onSelectModel) return;
|
||||||
|
|
||||||
|
// Если есть зоны и первая зона (order=0) имеет model_path
|
||||||
|
if (sortedZones.length > 0 && sortedZones[0]?.model_path) {
|
||||||
|
console.log('[Monitoring] Auto-selecting model with order=0:', sortedZones[0].model_path);
|
||||||
|
setAutoSelectedRef(true);
|
||||||
|
onSelectModel(sortedZones[0].model_path);
|
||||||
|
}
|
||||||
|
}, [sortedZones, autoSelectedRef, onSelectModel])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4">
|
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4">
|
||||||
|
|||||||
@@ -35,17 +35,22 @@ interface MonitoringProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
||||||
const { currentObject, currentZones, zonesLoading, zonesError, loadZones } = useNavigationStore();
|
const { currentObject, currentZones, zonesLoading, zonesError, loadZones, currentModelPath } = useNavigationStore();
|
||||||
const [userSelectedModel, setUserSelectedModel] = useState(false);
|
const [autoSelectedRef, setAutoSelectedRef] = React.useState(false);
|
||||||
|
|
||||||
const handleSelectModel = useCallback((modelPath: string, isUserClick = false) => {
|
const handleSelectModel = useCallback((modelPath: string) => {
|
||||||
console.log(`[Monitoring] Model selected: ${modelPath}, isUserClick: ${isUserClick}`);
|
console.log(`[Monitoring] Model selected: ${modelPath}`);
|
||||||
console.log(`[Monitoring] onSelectModel callback:`, onSelectModel);
|
console.log(`[Monitoring] onSelectModel callback:`, onSelectModel);
|
||||||
if (isUserClick) {
|
|
||||||
setUserSelectedModel(true);
|
|
||||||
}
|
|
||||||
onSelectModel?.(modelPath);
|
onSelectModel?.(modelPath);
|
||||||
}, [onSelectModel]);
|
|
||||||
|
// Автоматически закрываем панель после выбора модели
|
||||||
|
if (onClose) {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('[Monitoring] Auto-closing after model selection');
|
||||||
|
onClose();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [onSelectModel, onClose]);
|
||||||
|
|
||||||
// Загрузка зон при изменении объекта
|
// Загрузка зон при изменении объекта
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,25 +60,19 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|||||||
loadZones(objId);
|
loadZones(objId);
|
||||||
}, [currentObject?.id, loadZones]);
|
}, [currentObject?.id, loadZones]);
|
||||||
|
|
||||||
// Автоматический выбор первой зоны при загрузке (только если пользователь не выбрал вручную)
|
// Автоматический выбор модели, если currentModelPath установлен (переход из таблицы)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSelectedModel) {
|
if (!currentModelPath || autoSelectedRef || !onSelectModel) return;
|
||||||
console.log('[Monitoring] User already selected model, skipping auto-selection');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedZones: Zone[] = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
console.log('[Monitoring] Auto-selecting model from currentModelPath:', currentModelPath);
|
||||||
const oa = typeof a.order === 'number' ? a.order : 0;
|
setAutoSelectedRef(true);
|
||||||
const ob = typeof b.order === 'number' ? b.order : 0;
|
onSelectModel(currentModelPath);
|
||||||
if (oa !== ob) return oa - ob;
|
}, [currentModelPath, autoSelectedRef, onSelectModel]);
|
||||||
return (a.name || '').localeCompare(b.name || '');
|
|
||||||
});
|
|
||||||
|
|
||||||
if (sortedZones.length > 0 && sortedZones[0].model_path && !zonesLoading) {
|
// Сброс флага при изменении объекта
|
||||||
console.log('[Monitoring] Auto-selecting first zone model');
|
useEffect(() => {
|
||||||
handleSelectModel(sortedZones[0].model_path, false);
|
setAutoSelectedRef(false);
|
||||||
}
|
}, [currentObject?.id])
|
||||||
}, [currentZones, zonesLoading, handleSelectModel, userSelectedModel]);
|
|
||||||
|
|
||||||
const sortedZones: Zone[] = React.useMemo(() => {
|
const sortedZones: Zone[] = React.useMemo(() => {
|
||||||
const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
||||||
@@ -119,26 +118,30 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|||||||
<button
|
<button
|
||||||
key={`zone-${sortedZones[0].id}-panorama`}
|
key={`zone-${sortedZones[0].id}-panorama`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => sortedZones[0].model_path ? handleSelectModel(sortedZones[0].model_path, true) : null}
|
onClick={() => sortedZones[0].model_path ? handleSelectModel(sortedZones[0].model_path) : null}
|
||||||
className="w-full bg-gray-300 rounded-lg h-[200px] flex items-center justify-center hover:bg-gray-400 transition-colors mb-4"
|
className="group w-full bg-gradient-to-br from-cyan-600/20 via-blue-600/20 to-purple-600/20 rounded-xl h-[220px] flex items-center justify-center hover:from-cyan-500/30 hover:via-blue-500/30 hover:to-purple-500/30 transition-all duration-300 mb-4 border border-cyan-400/30 hover:border-cyan-400/60 shadow-lg shadow-cyan-500/10 hover:shadow-cyan-500/30 overflow-hidden relative backdrop-blur-sm"
|
||||||
title={sortedZones[0].model_path ? `Открыть 3D модель зоны: ${sortedZones[0].name}` : 'Модель зоны отсутствует'}
|
title={sortedZones[0].model_path ? `Открыть 3D модель зоны: ${sortedZones[0].name}` : 'Модель зоны отсутствует'}
|
||||||
disabled={!sortedZones[0].model_path}
|
disabled={!sortedZones[0].model_path}
|
||||||
>
|
>
|
||||||
<div className="w-full h-full bg-gray-200 rounded flex flex-col items-center justify-center relative">
|
{/* Градиентный фон при наведении */}
|
||||||
{/* Всегда рендерим с разрешённой заглушкой */}
|
<div className="absolute inset-0 bg-gradient-to-br from-cyan-400/10 via-blue-400/10 to-purple-400/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||||
|
{/* Анимированный градиент по краям */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-cyan-400/5 to-transparent animate-pulse"></div>
|
||||||
|
|
||||||
|
<div className="w-full h-full rounded-lg flex flex-col items-center justify-center relative">
|
||||||
<Image
|
<Image
|
||||||
src={resolveImageSrc(sortedZones[0].image_path)}
|
src={resolveImageSrc(sortedZones[0].image_path)}
|
||||||
alt={sortedZones[0].name || 'Зона'}
|
alt={sortedZones[0].name || 'Зона'}
|
||||||
width={200}
|
width={200}
|
||||||
height={200}
|
height={200}
|
||||||
className="max-w-full max-h-full object-contain opacity-50"
|
className="max-w-full max-h-full object-contain opacity-60 group-hover:opacity-80 transition-opacity duration-300"
|
||||||
style={{ height: 'auto' }}
|
style={{ height: 'auto' }}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
target.src = '/images/test_image.png';
|
target.src = '/images/test_image.png';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-2 left-2 right-2 text-sm text-gray-700 bg-white/80 rounded px-3 py-1 truncate">
|
<div className="absolute bottom-3 left-3 right-3 text-sm font-medium text-white bg-gradient-to-r from-cyan-600/80 via-blue-600/80 to-purple-600/80 backdrop-blur-md rounded-lg px-4 py-2 truncate border border-cyan-400/40 shadow-lg shadow-cyan-500/20">
|
||||||
{sortedZones[0].name}
|
{sortedZones[0].name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,25 +153,30 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|||||||
<button
|
<button
|
||||||
key={`zone-${zone.id}-${idx}`}
|
key={`zone-${zone.id}-${idx}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => zone.model_path ? handleSelectModel(zone.model_path, true) : null}
|
onClick={() => zone.model_path ? handleSelectModel(zone.model_path) : null}
|
||||||
className="relative flex-1 bg-gray-300 rounded-lg h-[120px] flex items-center justify-center hover:bg-gray-400 transition-colors"
|
className="group relative flex-1 bg-gradient-to-br from-emerald-600/20 via-teal-600/20 to-cyan-600/20 rounded-xl h-[140px] flex items-center justify-center hover:from-emerald-500/30 hover:via-teal-500/30 hover:to-cyan-500/30 transition-all duration-300 border border-emerald-400/30 hover:border-emerald-400/60 shadow-lg shadow-emerald-500/10 hover:shadow-emerald-500/30 overflow-hidden backdrop-blur-sm"
|
||||||
title={zone.model_path ? `Открыть 3D модель зоны: ${zone.name}` : 'Модель зоны отсутствует'}
|
title={zone.model_path ? `Открыть 3D модель зоны: ${zone.name}` : 'Модель зоны отсутствует'}
|
||||||
disabled={!zone.model_path}
|
disabled={!zone.model_path}
|
||||||
>
|
>
|
||||||
<div className="w-full h-full bg-gray-200 rounded flex flex-col items-center justify-center relative">
|
{/* Градиентный фон при наведении */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-emerald-400/10 via-teal-400/10 to-cyan-400/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||||
|
{/* Анимированный градиент по краям */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-emerald-400/5 to-transparent animate-pulse"></div>
|
||||||
|
|
||||||
|
<div className="w-full h-full rounded-lg flex flex-col items-center justify-center relative">
|
||||||
<Image
|
<Image
|
||||||
src={resolveImageSrc(zone.image_path)}
|
src={resolveImageSrc(zone.image_path)}
|
||||||
alt={zone.name || 'Зона'}
|
alt={zone.name || 'Зона'}
|
||||||
width={120}
|
width={120}
|
||||||
height={120}
|
height={120}
|
||||||
className="max-w-full max-h-full object-contain opacity-50"
|
className="max-w-full max-h-full object-contain opacity-60 group-hover:opacity-80 transition-opacity duration-300"
|
||||||
style={{ height: 'auto' }}
|
style={{ height: 'auto' }}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
target.src = '/images/test_image.png';
|
target.src = '/images/test_image.png';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-1 left-1 right-1 text-[10px] text-gray-700 bg-white/70 rounded px-2 py-0.5 truncate">
|
<div className="absolute bottom-2 left-2 right-2 text-[11px] font-medium text-white bg-gradient-to-r from-emerald-600/80 via-teal-600/80 to-cyan-600/80 backdrop-blur-md rounded-md px-2 py-1 truncate border border-emerald-400/40 shadow-md shadow-emerald-500/20">
|
||||||
{zone.name}
|
{zone.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
import React, { useEffect, useCallback, useState } from 'react';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import useNavigationStore from '@/app/store/navigationStore';
|
|
||||||
import type { Zone } from '@/app/types';
|
|
||||||
|
|
||||||
// Безопасный резолвер src изображения, чтобы избежать ошибок Invalid URL в next/image
|
|
||||||
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';
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
// Если путь содержит public/images (даже абсолютный путь ФС), переводим в относительный путь сайта
|
|
||||||
if (/\/public\/images\//i.test(s)) {
|
|
||||||
const parts = s.split(/\/public\/images\//i);
|
|
||||||
const rel = parts[1] || '';
|
|
||||||
return `/images/${rel}`;
|
|
||||||
}
|
|
||||||
// Абсолютные URL и пути, относительные к сайту
|
|
||||||
if (s.startsWith('http://') || s.startsWith('https://')) return s;
|
|
||||||
if (s.startsWith('/')) return s;
|
|
||||||
// Нормализуем относительные имена ресурсов до путей сайта под /images
|
|
||||||
// Убираем ведущий 'public/', если он присутствует
|
|
||||||
s = s.replace(/^public\//i, '');
|
|
||||||
return s.startsWith('images/') ? `/${s}` : `/images/${s}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MonitoringProps {
|
|
||||||
onClose?: () => void;
|
|
||||||
onSelectModel?: (modelPath: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|
||||||
const { currentObject, currentZones, zonesLoading, zonesError, loadZones } = useNavigationStore();
|
|
||||||
|
|
||||||
const handleSelectModel = useCallback((modelPath: string) => {
|
|
||||||
console.log(`[Monitoring] Model selected: ${modelPath}`);
|
|
||||||
console.log(`[Monitoring] onSelectModel callback:`, onSelectModel);
|
|
||||||
onSelectModel?.(modelPath);
|
|
||||||
}, [onSelectModel]);
|
|
||||||
|
|
||||||
// Загрузка зон при изменении объекта
|
|
||||||
useEffect(() => {
|
|
||||||
const objId = currentObject?.id;
|
|
||||||
if (!objId) return;
|
|
||||||
console.log(`[Monitoring] Loading zones for object ID: ${objId}`);
|
|
||||||
loadZones(objId);
|
|
||||||
}, [currentObject?.id, loadZones]);
|
|
||||||
|
|
||||||
// Автоматический выбор первой зоны ОТКЛЮЧЕН - пользователь должен выбрать модель вручную
|
|
||||||
|
|
||||||
const sortedZones: Zone[] = React.useMemo(() => {
|
|
||||||
const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
|
||||||
const oa = typeof a.order === 'number' ? a.order : 0;
|
|
||||||
const ob = typeof b.order === 'number' ? b.order : 0;
|
|
||||||
if (oa !== ob) return oa - ob;
|
|
||||||
return (a.name || '').localeCompare(b.name || '');
|
|
||||||
});
|
|
||||||
console.log(`[Monitoring] Sorted zones:`, sorted.map(z => ({ id: z.id, name: z.name, model_path: z.model_path })));
|
|
||||||
return sorted;
|
|
||||||
}, [currentZones]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-white text-2xl font-semibold">Зоны мониторинга</h2>
|
|
||||||
{onClose && (
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-white hover:text-gray-300 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* UI зон */}
|
|
||||||
{zonesError && (
|
|
||||||
<div className="rounded-lg bg-red-600/20 border border-red-600/40 text-red-200 text-xs px-3 py-2">
|
|
||||||
Ошибка загрузки зон: {zonesError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{zonesLoading && (
|
|
||||||
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
|
|
||||||
Загрузка зон...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sortedZones.length > 0 && (
|
|
||||||
<>
|
|
||||||
{sortedZones[0] && (
|
|
||||||
<button
|
|
||||||
key={`zone-${sortedZones[0].id}-panorama`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => sortedZones[0].model_path ? handleSelectModel(sortedZones[0].model_path) : null}
|
|
||||||
className="w-full bg-gray-300 rounded-lg h-[200px] flex items-center justify-center hover:bg-gray-400 transition-colors mb-4"
|
|
||||||
title={sortedZones[0].model_path ? `Открыть 3D модель зоны: ${sortedZones[0].name}` : 'Модель зоны отсутствует'}
|
|
||||||
disabled={!sortedZones[0].model_path}
|
|
||||||
>
|
|
||||||
<div className="w-full h-full bg-gray-200 rounded flex flex-col items-center justify-center relative">
|
|
||||||
{/* Всегда рендерим с разрешённой заглушкой */}
|
|
||||||
<Image
|
|
||||||
src={resolveImageSrc(sortedZones[0].image_path)}
|
|
||||||
alt={sortedZones[0].name || 'Зона'}
|
|
||||||
width={200}
|
|
||||||
height={200}
|
|
||||||
className="max-w-full max-h-full object-contain opacity-50"
|
|
||||||
style={{ height: 'auto' }}
|
|
||||||
onError={(e) => {
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
target.src = '/images/test_image.png';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-2 left-2 right-2 text-sm text-gray-700 bg-white/80 rounded px-3 py-1 truncate">
|
|
||||||
{sortedZones[0].name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{sortedZones.length > 1 && (
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{sortedZones.slice(1).map((zone: Zone, idx: number) => (
|
|
||||||
<button
|
|
||||||
key={`zone-${zone.id}-${idx}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => zone.model_path ? handleSelectModel(zone.model_path) : null}
|
|
||||||
className="relative flex-1 bg-gray-300 rounded-lg h-[120px] flex items-center justify-center hover:bg-gray-400 transition-colors"
|
|
||||||
title={zone.model_path ? `Открыть 3D модель зоны: ${zone.name}` : 'Модель зоны отсутствует'}
|
|
||||||
disabled={!zone.model_path}
|
|
||||||
>
|
|
||||||
<div className="w-full h-full bg-gray-200 rounded flex flex-col items-center justify-center relative">
|
|
||||||
<Image
|
|
||||||
src={resolveImageSrc(zone.image_path)}
|
|
||||||
alt={zone.name || 'Зона'}
|
|
||||||
width={120}
|
|
||||||
height={120}
|
|
||||||
className="max-w-full max-h-full object-contain opacity-50"
|
|
||||||
style={{ height: 'auto' }}
|
|
||||||
onError={(e) => {
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
target.src = '/images/test_image.png';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-1 left-1 right-1 text-[10px] text-gray-700 bg-white/70 rounded px-2 py-0.5 truncate">
|
|
||||||
{zone.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{sortedZones.length === 0 && !zonesError && !zonesLoading && (
|
|
||||||
<div className="col-span-2">
|
|
||||||
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
|
|
||||||
Зоны не найдены для выбранного объекта. Проверьте параметр objectId в API /api/get-zones.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Monitoring;
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
import React, { useEffect, useCallback, useState } from 'react';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import useNavigationStore from '@/app/store/navigationStore';
|
|
||||||
import type { Zone } from '@/app/types';
|
|
||||||
|
|
||||||
// Безопасный резолвер src изображения, чтобы избежать ошибок Invalid URL в next/image
|
|
||||||
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';
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
// Если путь содержит public/images (даже абсолютный путь ФС), переводим в относительный путь сайта
|
|
||||||
if (/\/public\/images\//i.test(s)) {
|
|
||||||
const parts = s.split(/\/public\/images\//i);
|
|
||||||
const rel = parts[1] || '';
|
|
||||||
return `/images/${rel}`;
|
|
||||||
}
|
|
||||||
// Абсолютные URL и пути, относительные к сайту
|
|
||||||
if (s.startsWith('http://') || s.startsWith('https://')) return s;
|
|
||||||
if (s.startsWith('/')) return s;
|
|
||||||
// Нормализуем относительные имена ресурсов до путей сайта под /images
|
|
||||||
// Убираем ведущий 'public/', если он присутствует
|
|
||||||
s = s.replace(/^public\//i, '');
|
|
||||||
return s.startsWith('images/') ? `/${s}` : `/images/${s}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MonitoringProps {
|
|
||||||
onClose?: () => void;
|
|
||||||
onSelectModel?: (modelPath: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|
||||||
const { currentObject, currentZones, zonesLoading, zonesError, loadZones, currentModelPath } = useNavigationStore();
|
|
||||||
const [autoSelectedRef, setAutoSelectedRef] = React.useState(false);
|
|
||||||
|
|
||||||
const handleSelectModel = useCallback((modelPath: string) => {
|
|
||||||
console.log(`[Monitoring] Model selected: ${modelPath}`);
|
|
||||||
console.log(`[Monitoring] onSelectModel callback:`, onSelectModel);
|
|
||||||
onSelectModel?.(modelPath);
|
|
||||||
|
|
||||||
// Автоматически закрываем панель после выбора модели
|
|
||||||
if (onClose) {
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('[Monitoring] Auto-closing after model selection');
|
|
||||||
onClose();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}, [onSelectModel, onClose]);
|
|
||||||
|
|
||||||
// Загрузка зон при изменении объекта
|
|
||||||
useEffect(() => {
|
|
||||||
const objId = currentObject?.id;
|
|
||||||
if (!objId) return;
|
|
||||||
console.log(`[Monitoring] Loading zones for object ID: ${objId}`);
|
|
||||||
loadZones(objId);
|
|
||||||
}, [currentObject?.id, loadZones]);
|
|
||||||
|
|
||||||
// Автоматический выбор модели, если currentModelPath установлен (переход из таблицы)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentModelPath || autoSelectedRef || !onSelectModel) return;
|
|
||||||
|
|
||||||
console.log('[Monitoring] Auto-selecting model from currentModelPath:', currentModelPath);
|
|
||||||
setAutoSelectedRef(true);
|
|
||||||
onSelectModel(currentModelPath);
|
|
||||||
}, [currentModelPath, autoSelectedRef, onSelectModel]);
|
|
||||||
|
|
||||||
// Сброс флага при изменении объекта
|
|
||||||
useEffect(() => {
|
|
||||||
setAutoSelectedRef(false);
|
|
||||||
}, [currentObject?.id])
|
|
||||||
|
|
||||||
const sortedZones: Zone[] = React.useMemo(() => {
|
|
||||||
const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
|
||||||
const oa = typeof a.order === 'number' ? a.order : 0;
|
|
||||||
const ob = typeof b.order === 'number' ? b.order : 0;
|
|
||||||
if (oa !== ob) return oa - ob;
|
|
||||||
return (a.name || '').localeCompare(b.name || '');
|
|
||||||
});
|
|
||||||
console.log(`[Monitoring] Sorted zones:`, sorted.map(z => ({ id: z.id, name: z.name, model_path: z.model_path })));
|
|
||||||
return sorted;
|
|
||||||
}, [currentZones]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-white text-2xl font-semibold">Зоны мониторинга</h2>
|
|
||||||
{onClose && (
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-white hover:text-gray-300 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* UI зон */}
|
|
||||||
{zonesError && (
|
|
||||||
<div className="rounded-lg bg-red-600/20 border border-red-600/40 text-red-200 text-xs px-3 py-2">
|
|
||||||
Ошибка загрузки зон: {zonesError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{zonesLoading && (
|
|
||||||
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
|
|
||||||
Загрузка зон...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sortedZones.length > 0 && (
|
|
||||||
<>
|
|
||||||
{sortedZones[0] && (
|
|
||||||
<button
|
|
||||||
key={`zone-${sortedZones[0].id}-panorama`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => sortedZones[0].model_path ? handleSelectModel(sortedZones[0].model_path) : null}
|
|
||||||
className="group w-full bg-gradient-to-br from-cyan-600/20 via-blue-600/20 to-purple-600/20 rounded-xl h-[220px] flex items-center justify-center hover:from-cyan-500/30 hover:via-blue-500/30 hover:to-purple-500/30 transition-all duration-300 mb-4 border border-cyan-400/30 hover:border-cyan-400/60 shadow-lg shadow-cyan-500/10 hover:shadow-cyan-500/30 overflow-hidden relative backdrop-blur-sm"
|
|
||||||
title={sortedZones[0].model_path ? `Открыть 3D модель зоны: ${sortedZones[0].name}` : 'Модель зоны отсутствует'}
|
|
||||||
disabled={!sortedZones[0].model_path}
|
|
||||||
>
|
|
||||||
{/* Градиентный фон при наведении */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-cyan-400/10 via-blue-400/10 to-purple-400/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
|
||||||
{/* Анимированный градиент по краям */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-cyan-400/5 to-transparent animate-pulse"></div>
|
|
||||||
|
|
||||||
<div className="w-full h-full rounded-lg flex flex-col items-center justify-center relative">
|
|
||||||
<Image
|
|
||||||
src={resolveImageSrc(sortedZones[0].image_path)}
|
|
||||||
alt={sortedZones[0].name || 'Зона'}
|
|
||||||
width={200}
|
|
||||||
height={200}
|
|
||||||
className="max-w-full max-h-full object-contain opacity-60 group-hover:opacity-80 transition-opacity duration-300"
|
|
||||||
style={{ height: 'auto' }}
|
|
||||||
onError={(e) => {
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
target.src = '/images/test_image.png';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-3 left-3 right-3 text-sm font-medium text-white bg-gradient-to-r from-cyan-600/80 via-blue-600/80 to-purple-600/80 backdrop-blur-md rounded-lg px-4 py-2 truncate border border-cyan-400/40 shadow-lg shadow-cyan-500/20">
|
|
||||||
{sortedZones[0].name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{sortedZones.length > 1 && (
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{sortedZones.slice(1).map((zone: Zone, idx: number) => (
|
|
||||||
<button
|
|
||||||
key={`zone-${zone.id}-${idx}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => zone.model_path ? handleSelectModel(zone.model_path) : null}
|
|
||||||
className="group relative flex-1 bg-gradient-to-br from-emerald-600/20 via-teal-600/20 to-cyan-600/20 rounded-xl h-[140px] flex items-center justify-center hover:from-emerald-500/30 hover:via-teal-500/30 hover:to-cyan-500/30 transition-all duration-300 border border-emerald-400/30 hover:border-emerald-400/60 shadow-lg shadow-emerald-500/10 hover:shadow-emerald-500/30 overflow-hidden backdrop-blur-sm"
|
|
||||||
title={zone.model_path ? `Открыть 3D модель зоны: ${zone.name}` : 'Модель зоны отсутствует'}
|
|
||||||
disabled={!zone.model_path}
|
|
||||||
>
|
|
||||||
{/* Градиентный фон при наведении */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-400/10 via-teal-400/10 to-cyan-400/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
|
||||||
{/* Анимированный градиент по краям */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-emerald-400/5 to-transparent animate-pulse"></div>
|
|
||||||
|
|
||||||
<div className="w-full h-full rounded-lg flex flex-col items-center justify-center relative">
|
|
||||||
<Image
|
|
||||||
src={resolveImageSrc(zone.image_path)}
|
|
||||||
alt={zone.name || 'Зона'}
|
|
||||||
width={120}
|
|
||||||
height={120}
|
|
||||||
className="max-w-full max-h-full object-contain opacity-60 group-hover:opacity-80 transition-opacity duration-300"
|
|
||||||
style={{ height: 'auto' }}
|
|
||||||
onError={(e) => {
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
target.src = '/images/test_image.png';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-2 left-2 right-2 text-[11px] font-medium text-white bg-gradient-to-r from-emerald-600/80 via-teal-600/80 to-cyan-600/80 backdrop-blur-md rounded-md px-2 py-1 truncate border border-emerald-400/40 shadow-md shadow-emerald-500/20">
|
|
||||||
{zone.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{sortedZones.length === 0 && !zonesError && !zonesLoading && (
|
|
||||||
<div className="col-span-2">
|
|
||||||
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
|
|
||||||
Зоны не найдены для выбранного объекта. Проверьте параметр objectId в API /api/get-zones.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Monitoring;
|
|
||||||
Reference in New Issue
Block a user