переделана логика загрузки модели, замена страницы Объекты на другой внешний вид, добавление в меню пункта Объекты

This commit is contained in:
2026-02-03 19:00:02 +03:00
parent 458222817e
commit 5e58f6ef76
24 changed files with 3514 additions and 1161 deletions

View File

@@ -115,6 +115,7 @@ const NavigationPage: React.FC = () => {
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) {
@@ -347,27 +348,8 @@ const NavigationPage: React.FC = () => {
);
if (detector) {
if (showFloorNavigation || showListOfDetectors) {
// Всегда показываем меню детектора для всех датчиков
handleDetectorMenuClick(detector);
} else if (detector.notifications && detector.notifications.length > 0) {
const sortedNotifications = [...detector.notifications].sort((a, b) => {
const priorityOrder: { [key: string]: number } = { critical: 0, warning: 1, info: 2 };
return priorityOrder[a.priority.toLowerCase()] - priorityOrder[b.priority.toLowerCase()];
});
const notification = sortedNotifications[0];
const alert: AlertType = {
...notification,
detector_id: detector.detector_id,
detector_name: detector.name,
location: detector.location,
object: detector.object,
status: detector.status,
type: notification.type || 'info',
};
handleAlertClick(alert);
} else {
handleDetectorMenuClick(detector);
}
} else {
setFocusedSensorId(null);
closeDetectorMenu();

View File

@@ -0,0 +1,636 @@
'use client'
import React, { useEffect, useCallback, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Image from 'next/image'
import Sidebar from '../../../components/ui/Sidebar'
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
import useNavigationStore from '../../store/navigationStore'
import Monitoring from '../../../components/navigation/Monitoring'
import FloorNavigation from '../../../components/navigation/FloorNavigation'
import DetectorMenu from '../../../components/navigation/DetectorMenu'
import ListOfDetectors from '../../../components/navigation/ListOfDetectors'
import Sensors from '../../../components/navigation/Sensors'
import AlertMenu from '../../../components/navigation/AlertMenu'
import Notifications from '../../../components/notifications/Notifications'
import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo'
import dynamic from 'next/dynamic'
import type { ModelViewerProps } from '../../../components/model/ModelViewer'
import * as statusColors from '../../../lib/statusColors'
const ModelViewer = dynamic<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

View File

@@ -0,0 +1,620 @@
'use client'
import React, { useEffect, useCallback, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Image from 'next/image'
import Sidebar from '../../../components/ui/Sidebar'
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
import useNavigationStore from '../../store/navigationStore'
import Monitoring from '../../../components/navigation/Monitoring'
import FloorNavigation from '../../../components/navigation/FloorNavigation'
import DetectorMenu from '../../../components/navigation/DetectorMenu'
import ListOfDetectors from '../../../components/navigation/ListOfDetectors'
import Sensors from '../../../components/navigation/Sensors'
import AlertMenu from '../../../components/navigation/AlertMenu'
import Notifications from '../../../components/notifications/Notifications'
import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo'
import dynamic from 'next/dynamic'
import type { ModelViewerProps } from '../../../components/model/ModelViewer'
import * as statusColors from '../../../lib/statusColors'
const ModelViewer = dynamic<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 [showStats, setShowStats] = 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}
showStats={showStats}
onToggleStats={() => setShowStats(!showStats)}
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

View File

@@ -3,7 +3,6 @@
import React, { useState, useEffect } from 'react'
import ObjectGallery from '../../../components/objects/ObjectGallery'
import { ObjectData } from '../../../components/objects/ObjectCard'
import Sidebar from '../../../components/ui/Sidebar'
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
@@ -13,7 +12,6 @@ const transformRawToObjectData = (raw: any): ObjectData => {
const rawId = raw?.id ?? raw?.object_id ?? raw?.uuid ?? raw?.name
const object_id = typeof rawId === 'number' ? `object_${rawId}` : String(rawId ?? '')
// Если объект имеет числовой идентификатор, возвращаем его в виде строки с префиксом 'object_'
const deriveTitle = (): string => {
const t = (raw?.title || '').toString().trim()
if (t) return t
@@ -24,7 +22,6 @@ const transformRawToObjectData = (raw: any): ObjectData => {
if (typeof numMatch === 'number' && !Number.isNaN(numMatch)) {
return `Объект ${numMatch}`
}
// Если объект не имеет числовой идентификатор, возвращаем его строковый идентификатор
return idStr ? `Объект ${idStr}` : `Объект ${object_id}`
}
@@ -79,7 +76,6 @@ const ObjectsPage: React.FC = () => {
} else if (Array.isArray(data?.objects)) {
rawObjectsArray = data.objects
} else if (data && typeof data === 'object') {
// если приходит как map { id: obj }
rawObjectsArray = Object.values(data)
}
@@ -103,8 +99,9 @@ const ObjectsPage: React.FC = () => {
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-[#0e111a]">
<div className="text-center">
<div className="relative flex items-center justify-center min-h-screen overflow-hidden">
<AnimatedBackground />
<div className="relative z-10 text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-white">Загрузка объектов...</p>
</div>
@@ -114,8 +111,9 @@ const ObjectsPage: React.FC = () => {
if (error) {
return (
<div className="flex items-center justify-center min-h-screen bg-[#0e111a]">
<div className="text-center">
<div className="relative flex items-center justify-center min-h-screen overflow-hidden">
<AnimatedBackground />
<div className="relative z-10 text-center">
<div className="text-red-500 mb-4">
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
@@ -130,51 +128,37 @@ const ObjectsPage: React.FC = () => {
}
return (
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
<div className="relative flex flex-col h-screen overflow-hidden">
<AnimatedBackground />
<div className="relative z-20">
<Sidebar activeItem={null} />
</div>
<div className="relative z-10 flex-1 overflow-y-auto">
{/* Приветствие и информация */}
<div className="min-h-screen flex flex-col items-center justify-start pt-20 px-8">
{/* Логотип */}
<div className="mb-8 flex justify-center">
<div className="relative w-64 h-20">
{/* Header */}
<header className="relative z-20 bg-[#161824]/80 backdrop-blur-sm border-b border-blue-500/20">
<div className="flex items-center justify-between px-6 py-3">
<div className="flex items-center gap-4">
<Image
src="/icons/logo.png"
alt="AerBIM Logo"
width={438}
height={60}
width={150}
height={40}
className="object-contain"
/>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-400">
Версия: <span className="text-cyan-400 font-semibold">3.0.0</span>
</span>
</div>
{/* Приветствие */}
<h1 className="text-5xl font-bold text-white mb-4 text-center animate-fade-in" style={{ fontFamily: 'Inter, sans-serif' }}>
Добро пожаловать!
</h1>
<p className="text-xl text-gray-300 mb-8 text-center animate-fade-in" style={{ animationDelay: '0.2s', fontFamily: 'Inter, sans-serif' }}>
Система мониторинга AerBIM Monitor
</p>
{/* Версия системы */}
<div className="mb-16 p-4 rounded-lg bg-gradient-to-r from-blue-500/10 to-cyan-500/10 border border-blue-500/20 inline-block animate-fade-in" style={{ animationDelay: '0.4s' }}>
<p className="text-sm text-gray-400" style={{ fontFamily: 'Inter, sans-serif' }}>
Версия системы: <span className="text-cyan-400 font-semibold">3.0.0</span>
</p>
</div>
</header>
{/* Блок с галереей объектов */}
<div className="w-full max-w-6xl p-8 rounded-xl bg-gradient-to-r from-blue-500/10 to-cyan-500/10 border border-blue-500/20 backdrop-blur-sm">
{/* Заголовок галереи */}
<h2 className="text-3xl font-bold text-white mb-8 text-center" style={{ fontFamily: 'Inter, sans-serif' }}>
{/* Main Content */}
<div className="relative z-10 flex-1 overflow-y-auto">
<div className="max-w-7xl mx-auto px-6 py-6">
{/* Заголовок */}
<h1 className="text-2xl font-bold text-white mb-6">
Выберите объект для работы
</h2>
</h1>
{/* Галерея объектов */}
<ObjectGallery
@@ -186,24 +170,6 @@ const ObjectsPage: React.FC = () => {
</div>
</div>
</div>
<style jsx>{`
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.8s ease-out forwards;
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,208 @@
'use client'
import React, { useState, useEffect } from 'react'
import ObjectGallery from '../../../components/objects/ObjectGallery'
import { ObjectData } from '../../../components/objects/ObjectCard'
import Sidebar from '../../../components/ui/Sidebar'
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
// Универсальная функция для преобразования объекта из бэкенда в ObjectData
const transformRawToObjectData = (raw: any): ObjectData => {
const rawId = raw?.id ?? raw?.object_id ?? raw?.uuid ?? raw?.name
const object_id = typeof rawId === 'number' ? `object_${rawId}` : String(rawId ?? '')
// Если объект имеет числовой идентификатор, возвращаем его в виде строки с префиксом 'object_'
const deriveTitle = (): string => {
const t = (raw?.title || '').toString().trim()
if (t) return t
const idStr = String(rawId ?? '').toString()
const numMatch = typeof rawId === 'number'
? rawId
: (() => { const m = idStr.match(/\d+/); return m ? Number(m[0]) : undefined })()
if (typeof numMatch === 'number' && !Number.isNaN(numMatch)) {
return `Объект ${numMatch}`
}
// Если объект не имеет числовой идентификатор, возвращаем его строковый идентификатор
return idStr ? `Объект ${idStr}` : `Объект ${object_id}`
}
return {
object_id,
title: deriveTitle(),
description: raw?.description ?? `Описание объекта ${raw?.title ?? object_id}`,
image: raw?.image ?? null,
location: raw?.location ?? raw?.address ?? 'Не указано',
floors: Number(raw?.floors ?? 0),
area: String(raw?.area ?? ''),
type: raw?.type ?? 'object',
status: raw?.status ?? 'active',
}
}
const ObjectsPage: React.FC = () => {
const [objects, setObjects] = useState<ObjectData[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
const router = useRouter()
useEffect(() => {
const loadData = async () => {
setLoading(true)
setError(null)
try {
const url = '/api/get-objects'
const res = await fetch(url, { cache: 'no-store' })
const payloadText = await res.text()
let payload: any
try { payload = JSON.parse(payloadText) } catch { payload = payloadText }
console.log('[ObjectsPage] GET /api/get-objects', { status: res.status, payload })
if (!res.ok) {
const errorMessage = typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить данные объектов')
if (errorMessage.includes('Authentication required') || res.status === 401) {
console.log('[ObjectsPage] Authentication required, redirecting to login')
router.push('/login')
return
}
throw new Error(errorMessage)
}
const data = (payload?.data ?? payload) as any
let rawObjectsArray: any[] = []
if (Array.isArray(data)) {
rawObjectsArray = data
} else if (Array.isArray(data?.objects)) {
rawObjectsArray = data.objects
} else if (data && typeof data === 'object') {
// если приходит как map { id: obj }
rawObjectsArray = Object.values(data)
}
const transformedObjects = rawObjectsArray.map(transformRawToObjectData)
setObjects(transformedObjects)
} catch (err: any) {
console.error('Ошибка при загрузке данных объектов:', err)
setError(err?.message || 'Произошла неизвестная ошибка')
} finally {
setLoading(false)
}
}
loadData()
}, [router])
const handleObjectSelect = (objectId: string) => {
console.log('Object selected:', objectId)
setSelectedObjectId(objectId)
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-[#0e111a]">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-white">Загрузка объектов...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center min-h-screen bg-[#0e111a]">
<div className="text-center">
<div className="text-red-500 mb-4">
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
</div>
<h3 className="text-lg font-medium text-white mb-2">Ошибка загрузки данных</h3>
<p className="text-[#71717a] mb-4">{error}</p>
<p className="text-sm text-gray-500">Если проблема повторяется, обратитесь к администратору</p>
</div>
</div>
)
}
return (
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
<AnimatedBackground />
{/* Sidebar скрыт на странице выбора объектов */}
<div className="relative z-10 flex-1 overflow-y-auto">
{/* Приветствие и информация */}
<div className="min-h-screen flex flex-col items-center justify-start pt-8 px-8">
{/* Логотип */}
<div className="mb-4 flex justify-center">
<div className="relative w-64 h-20">
<Image
src="/icons/logo.png"
alt="AerBIM Logo"
width={438}
height={60}
className="object-contain"
/>
</div>
</div>
{/* Приветствие */}
<h1 className="text-5xl font-bold text-white mb-4 text-center animate-fade-in" style={{ fontFamily: 'Inter, sans-serif' }}>
Добро пожаловать!
</h1>
<p className="text-xl text-gray-300 mb-8 text-center animate-fade-in" style={{ animationDelay: '0.2s', fontFamily: 'Inter, sans-serif' }}>
Система мониторинга AerBIM Monitor
</p>
{/* Версия системы */}
<div className="mb-6 p-3 rounded-lg bg-gradient-to-r from-blue-500/10 to-cyan-500/10 border border-blue-500/20 inline-block">
<p className="text-sm text-gray-400" style={{ fontFamily: 'Inter, sans-serif' }}>
Версия системы: <span className="text-cyan-400 font-semibold">3.0.0</span>
</p>
</div>
{/* Блок с галереей объектов */}
<div className="w-full max-w-6xl p-4 rounded-xl bg-gradient-to-r from-blue-500/10 to-cyan-500/10 border border-blue-500/20 backdrop-blur-sm">
{/* Заголовок галереи */}
<h2 className="text-2xl font-bold text-white mb-4 text-center" style={{ fontFamily: 'Inter, sans-serif' }}>
Выберите объект для работы
</h2>
{/* Галерея объектов */}
<ObjectGallery
objects={objects}
title=""
onObjectSelect={handleObjectSelect}
selectedObjectId={selectedObjectId}
/>
</div>
</div>
</div>
<style jsx>{`
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.8s ease-out forwards;
}
`}</style>
</div>
)
}
export default ObjectsPage

View File

@@ -103,7 +103,6 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Статус</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Местоположение</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Приоритет</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Подтверждено</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Время</th>
@@ -133,9 +132,6 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
<td style={interRegularStyle} className="py-4 text-sm text-white">
{item.message}
</td>
<td style={interRegularStyle} className="py-4 text-sm text-white">
{item.location || '-'}
</td>
<td className="py-4">
<span
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
@@ -194,7 +190,7 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
))}
{filteredAlerts.length === 0 && (
<tr>
<td colSpan={8} className="py-8 text-center text-gray-400">
<td colSpan={7} className="py-8 text-center text-gray-400">
Записей не найдено
</td>
</tr>

View File

@@ -0,0 +1,210 @@
import React, { useState, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import useNavigationStore from '../../app/store/navigationStore'
import * as statusColors from '../../lib/statusColors'
interface AlertItem {
id: number
type: string
message: string
timestamp: string
acknowledged: boolean
priority: string
detector_id?: string
detector_name?: string
location?: string
object?: string
serial_number?: string
floor?: number
}
interface AlertsListProps {
alerts: AlertItem[]
onAcknowledgeToggle: (alertId: number) => void
initialSearchTerm?: string
}
const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, initialSearchTerm = '' }) => {
const router = useRouter()
const { navigateToSensor } = useNavigationStore()
const [searchTerm, setSearchTerm] = useState(initialSearchTerm)
const filteredAlerts = useMemo(() => {
return alerts.filter(alert => {
const matchesSearch = searchTerm === '' || alert.detector_id?.toString() === searchTerm
return matchesSearch
})
}, [alerts, searchTerm])
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
const getStatusColor = (type: string) => {
switch (type) {
case 'critical':
return statusColors.STATUS_COLOR_CRITICAL
case 'warning':
return statusColors.STATUS_COLOR_WARNING
case 'info':
return statusColors.STATUS_COLOR_NORMAL
default:
return statusColors.STATUS_COLOR_UNKNOWN
}
}
const handleGoTo3D = async (alert: AlertItem, viewType: 'building' | 'floor') => {
// Используем доступные идентификаторы датчика
const sensorId = alert.serial_number || alert.detector_name || alert.detector_id
if (!sensorId) {
console.warn('[AlertsList] Alert missing sensor identifier:', alert)
return
}
const sensorSerialNumber = await navigateToSensor(
sensorId,
alert.floor || null,
viewType
)
if (sensorSerialNumber) {
router.push(`/navigation?focusSensorId=${encodeURIComponent(sensorSerialNumber)}`)
}
}
return (
<div className="space-y-6">
{/* Поиск */}
<div className="flex items-center justify-end gap-4 mb-6">
<div className="relative">
<input
type="text"
placeholder="Поиск по ID детектора..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64"
/>
<svg className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
{/* Таблица алертов */}
<div className="bg-[#161824] rounded-[20px] p-6">
<div className="mb-4 flex items-center justify-between">
<h2 style={interSemiboldStyle} className="text-xl text-white">История тревог</h2>
<span style={interRegularStyle} className="text-sm text-gray-400">Всего: {filteredAlerts.length}</span>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-700">
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Статус</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Местоположение</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Приоритет</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Подтверждено</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Время</th>
<th style={interSemiboldStyle} className="text-center text-white text-sm py-3">3D Вид</th>
</tr>
</thead>
<tbody>
{filteredAlerts.map((item) => (
<tr key={item.id} className="border-b border-gray-700 hover:bg-gray-800/50 transition-colors">
<td style={interRegularStyle} className="py-4 text-sm text-white">
<div>{item.detector_name || 'Детектор'}</div>
{item.detector_id ? (
<div className="text-gray-400">ID: {item.detector_id}</div>
) : null}
</td>
<td className="py-4">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: getStatusColor(item.type) }}
></div>
<span style={interRegularStyle} className="text-sm text-gray-300">
{item.type === 'critical' ? 'Критический' : item.type === 'warning' ? 'Предупреждение' : 'Информация'}
</span>
</div>
</td>
<td style={interRegularStyle} className="py-4 text-sm text-white">
{item.message}
</td>
<td style={interRegularStyle} className="py-4 text-sm text-white">
{item.location || '-'}
</td>
<td className="py-4">
<span
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
style={{
backgroundColor:
item.priority === 'high'
? statusColors.STATUS_COLOR_CRITICAL
: item.priority === 'medium'
? statusColors.STATUS_COLOR_WARNING
: statusColors.STATUS_COLOR_NORMAL,
}}
>
{item.priority === 'high' ? 'Высокий' : item.priority === 'medium' ? 'Средний' : 'Низкий'}
</span>
</td>
<td className="py-4">
<span style={interRegularStyle} className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs ${
item.acknowledged ? 'bg-green-600/20 text-green-300 ring-1 ring-green-600/40' : 'bg-red-600/20 text-red-300 ring-1 ring-red-600/40'
}`}>
{item.acknowledged ? 'Да' : 'Нет'}
</span>
<button
onClick={() => onAcknowledgeToggle(item.id)}
style={interRegularStyle}
className="ml-2 inline-flex items-center px-2 py-1 rounded text-xs bg-[#2a2e3e] text-white hover:bg-[#353a4d]"
>
{item.acknowledged ? 'Снять' : 'Подтвердить'}
</button>
</td>
<td style={interRegularStyle} className="py-4 text-sm text-gray-300">
{new Date(item.timestamp).toLocaleString('ru-RU')}
</td>
<td className="py-4">
<div className="flex items-center justify-center gap-2">
<button
onClick={() => handleGoTo3D(item, 'building')}
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
title="Показать на общей модели"
>
<img src="/icons/Building3D.png" alt="Здание" className="w-5 h-5 opacity-70 group-hover:opacity-100" />
</button>
<button
onClick={() => handleGoTo3D(item, 'floor')}
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
title="Показать на этаже"
>
<img
src="/icons/Floor3D.png"
alt="Этаж"
className="w-5 h-5 opacity-70 group-hover:opacity-100"
/>
</button>
</div>
</td>
</tr>
))}
{filteredAlerts.length === 0 && (
<tr>
<td colSpan={8} className="py-8 text-center text-gray-400">
Записей не найдено
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
export default AlertsList

View File

@@ -151,28 +151,6 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
<table className="w-full">
<thead>
<tr className="border-b border-gray-700">
<th className="text-left text-white font-medium py-3 w-12">
<input
type="checkbox"
checked={selectedDetectors.length === currentDetectors.length && currentDetectors.length > 0}
onChange={(e) => {
if (e.target.checked) {
currentDetectors.forEach(detector => {
if (!selectedDetectors.includes(detector.detector_id)) {
onDetectorSelect(detector.detector_id, true)
}
})
} else {
currentDetectors.forEach(detector => {
if (selectedDetectors.includes(detector.detector_id)) {
onDetectorSelect(detector.detector_id, false)
}
})
}
}}
className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
/>
</th>
<th className="text-left text-white font-medium py-3">Детектор</th>
<th className="text-left text-white font-medium py-3">Статус</th>
<th className="text-left text-white font-medium py-3">Местоположение</th>
@@ -185,14 +163,6 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
return (
<tr key={detector.detector_id} className="border-b border-gray-800">
<td className="py-3">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => onDetectorSelect(detector.detector_id, e.target.checked)}
className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
/>
</td>
<td className="py-3 text-white text-sm">{detector.name}</td>
<td className="py-3">
<div className="flex items-center gap-2">

View File

@@ -0,0 +1,329 @@
'use client'
import React, { useState, useEffect } from 'react'
import * as statusColors from '../../lib/statusColors'
interface Detector {
detector_id: number
name: string
location: string
status: string
object: string
floor: number
checked: boolean
}
interface RawDetector {
detector_id: number
name: string
object: string
status: string
type: string
detector_type: string
location: string
floor: number
notifications: Array<{
id: number
type: string
message: string
timestamp: string
acknowledged: boolean
priority: string
}>
}
interface DetectorListProps {
objectId?: string
selectedDetectors: number[]
onDetectorSelect: (detectorId: number, selected: boolean) => void
initialSearchTerm?: string
}
// Функция для генерации умного диапазона страниц
function getPaginationRange(currentPage: number, totalPages: number): (number | string)[] {
if (totalPages <= 7) {
// Если страниц мало - показываем все
return Array.from({ length: totalPages }, (_, i) => i + 1)
}
// Всегда показываем первые 3 и последние 3
const start = [1, 2, 3]
const end = [totalPages - 2, totalPages - 1, totalPages]
if (currentPage <= 4) {
// Начало: 1 2 3 4 5 ... 11 12 13
return [1, 2, 3, 4, 5, '...', ...end]
}
if (currentPage >= totalPages - 3) {
// Конец: 1 2 3 ... 9 10 11 12 13
return [...start, '...', totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages]
}
// Середина: 1 2 3 ... 6 7 8 ... 11 12 13
return [...start, '...', currentPage - 1, currentPage, currentPage + 1, '...', ...end]
}
const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors, onDetectorSelect, initialSearchTerm = '' }) => {
const [detectors, setDetectors] = useState<Detector[]>([])
const [searchTerm, setSearchTerm] = useState<string>(initialSearchTerm)
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 20
useEffect(() => {
const loadDetectors = async () => {
try {
const res = await fetch('/api/get-detectors', { cache: 'no-store' })
if (!res.ok) return
const payload = await res.json()
const detectorsData: Record<string, RawDetector> = payload?.data?.detectors ?? {}
const rawArray: RawDetector[] = Object.values(detectorsData).filter(
(detector) => (objectId ? detector.object === objectId : true)
)
const normalized: Detector[] = rawArray.map((d) => ({
detector_id: d.detector_id,
name: d.name,
location: d.location,
status: d.status,
object: d.object,
floor: d.floor,
checked: false,
}))
console.log('[DetectorList] Payload:', payload)
setDetectors(normalized)
} catch (e) {
console.error('Failed to load detectors:', e)
}
}
loadDetectors()
}, [objectId])
const filteredDetectors = detectors.filter(detector => {
const matchesSearch = searchTerm === '' || detector.detector_id.toString() === searchTerm
return matchesSearch
})
// Сброс на первую страницу при изменении поиска
useEffect(() => {
setCurrentPage(1)
}, [searchTerm])
// Пагинация
const totalPages = Math.ceil(filteredDetectors.length / itemsPerPage)
const startIndex = (currentPage - 1) * itemsPerPage
const endIndex = startIndex + itemsPerPage
const currentDetectors = filteredDetectors.slice(startIndex, endIndex)
const paginationRange = getPaginationRange(currentPage, totalPages)
const handlePageChange = (page: number) => {
setCurrentPage(page)
// Скролл наверх таблицы
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
</div>
<div className="flex items-center gap-3">
<div className="relative">
<input
type="text"
placeholder="Поиск по ID детектора..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64 text-sm font-medium"
style={{ fontFamily: 'Inter, sans-serif' }}
/>
<svg className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
</div>
{/* Таблица детекторов */}
<div className="bg-[#161824] rounded-[20px] p-6">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-700">
<th className="text-left text-white font-medium py-3 w-12">
<input
type="checkbox"
checked={selectedDetectors.length === currentDetectors.length && currentDetectors.length > 0}
onChange={(e) => {
if (e.target.checked) {
currentDetectors.forEach(detector => {
if (!selectedDetectors.includes(detector.detector_id)) {
onDetectorSelect(detector.detector_id, true)
}
})
} else {
currentDetectors.forEach(detector => {
if (selectedDetectors.includes(detector.detector_id)) {
onDetectorSelect(detector.detector_id, false)
}
})
}
}}
className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
/>
</th>
<th className="text-left text-white font-medium py-3">Детектор</th>
<th className="text-left text-white font-medium py-3">Статус</th>
<th className="text-left text-white font-medium py-3">Местоположение</th>
<th className="text-left text-white font-medium py-3">Проверен</th>
</tr>
</thead>
<tbody>
{currentDetectors.map((detector) => {
const isSelected = selectedDetectors.includes(detector.detector_id)
return (
<tr key={detector.detector_id} className="border-b border-gray-800">
<td className="py-3">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => onDetectorSelect(detector.detector_id, e.target.checked)}
className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
/>
</td>
<td className="py-3 text-white text-sm">{detector.name}</td>
<td className="py-3">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: detector.status }}
></div>
<span className="text-sm text-gray-300">
{detector.status === statusColors.STATUS_COLOR_CRITICAL
? 'Критическое'
: detector.status === statusColors.STATUS_COLOR_WARNING
? 'Предупреждение'
: 'Норма'}
</span>
</div>
</td>
<td className="py-3 text-gray-400 text-sm">{detector.location}</td>
<td className="py-3">
{detector.checked ? (
<div className="flex items-center gap-1">
<svg className="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span className="text-sm text-green-500">Да</span>
</div>
) : (
<span className="text-sm text-gray-500">Нет</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Пагинация */}
{totalPages > 1 && (
<div className="mt-6 flex flex-col items-center gap-4">
{/* Кнопки пагинации */}
<div className="flex items-center gap-2">
{/* Кнопка "Предыдущая" */}
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
currentPage === 1
? 'text-gray-600 cursor-not-allowed'
: 'text-gray-300 hover:text-white hover:bg-[#1E293B]'
}`}
>
← Предыдущая
</button>
{/* Номера страниц */}
{paginationRange.map((page, index) => {
if (page === '...') {
return (
<span key={`ellipsis-${index}`} className="px-3 py-2 text-gray-500">
...
</span>
)
}
const pageNumber = page as number
const isActive = pageNumber === currentPage
return (
<button
key={pageNumber}
onClick={() => handlePageChange(pageNumber)}
className={`min-w-[40px] px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-blue-500 text-white'
: 'text-gray-300 hover:text-white hover:bg-[#1E293B]'
}`}
>
{pageNumber}
</button>
)
})}
{/* Кнопка "Следующая" */}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
currentPage === totalPages
? 'text-gray-600 cursor-not-allowed'
: 'text-gray-300 hover:text-white hover:bg-[#1E293B]'
}`}
>
Следующая →
</button>
</div>
{/* Счётчик */}
<div className="text-sm text-gray-400">
Показано {startIndex + 1}-{Math.min(endIndex, filteredDetectors.length)} из {filteredDetectors.length} датчиков
</div>
</div>
)}
</div>
{/* Статы детекторов */}
<div className="mt-6 grid grid-cols-4 gap-4">
<div className="bg-[#161824] p-4 rounded-lg">
<div className="text-2xl font-bold text-white">{filteredDetectors.length}</div>
<div className="text-sm text-gray-400">Всего</div>
</div>
<div className="bg-[#161824] p-4 rounded-lg">
<div className="text-2xl font-bold text-green-500">{filteredDetectors.filter(d => d.status === statusColors.STATUS_COLOR_NORMAL).length}</div>
<div className="text-sm text-gray-400">Норма</div>
</div>
<div className="bg-[#161824] p-4 rounded-lg">
<div className="text-2xl font-bold text-orange-500">{filteredDetectors.filter(d => d.status === statusColors.STATUS_COLOR_WARNING).length}</div>
<div className="text-sm text-gray-400">Предупреждения</div>
</div>
<div className="bg-[#161824] p-4 rounded-lg">
<div className="text-2xl font-bold text-red-500">{filteredDetectors.filter(d => d.status === statusColors.STATUS_COLOR_CRITICAL).length}</div>
<div className="text-sm text-gray-400">Критические</div>
</div>
</div>
{filteredDetectors.length === 0 && (
<div className="text-center py-8">
<p className="text-gray-400">Детекторы не найдены</p>
</div>
)}
</div>
)
}
export default DetectorList

View File

@@ -69,6 +69,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
onSensorPick,
highlightAllSensors,
sensorStatusMap,
showStats = false,
onToggleStats,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const engineRef = useRef<Nullable<Engine>>(null)
@@ -920,6 +922,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
/>
</>
)}
{/* UPDATED: Interactive overlay circles with hover effects */}

View File

@@ -20,6 +20,7 @@ import {
PointerEventTypes,
PointerInfo,
Matrix,
Ray,
} from '@babylonjs/core'
import '@babylonjs/loaders'
@@ -55,6 +56,8 @@ export interface ModelViewerProps {
onSensorPick?: (sensorId: string | null) => void
highlightAllSensors?: boolean
sensorStatusMap?: Record<string, string>
showStats?: boolean
onToggleStats?: () => void
}
const ModelViewer: React.FC<ModelViewerProps> = ({
@@ -68,6 +71,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
onSensorPick,
highlightAllSensors,
sensorStatusMap,
showStats = false,
onToggleStats,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const engineRef = useRef<Nullable<Engine>>(null)
@@ -340,7 +345,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
let engine: Engine
try {
engine = new Engine(canvas, true, { stencil: true })
// Оптимизация: используем FXAA вместо MSAA для снижения нагрузки на GPU
engine = new Engine(canvas, false, { stencil: true }) // false = отключаем MSAA
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
const message = `WebGL недоступен: ${errorMessage}`
@@ -363,6 +369,9 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
scene.clearColor = new Color4(0.1, 0.1, 0.15, 1)
// Оптимизация: включаем FXAA (более легковесное сглаживание)
scene.imageProcessingConfiguration.fxaaEnabled = true
const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene)
camera.attachControl(canvas, true)
camera.lowerRadiusLimit = 2
@@ -689,22 +698,26 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
const maxDimension = Math.max(size.x, size.y, size.z)
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
// Вычисляем оптимальные углы камеры для видимости датчика
// Позиционируем камеру спереди датчика с небольшим наклоном сверху
const directionToCamera = camera.position.subtract(center).normalize()
// Простое позиционирование камеры - всегда поворачиваемся к датчику
console.log('[ModelViewer] Calculating camera direction to sensor')
// Вычисляем целевые углы alpha и beta
// Вычисляем направление от текущей позиции камеры к датчику
const directionToSensor = center.subtract(camera.position).normalize()
// Преобразуем в сферические координаты
// alpha - горизонтальный угол (вокруг оси Y)
// beta - вертикальный угол (наклон)
let targetAlpha = Math.atan2(directionToCamera.x, directionToCamera.z)
let targetBeta = Math.acos(directionToCamera.y)
let targetAlpha = Math.atan2(directionToSensor.x, directionToSensor.z)
// Если датчик за стеной, позиционируем камеру спереди
// Используем направление от центра сцены к датчику
const sceneCenter = Vector3.Zero()
const directionFromSceneCenter = center.subtract(sceneCenter).normalize()
targetAlpha = Math.atan2(directionFromSceneCenter.x, directionFromSceneCenter.z) + Math.PI
targetBeta = Math.PI / 3 // 60 градусов - смотрим немного сверху
// beta - вертикальный угол (от вертикали)
// Используем оптимальный угол 60° для обзора
let targetBeta = Math.PI / 3 // 60°
console.log('[ModelViewer] Calculated camera direction:', {
alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°',
beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°',
sensorPosition: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) },
cameraPosition: { x: camera.position.x.toFixed(2), y: camera.position.y.toFixed(2), z: camera.position.z.toFixed(2) }
})
// Нормализуем alpha в диапазон [-PI, PI]
while (targetAlpha > Math.PI) targetAlpha -= 2 * Math.PI
@@ -715,6 +728,25 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
scene.stopAnimation(camera)
// Логирование перед анимацией
console.log('[ModelViewer] Starting camera animation:', {
sensorId,
from: {
target: { x: camera.target.x.toFixed(2), y: camera.target.y.toFixed(2), z: camera.target.z.toFixed(2) },
radius: camera.radius.toFixed(2),
alpha: (camera.alpha * 180 / Math.PI).toFixed(1) + '°',
beta: (camera.beta * 180 / Math.PI).toFixed(1) + '°'
},
to: {
target: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) },
radius: targetRadius.toFixed(2),
alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°',
beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°'
},
alphaChange: ((targetAlpha - camera.alpha) * 180 / Math.PI).toFixed(1) + '°',
betaChange: ((targetBeta - camera.beta) * 180 / Math.PI).toFixed(1) + '°'
})
const ease = new CubicEase()
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
const frameRate = 60
@@ -891,7 +923,49 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
panActive={panActive}
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
onToggleStats={onToggleStats}
statsActive={showStats}
/>
{/* Блок статистики датчиков */}
{showStats && sensorStatusMap && (
<div className="absolute top-24 left-8 z-[60] pointer-events-none">
<div className="bg-[#161824] rounded-[15px] border border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.3)] py-3 px-4">
<div className="flex flex-col gap-2 text-gray-300">
{/* Всего датчиков */}
<div className="flex items-center justify-between gap-4">
<span className="text-xs">Всего:</span>
<span className="text-sm font-semibold">{Object.keys(sensorStatusMap).length}</span>
</div>
<div className="h-px bg-white/10"></div>
{/* Нормальный */}
<div className="flex items-center justify-between gap-4">
<span className="text-xs">Норма:</span>
<span className="text-sm font-semibold">
{Object.values(sensorStatusMap).filter(s => s === '#4ade80' || s.toLowerCase() === 'normal').length}
</span>
</div>
{/* Предупреждение */}
<div className="flex items-center justify-between gap-4">
<span className="text-xs">Предупр.:</span>
<span className="text-sm font-semibold">
{Object.values(sensorStatusMap).filter(s => s === '#fb923c' || s.toLowerCase() === 'warning').length}
</span>
</div>
{/* Критический */}
<div className="flex items-center justify-between gap-4">
<span className="text-xs">Критич.:</span>
<span className="text-sm font-semibold">
{Object.values(sensorStatusMap).filter(s => s === '#ef4444' || s.toLowerCase() === 'critical').length}
</span>
</div>
</div>
</div>
</div>
)}
</>
)}
{/* UPDATED: Interactive overlay circles with hover effects */}

View File

@@ -1,944 +0,0 @@
'use client'
import React, { useEffect, useRef, useState } from 'react'
import {
Engine,
Scene,
Vector3,
HemisphericLight,
ArcRotateCamera,
Color3,
Color4,
AbstractMesh,
Nullable,
HighlightLayer,
Animation,
CubicEase,
EasingFunction,
ImportMeshAsync,
PointerEventTypes,
PointerInfo,
Matrix,
} from '@babylonjs/core'
import '@babylonjs/loaders'
import SceneToolbar from './SceneToolbar';
import LoadingSpinner from '../ui/LoadingSpinner'
import useNavigationStore from '@/app/store/navigationStore'
import {
getSensorIdFromMesh,
collectSensorMeshes,
applyHighlightToMeshes,
statusToColor3,
} from './sensorHighlight'
import {
computeSensorOverlayCircles,
hexWithAlpha,
} from './sensorHighlightOverlay'
export interface ModelViewerProps {
modelPath: string
onSelectModel: (path: string) => void;
onModelLoaded?: (modelData: {
meshes: AbstractMesh[]
boundingBox: {
min: { x: number; y: number; z: number }
max: { x: number; y: number; z: number }
}
}) => void
onError?: (error: string) => void
activeMenu?: string | null
focusSensorId?: string | null
renderOverlay?: (params: { anchor: { left: number; top: number } | null; info?: { name?: string; sensorId?: string } | null }) => React.ReactNode
isSensorSelectionEnabled?: boolean
onSensorPick?: (sensorId: string | null) => void
highlightAllSensors?: boolean
sensorStatusMap?: Record<string, string>
}
const ModelViewer: React.FC<ModelViewerProps> = ({
modelPath,
onSelectModel,
onModelLoaded,
onError,
focusSensorId,
renderOverlay,
isSensorSelectionEnabled,
onSensorPick,
highlightAllSensors,
sensorStatusMap,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const engineRef = useRef<Nullable<Engine>>(null)
const sceneRef = useRef<Nullable<Scene>>(null)
const [isLoading, setIsLoading] = useState(false)
const [loadingProgress, setLoadingProgress] = useState(0)
const [showModel, setShowModel] = useState(false)
const isInitializedRef = useRef(false)
const isDisposedRef = useRef(false)
const importedMeshesRef = useRef<AbstractMesh[]>([])
const highlightLayerRef = useRef<HighlightLayer | null>(null)
const highlightedMeshesRef = useRef<AbstractMesh[]>([])
const chosenMeshRef = useRef<AbstractMesh | null>(null)
const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null)
const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null)
const [modelReady, setModelReady] = useState(false)
const [panActive, setPanActive] = useState(false);
const [webglError, setWebglError] = useState<string | null>(null)
const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState<
{ sensorId: string; left: number; top: number; colorHex: string }[]
>([])
// NEW: State for tracking hovered sensor in overlay circles
const [hoveredSensorId, setHoveredSensorId] = useState<string | null>(null)
const handlePan = () => setPanActive(!panActive);
useEffect(() => {
const scene = sceneRef.current;
const camera = scene?.activeCamera as ArcRotateCamera;
const canvas = canvasRef.current;
if (!scene || !camera || !canvas) {
return;
}
let observer: any = null;
if (panActive) {
camera.detachControl();
observer = scene.onPointerObservable.add((pointerInfo: PointerInfo) => {
const evt = pointerInfo.event;
if (evt.buttons === 1) {
camera.inertialPanningX -= evt.movementX / camera.panningSensibility;
camera.inertialPanningY += evt.movementY / camera.panningSensibility;
}
else if (evt.buttons === 2) {
camera.inertialAlphaOffset -= evt.movementX / camera.angularSensibilityX;
camera.inertialBetaOffset -= evt.movementY / camera.angularSensibilityY;
}
}, PointerEventTypes.POINTERMOVE);
} else {
camera.detachControl();
camera.attachControl(canvas, true);
}
return () => {
if (observer) {
scene.onPointerObservable.remove(observer);
}
if (!camera.isDisposed() && !camera.inputs.attachedToElement) {
camera.attachControl(canvas, true);
}
};
}, [panActive, sceneRef, canvasRef]);
const handleZoomIn = () => {
const camera = sceneRef.current?.activeCamera as ArcRotateCamera
if (camera) {
sceneRef.current?.stopAnimation(camera)
const ease = new CubicEase()
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT)
const frameRate = 60
const durationMs = 300
const totalFrames = Math.round((durationMs / 1000) * frameRate)
const currentRadius = camera.radius
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 0.1, currentRadius * 0.8)
Animation.CreateAndStartAnimation(
'zoomIn',
camera,
'radius',
frameRate,
totalFrames,
currentRadius,
targetRadius,
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
}
}
const handleZoomOut = () => {
const camera = sceneRef.current?.activeCamera as ArcRotateCamera
if (camera) {
sceneRef.current?.stopAnimation(camera)
const ease = new CubicEase()
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT)
const frameRate = 60
const durationMs = 300
const totalFrames = Math.round((durationMs / 1000) * frameRate)
const currentRadius = camera.radius
const targetRadius = Math.min(camera.upperRadiusLimit ?? Infinity, currentRadius * 1.2)
Animation.CreateAndStartAnimation(
'zoomOut',
camera,
'radius',
frameRate,
totalFrames,
currentRadius,
targetRadius,
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
}
}
const handleTopView = () => {
const camera = sceneRef.current?.activeCamera as ArcRotateCamera;
if (camera) {
sceneRef.current?.stopAnimation(camera);
const ease = new CubicEase();
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT);
const frameRate = 60;
const durationMs = 500;
const totalFrames = Math.round((durationMs / 1000) * frameRate);
Animation.CreateAndStartAnimation(
'topViewAlpha',
camera,
'alpha',
frameRate,
totalFrames,
camera.alpha,
Math.PI / 2,
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
);
Animation.CreateAndStartAnimation(
'topViewBeta',
camera,
'beta',
frameRate,
totalFrames,
camera.beta,
0,
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
);
}
};
// NEW: Function to handle overlay circle click
const handleOverlayCircleClick = (sensorId: string) => {
console.log('[ModelViewer] Overlay circle clicked:', sensorId)
// Find the mesh for this sensor
const allMeshes = importedMeshesRef.current || []
const sensorMeshes = collectSensorMeshes(allMeshes)
const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
if (!targetMesh) {
console.warn(`[ModelViewer] Mesh not found for sensor: ${sensorId}`)
return
}
const scene = sceneRef.current
const camera = scene?.activeCamera as ArcRotateCamera
if (!scene || !camera) return
// Calculate bounding box of the sensor mesh
const bbox = (typeof targetMesh.getHierarchyBoundingVectors === 'function')
? targetMesh.getHierarchyBoundingVectors()
: {
min: targetMesh.getBoundingInfo().boundingBox.minimumWorld,
max: targetMesh.getBoundingInfo().boundingBox.maximumWorld
}
const center = bbox.min.add(bbox.max).scale(0.5)
const size = bbox.max.subtract(bbox.min)
const maxDimension = Math.max(size.x, size.y, size.z)
// Calculate optimal camera distance
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
// Stop any current animations
scene.stopAnimation(camera)
// Setup easing
const ease = new CubicEase()
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
const frameRate = 60
const durationMs = 600 // 0.6 seconds for smooth animation
const totalFrames = Math.round((durationMs / 1000) * frameRate)
// Animate camera target position
Animation.CreateAndStartAnimation(
'camTarget',
camera,
'target',
frameRate,
totalFrames,
camera.target.clone(),
center.clone(),
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
// Animate camera radius (zoom)
Animation.CreateAndStartAnimation(
'camRadius',
camera,
'radius',
frameRate,
totalFrames,
camera.radius,
targetRadius,
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
// Call callback to display tooltip
onSensorPick?.(sensorId)
console.log('[ModelViewer] Camera animation started for sensor:', sensorId)
}
useEffect(() => {
isDisposedRef.current = false
isInitializedRef.current = false
return () => {
isDisposedRef.current = true
}
}, [])
useEffect(() => {
if (!canvasRef.current || isInitializedRef.current) return
const canvas = canvasRef.current
setWebglError(null)
let hasWebGL = false
try {
const testCanvas = document.createElement('canvas')
const gl =
testCanvas.getContext('webgl2') ||
testCanvas.getContext('webgl') ||
testCanvas.getContext('experimental-webgl')
hasWebGL = !!gl
} catch {
hasWebGL = false
}
if (!hasWebGL) {
const message = 'WebGL не поддерживается в текущем окружении'
setWebglError(message)
onError?.(message)
setIsLoading(false)
setModelReady(false)
return
}
let engine: Engine
try {
// Оптимизация: используем FXAA вместо MSAA для снижения нагрузки на GPU
engine = new Engine(canvas, false, { stencil: true }) // false = отключаем MSAA
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
const message = `WebGL недоступен: ${errorMessage}`
setWebglError(message)
onError?.(message)
setIsLoading(false)
setModelReady(false)
return
}
engineRef.current = engine
engine.runRenderLoop(() => {
if (!isDisposedRef.current && sceneRef.current) {
sceneRef.current.render()
}
})
const scene = new Scene(engine)
sceneRef.current = scene
scene.clearColor = new Color4(0.1, 0.1, 0.15, 1)
// Оптимизация: включаем FXAA (более легковесное сглаживание)
scene.imageProcessingConfiguration.fxaaEnabled = true
const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene)
camera.attachControl(canvas, true)
camera.lowerRadiusLimit = 2
camera.upperRadiusLimit = 200
camera.wheelDeltaPercentage = 0.01
camera.panningSensibility = 50
camera.angularSensibilityX = 1000
camera.angularSensibilityY = 1000
const ambientLight = new HemisphericLight('ambientLight', new Vector3(0, 1, 0), scene)
ambientLight.intensity = 0.4
ambientLight.diffuse = new Color3(0.7, 0.7, 0.8)
ambientLight.specular = new Color3(0.2, 0.2, 0.3)
ambientLight.groundColor = new Color3(0.3, 0.3, 0.4)
const keyLight = new HemisphericLight('keyLight', new Vector3(1, 1, 0), scene)
keyLight.intensity = 0.6
keyLight.diffuse = new Color3(1, 1, 0.9)
keyLight.specular = new Color3(1, 1, 0.9)
const fillLight = new HemisphericLight('fillLight', new Vector3(-1, 0.5, -1), scene)
fillLight.intensity = 0.3
fillLight.diffuse = new Color3(0.8, 0.8, 1)
const hl = new HighlightLayer('highlight-layer', scene, {
mainTextureRatio: 1,
blurTextureSizeRatio: 1,
})
hl.innerGlow = false
hl.outerGlow = true
hl.blurHorizontalSize = 2
hl.blurVerticalSize = 2
highlightLayerRef.current = hl
const handleResize = () => {
if (!isDisposedRef.current) {
engine.resize()
}
}
window.addEventListener('resize', handleResize)
isInitializedRef.current = true
return () => {
isDisposedRef.current = true
isInitializedRef.current = false
window.removeEventListener('resize', handleResize)
highlightLayerRef.current?.dispose()
highlightLayerRef.current = null
if (engineRef.current) {
engineRef.current.dispose()
engineRef.current = null
}
sceneRef.current = null
}
}, [onError])
useEffect(() => {
if (!modelPath || !sceneRef.current || !engineRef.current) return
const scene = sceneRef.current
setIsLoading(true)
setLoadingProgress(0)
setShowModel(false)
setModelReady(false)
const loadModel = async () => {
try {
console.log('[ModelViewer] Starting to load model:', modelPath)
// UI элемент загрузчика (есть эффект замедленности)
const progressInterval = setInterval(() => {
setLoadingProgress(prev => {
if (prev >= 90) {
clearInterval(progressInterval)
return 90
}
return prev + Math.random() * 15
})
}, 100)
// Use the correct ImportMeshAsync signature: (url, scene, onProgress)
const result = await ImportMeshAsync(modelPath, scene, (evt) => {
if (evt.lengthComputable) {
const progress = (evt.loaded / evt.total) * 100
setLoadingProgress(progress)
console.log('[ModelViewer] Loading progress:', progress)
}
})
clearInterval(progressInterval)
if (isDisposedRef.current) {
console.log('[ModelViewer] Component disposed during load')
return
}
console.log('[ModelViewer] Model loaded successfully:', {
meshesCount: result.meshes.length,
particleSystemsCount: result.particleSystems.length,
skeletonsCount: result.skeletons.length,
animationGroupsCount: result.animationGroups.length
})
importedMeshesRef.current = result.meshes
if (result.meshes.length > 0) {
const boundingBox = result.meshes[0].getHierarchyBoundingVectors()
onModelLoaded?.({
meshes: result.meshes,
boundingBox: {
min: { x: boundingBox.min.x, y: boundingBox.min.y, z: boundingBox.min.z },
max: { x: boundingBox.max.x, y: boundingBox.max.y, z: boundingBox.max.z },
},
})
// Автоматическое кадрирование камеры для отображения всей модели
const camera = scene.activeCamera as ArcRotateCamera
if (camera) {
const center = boundingBox.min.add(boundingBox.max).scale(0.5)
const size = boundingBox.max.subtract(boundingBox.min)
const maxDimension = Math.max(size.x, size.y, size.z)
// Устанавливаем оптимальное расстояние камеры
const targetRadius = maxDimension * 2.5 // Множитель для комфортного отступа
// Плавная анимация камеры к центру модели
scene.stopAnimation(camera)
const ease = new CubicEase()
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
const frameRate = 60
const durationMs = 800 // 0.8 секунды
const totalFrames = Math.round((durationMs / 1000) * frameRate)
// Анимация позиции камеры
Animation.CreateAndStartAnimation(
'frameCameraTarget',
camera,
'target',
frameRate,
totalFrames,
camera.target.clone(),
center.clone(),
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
// Анимация зума
Animation.CreateAndStartAnimation(
'frameCameraRadius',
camera,
'radius',
frameRate,
totalFrames,
camera.radius,
targetRadius,
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
console.log('[ModelViewer] Camera framed to model:', { center, targetRadius, maxDimension })
}
}
setLoadingProgress(100)
setShowModel(true)
setModelReady(true)
setIsLoading(false)
} catch (error) {
if (isDisposedRef.current) return
const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка'
console.error('[ModelViewer] Error loading model:', errorMessage)
const message = `Ошибка при загрузке модели: ${errorMessage}`
onError?.(message)
setIsLoading(false)
setModelReady(false)
}
}
loadModel()
}, [modelPath, onModelLoaded, onError])
useEffect(() => {
if (!highlightAllSensors || focusSensorId || !modelReady) {
setAllSensorsOverlayCircles([])
return
}
const scene = sceneRef.current
const engine = engineRef.current
if (!scene || !engine) {
setAllSensorsOverlayCircles([])
return
}
const allMeshes = importedMeshesRef.current || []
const sensorMeshes = collectSensorMeshes(allMeshes)
if (sensorMeshes.length === 0) {
setAllSensorsOverlayCircles([])
return
}
const engineTyped = engine as Engine
const updateCircles = () => {
const circles = computeSensorOverlayCircles({
scene,
engine: engineTyped,
meshes: sensorMeshes,
sensorStatusMap,
})
setAllSensorsOverlayCircles(circles)
}
updateCircles()
const observer = scene.onBeforeRenderObservable.add(updateCircles)
return () => {
scene.onBeforeRenderObservable.remove(observer)
setAllSensorsOverlayCircles([])
}
}, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap])
useEffect(() => {
if (!highlightAllSensors || focusSensorId || !modelReady) {
return
}
const scene = sceneRef.current
if (!scene) return
const allMeshes = importedMeshesRef.current || []
if (allMeshes.length === 0) {
return
}
const sensorMeshes = collectSensorMeshes(allMeshes)
console.log('[ModelViewer] Total meshes in model:', allMeshes.length)
console.log('[ModelViewer] Sensor meshes found:', sensorMeshes.length)
// Log first 5 sensor IDs found in meshes
const sensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean).slice(0, 5)
console.log('[ModelViewer] Sample sensor IDs from meshes:', sensorIds)
if (sensorMeshes.length === 0) {
console.warn('[ModelViewer] No sensor meshes found in 3D model!')
return
}
applyHighlightToMeshes(
highlightLayerRef.current,
highlightedMeshesRef,
sensorMeshes,
mesh => {
const sid = getSensorIdFromMesh(mesh)
const status = sid ? sensorStatusMap?.[sid] : undefined
return statusToColor3(status ?? null)
},
)
}, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap])
useEffect(() => {
if (!focusSensorId || !modelReady) {
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
highlightedMeshesRef.current = []
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
setAllSensorsOverlayCircles([])
return
}
const sensorId = (focusSensorId ?? '').trim()
if (!sensorId) {
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
highlightedMeshesRef.current = []
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
return
}
const allMeshes = importedMeshesRef.current || []
if (allMeshes.length === 0) {
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
highlightedMeshesRef.current = []
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
return
}
const sensorMeshes = collectSensorMeshes(allMeshes)
const allSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m))
const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
console.log('[ModelViewer] Sensor focus', {
requested: sensorId,
totalImportedMeshes: allMeshes.length,
totalSensorMeshes: sensorMeshes.length,
allSensorIds: allSensorIds,
chosen: chosen ? { id: chosen.id, name: chosen.name, uniqueId: chosen.uniqueId, parent: chosen.parent?.name } : null,
source: 'result.meshes',
})
const scene = sceneRef.current!
if (chosen) {
try {
const camera = scene.activeCamera as ArcRotateCamera
const bbox = (typeof chosen.getHierarchyBoundingVectors === 'function')
? chosen.getHierarchyBoundingVectors()
: { min: chosen.getBoundingInfo().boundingBox.minimumWorld, max: chosen.getBoundingInfo().boundingBox.maximumWorld }
const center = bbox.min.add(bbox.max).scale(0.5)
const size = bbox.max.subtract(bbox.min)
const maxDimension = Math.max(size.x, size.y, size.z)
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
// Вычисляем оптимальные углы камеры для видимости датчика
// Позиционируем камеру спереди датчика с небольшим наклоном сверху
const directionToCamera = camera.position.subtract(center).normalize()
// Вычисляем целевые углы alpha и beta
// alpha - горизонтальный угол (вокруг оси Y)
// beta - вертикальный угол (наклон)
let targetAlpha = Math.atan2(directionToCamera.x, directionToCamera.z)
let targetBeta = Math.acos(directionToCamera.y)
// Если датчик за стеной, позиционируем камеру спереди
// Используем направление от центра сцены к датчику
const sceneCenter = Vector3.Zero()
const directionFromSceneCenter = center.subtract(sceneCenter).normalize()
targetAlpha = Math.atan2(directionFromSceneCenter.x, directionFromSceneCenter.z) + Math.PI
targetBeta = Math.PI / 3 // 60 градусов - смотрим немного сверху
// Нормализуем alpha в диапазон [-PI, PI]
while (targetAlpha > Math.PI) targetAlpha -= 2 * Math.PI
while (targetAlpha < -Math.PI) targetAlpha += 2 * Math.PI
// Ограничиваем beta в разумных пределах
targetBeta = Math.max(0.1, Math.min(Math.PI - 0.1, targetBeta))
scene.stopAnimation(camera)
const ease = new CubicEase()
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
const frameRate = 60
const durationMs = 800
const totalFrames = Math.round((durationMs / 1000) * frameRate)
Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
Animation.CreateAndStartAnimation('camAlpha', camera, 'alpha', frameRate, totalFrames, camera.alpha, targetAlpha, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
Animation.CreateAndStartAnimation('camBeta', camera, 'beta', frameRate, totalFrames, camera.beta, targetBeta, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
applyHighlightToMeshes(
highlightLayerRef.current,
highlightedMeshesRef,
[chosen],
mesh => {
const sid = getSensorIdFromMesh(mesh)
const status = sid ? sensorStatusMap?.[sid] : undefined
return statusToColor3(status ?? null)
},
)
chosenMeshRef.current = chosen
setOverlayData({ name: chosen.name, sensorId })
} catch {
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
highlightedMeshesRef.current = []
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
}
} else {
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
highlightedMeshesRef.current = []
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusSensorId, modelReady, highlightAllSensors])
useEffect(() => {
const scene = sceneRef.current
if (!scene || !modelReady || !isSensorSelectionEnabled) return
const pickObserver = scene.onPointerObservable.add((pointerInfo: PointerInfo) => {
if (pointerInfo.type !== PointerEventTypes.POINTERPICK) return
const pick = pointerInfo.pickInfo
if (!pick || !pick.hit) {
onSensorPick?.(null)
return
}
const pickedMesh = pick.pickedMesh
const sensorId = getSensorIdFromMesh(pickedMesh)
if (sensorId) {
onSensorPick?.(sensorId)
} else {
onSensorPick?.(null)
}
})
return () => {
scene.onPointerObservable.remove(pickObserver)
}
}, [modelReady, isSensorSelectionEnabled, onSensorPick])
const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => {
if (!sceneRef.current || !mesh) return null
const scene = sceneRef.current
try {
const bbox = (typeof mesh.getHierarchyBoundingVectors === 'function')
? mesh.getHierarchyBoundingVectors()
: { min: mesh.getBoundingInfo().boundingBox.minimumWorld, max: mesh.getBoundingInfo().boundingBox.maximumWorld }
const center = bbox.min.add(bbox.max).scale(0.5)
const viewport = scene.activeCamera?.viewport.toGlobal(engineRef.current!.getRenderWidth(), engineRef.current!.getRenderHeight())
if (!viewport) return null
const projected = Vector3.Project(center, Matrix.Identity(), scene.getTransformMatrix(), viewport)
if (!projected) return null
return { left: projected.x, top: projected.y }
} catch (error) {
console.error('[ModelViewer] Error computing overlay position:', error)
return null
}
}, [])
useEffect(() => {
if (!chosenMeshRef.current || !overlayData) return
const pos = computeOverlayPosition(chosenMeshRef.current)
setOverlayPos(pos)
}, [overlayData, computeOverlayPosition])
useEffect(() => {
if (!sceneRef.current || !chosenMeshRef.current || !overlayData) return
const scene = sceneRef.current
const updateOverlayPosition = () => {
const pos = computeOverlayPosition(chosenMeshRef.current)
setOverlayPos(pos)
}
scene.registerBeforeRender(updateOverlayPosition)
return () => scene.unregisterBeforeRender(updateOverlayPosition)
}, [overlayData, computeOverlayPosition])
return (
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
{!modelPath ? (
<div className="h-full flex items-center justify-center">
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
<div className="text-amber-400 text-lg font-semibold mb-2">
3D модель не выбрана
</div>
<div className="text-gray-300 mb-4">
Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр
</div>
<div className="text-sm text-gray-400">
Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API
</div>
</div>
</div>
) : (
<>
<canvas
ref={canvasRef}
className={`w-full h-full outline-none block transition-opacity duration-500 ${
showModel && !webglError ? 'opacity-100' : 'opacity-0'
}`}
/>
{webglError ? (
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
<div className="text-red-400 text-lg font-semibold mb-2">
3D просмотр недоступен
</div>
<div className="text-gray-300 mb-4">
{webglError}
</div>
<div className="text-sm text-gray-400">
Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве
</div>
</div>
</div>
) : isLoading ? (
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
<LoadingSpinner
progress={loadingProgress}
size={120}
strokeWidth={8}
/>
</div>
) : !modelReady ? (
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-40">
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
<div className="text-gray-400 text-lg font-semibold mb-4">
3D модель не загружена
</div>
<div className="text-sm text-gray-400">
Модель не готова к отображению
</div>
</div>
</div>
) : null}
<SceneToolbar
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onTopView={handleTopView}
onPan={handlePan}
onSelectModel={onSelectModel}
panActive={panActive}
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
/>
</>
)}
{/* UPDATED: Interactive overlay circles with hover effects */}
{allSensorsOverlayCircles.map(circle => {
const size = 36
const radius = size / 2
const fill = hexWithAlpha(circle.colorHex, 0.2)
const isHovered = hoveredSensorId === circle.sensorId
return (
<div
key={`${circle.sensorId}-${Math.round(circle.left)}-${Math.round(circle.top)}`}
onClick={() => handleOverlayCircleClick(circle.sensorId)}
onMouseEnter={() => setHoveredSensorId(circle.sensorId)}
onMouseLeave={() => setHoveredSensorId(null)}
style={{
position: 'absolute',
left: circle.left - radius,
top: circle.top - radius,
width: size,
height: size,
borderRadius: '9999px',
border: `2px solid ${circle.colorHex}`,
backgroundColor: fill,
pointerEvents: 'auto',
cursor: 'pointer',
transition: 'all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)',
transform: isHovered ? 'scale(1.4)' : 'scale(1)',
boxShadow: isHovered
? `0 0 25px ${circle.colorHex}, inset 0 0 10px ${circle.colorHex}`
: `0 0 8px ${circle.colorHex}`,
zIndex: isHovered ? 50 : 10,
}}
title={`Датчик: ${circle.sensorId}`}
/>
)
})}
{renderOverlay && overlayPos && overlayData
? renderOverlay({ anchor: overlayPos, info: overlayData })
: null
}
</div>
)
}
export default ModelViewer

View File

@@ -85,7 +85,7 @@ const SceneToolbar: React.FC<SceneToolbarProps> = ({
},
{
icon: '/icons/Layers.png',
label: 'Уровни',
label: 'Общий вид',
onClick: handleToggleNavMenu,
active: navMenuActive,
},

View File

@@ -0,0 +1,168 @@
import React, { useState } from 'react';
import Image from 'next/image';
import useNavigationStore from '@/app/store/navigationStore';
import type { Zone } from '@/app/types';
interface ToolbarButton {
icon: string;
label: string;
onClick: () => void;
onMouseDown?: () => void;
onMouseUp?: () => void;
active?: boolean;
children?: ToolbarButton[];
}
interface SceneToolbarProps {
onZoomIn?: () => void;
onZoomOut?: () => void;
onTopView?: () => void;
onPan?: () => void;
onSelectModel?: (modelPath: string) => void;
panActive?: boolean;
navMenuActive?: boolean;
onToggleSensorHighlights?: () => void;
sensorHighlightsActive?: boolean;
}
const SceneToolbar: React.FC<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;

View File

@@ -3,6 +3,7 @@
import React from 'react'
import { useRouter } from 'next/navigation'
import useNavigationStore from '@/app/store/navigationStore'
import AreaChart from '../dashboard/AreaChart'
interface DetectorType {
detector_id: number
@@ -54,6 +55,14 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
? latestTimestamp.toLocaleString('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
: 'Нет данных'
// Данные для графика за последние 3 дня (мок данные)
const chartData: { timestamp: string; value: number }[] = [
{ timestamp: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), value: 75 },
{ timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), value: 82 },
{ timestamp: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), value: 78 },
{ timestamp: new Date().toISOString(), value: 85 },
]
// Определение типа детектора и его отображаемого названия
const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase()
const deriveCodeFromType = (): string => {
@@ -216,8 +225,16 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
// Компактный режим с якорной позицией (всплывающее окно)
// Используется для отображения информации при наведении на детектор в списке
if (compact && anchor) {
// Проверяем границы экрана и корректируем позицию
const tooltipHeight = 450 // Примерная высота толтипа с графиком
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800
const bottomOverflow = anchor.top + tooltipHeight - viewportHeight
// Если толтип выходит за нижнюю границу, сдвигаем вверх
const adjustedTop = bottomOverflow > 0 ? anchor.top - bottomOverflow - 20 : anchor.top
return (
<div className="absolute z-40" style={{ left: anchor.left, top: anchor.top }}>
<div className="absolute z-40" style={{ left: anchor.left, top: adjustedTop }}>
<div className="rounded-[10px] bg-black/80 text-white text-xs px-3 py-2 shadow-xl min-w-[300px] max-w-[400px]">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
@@ -244,6 +261,14 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
</button>
</div>
<DetailsSection compact={true} />
{/* График за последние 3 дня */}
<div className="mt-3">
<div className="text-[rgb(113,113,122)] text-[11px] mb-2">График за 3 дня</div>
<div className="min-h-[100px]">
<AreaChart data={chartData} />
</div>
</div>
</div>
</div>
)
@@ -279,6 +304,14 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
{/* Секция с детальной информацией о детекторе */}
<DetailsSection />
{/* График за последние 3 дня */}
<div className="mt-6">
<h4 className="text-white text-base font-medium mb-4">График за последние 3 дня</h4>
<div className="min-h-[200px] bg-[rgb(22,24,36)] rounded-lg p-4">
<AreaChart data={chartData} />
</div>
</div>
{/* Кнопка закрытия панели */}
<button
onClick={onClose}

View File

@@ -0,0 +1,296 @@
'use client'
import React from 'react'
import { useRouter } from 'next/navigation'
import useNavigationStore from '@/app/store/navigationStore'
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 DetectorMenuProps {
detector: DetectorType
isOpen: boolean
onClose: () => void
getStatusText: (status: string) => string
compact?: boolean
anchor?: { left: number; top: number } | null
}
// Главный компонент меню детектора
// Показывает детальную информацию о датчике с возможностью навигации к отчетам и истории
const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => {
const router = useRouter()
const { setSelectedDetector, currentObject } = useNavigationStore()
if (!isOpen) return null
// Определение последней временной метки из уведомлений детектора
const latestTimestamp = (() => {
const list = detector.notifications ?? []
if (!Array.isArray(list) || list.length === 0) return null
const dates = list.map(n => new Date(n.timestamp)).filter(d => !isNaN(d.getTime()))
if (dates.length === 0) return null
dates.sort((a, b) => b.getTime() - a.getTime())
return dates[0]
})()
const formattedTimestamp = latestTimestamp
? latestTimestamp.toLocaleString('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
: 'Нет данных'
// Определение типа детектора и его отображаемого названия
const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase()
const deriveCodeFromType = (): string => {
const t = (detector.type || '').toLowerCase()
if (!t) return ''
if (t.includes('инклинометр')) return 'GA'
if (t.includes('тензометр')) return 'PE'
if (t.includes('гидроуров')) return 'GLE'
return ''
}
const effectiveDetectorTypeCode = rawDetectorTypeCode || deriveCodeFromType()
// Карта соответствия кодов типов детекторов их русским названиям
const detectorTypeLabelMap: Record<string, string> = {
GA: 'Инклинометр',
PE: 'Тензометр',
GLE: 'Гидроуровень',
}
const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—'
// Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором
const handleReportsClick = () => {
const currentUrl = new URL(window.location.href)
const objectId = currentUrl.searchParams.get('objectId') || currentObject.id
const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title
const detectorData = {
...detector,
notifications: detector.notifications || []
}
setSelectedDetector(detectorData)
let reportsUrl = '/reports'
const params = new URLSearchParams()
if (objectId) params.set('objectId', objectId)
if (objectTitle) params.set('objectTitle', objectTitle)
if (params.toString()) {
reportsUrl += `?${params.toString()}`
}
router.push(reportsUrl)
}
// Обработчик клика по кнопке "История" - навигация на страницу истории тревог с выбранным детектором
const handleHistoryClick = () => {
const currentUrl = new URL(window.location.href)
const objectId = currentUrl.searchParams.get('objectId') || currentObject.id
const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title
const detectorData = {
...detector,
notifications: detector.notifications || []
}
setSelectedDetector(detectorData)
let alertsUrl = '/alerts'
const params = new URLSearchParams()
if (objectId) params.set('objectId', objectId)
if (objectTitle) params.set('objectTitle', objectTitle)
if (params.toString()) {
alertsUrl += `?${params.toString()}`
}
router.push(alertsUrl)
}
// Компонент секции деталей детектора
// Отображает информацию о датчике в компактном или полном формате
const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => (
<div className={compact ? 'mt-2 space-y-1' : 'space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden'}>
{compact ? (
// Компактный режим: 4 строки по 2 колонки с основной информацией
<>
{/* Строка 1: Маркировка и тип детектора */}
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Маркировка по проекту</div>
<div className="text-white text-xs truncate">{detector.name}</div>
</div>
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Тип детектора</div>
<div className="text-white text-xs truncate">{displayDetectorTypeLabel}</div>
</div>
</div>
{/* Строка 2: Местоположение и статус */}
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Местоположение</div>
<div className="text-white text-xs truncate">{detector.location}</div>
</div>
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Статус</div>
<div className="text-white text-xs truncate">{getStatusText(detector.status)}</div>
</div>
</div>
{/* Строка 3: Временная метка и этаж */}
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Временная метка</div>
<div className="text-white text-xs truncate">{formattedTimestamp}</div>
</div>
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Этаж</div>
<div className="text-white text-xs truncate">{detector.floor}</div>
</div>
</div>
{/* Строка 4: Серийный номер */}
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Серийный номер</div>
<div className="text-white text-xs truncate">{detector.serial_number}</div>
</div>
</div>
</>
) : (
// Полный режим: 3 строки по 2 колонки с рамками между элементами
<>
{/* Строка 1: Маркировка по проекту и тип детектора */}
<div className="flex">
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
<div className="text-white text-sm">{detector.name}</div>
</div>
<div className="flex-1 p-4">
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Тип детектора</div>
<div className="text-white text-sm">{displayDetectorTypeLabel}</div>
</div>
</div>
{/* Строка 2: Местоположение и статус */}
<div className="flex border-t border-[rgb(30,31,36)]">
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Местоположение</div>
<div className="text-white text-sm">{detector.location}</div>
</div>
<div className="flex-1 p-4">
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Статус</div>
<div className="text-white text-sm">{getStatusText(detector.status)}</div>
</div>
</div>
{/* Строка 3: Временная метка и серийный номер */}
<div className="flex border-t border-[rgb(30,31,36)]">
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Временная метка</div>
<div className="text-white text-sm">{formattedTimestamp}</div>
</div>
<div className="flex-1 p-4">
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Серийный номер</div>
<div className="text-white text-sm">{detector.serial_number}</div>
</div>
</div>
</>
)}
</div>
)
// Компактный режим с якорной позицией (всплывающее окно)
// Используется для отображения информации при наведении на детектор в списке
if (compact && anchor) {
return (
<div className="absolute z-40" style={{ left: anchor.left, top: anchor.top }}>
<div className="rounded-[10px] bg-black/80 text-white text-xs px-3 py-2 shadow-xl min-w-[300px] max-w-[400px]">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="font-semibold truncate">{detector.name}</div>
</div>
<button onClick={onClose} className="text-gray-300 hover:text-white transition-colors">
<svg className="w-4 h-4" 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>
<div className="mt-2 grid grid-cols-2 gap-2">
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Отчет
</button>
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
История
</button>
</div>
<DetailsSection compact={true} />
</div>
</div>
)
}
// Полный режим боковой панели (основной режим)
// Отображается как правая панель с полной информацией о детекторе
return (
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 з-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
<div className="h-full overflow-auto p-5">
<div className="flex items-center justify-between mb-4">
{/* Заголовок с названием детектора */}
<h3 className="text-white text-lg font-medium">
{detector.name}
</h3>
{/* Кнопки действий: Отчет и История */}
<div className="flex items-center gap-2">
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Отчет
</button>
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
История
</button>
</div>
</div>
{/* Секция с детальной информацией о детекторе */}
<DetailsSection />
{/* Кнопка закрытия панели */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6л12 12" />
</svg>
</button>
</div>
</div>
)
}
export default DetectorMenu

View File

@@ -82,7 +82,7 @@ const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected =
return (
<article
className={`flex flex-col w-full min-h-[414px] h-[414px] sm:h-auto sm:min-h-[350px] items-start gap-4 p-4 sm:p-6 relative bg-[#161824] rounded-[20px] overflow-hidden cursor-pointer transition-all duration-200 hover:bg-[#1a1d2e] ${
className={`flex flex-col w-full min-h-[340px] h-[340px] sm:h-auto sm:min-h-[300px] items-start gap-3 p-3 sm:p-4 relative bg-[#161824] rounded-[20px] overflow-hidden cursor-pointer transition-all duration-200 hover:bg-[#1a1d2e] ${
isSelected ? 'ring-2 ring-blue-500' : ''
}`}
onClick={handleCardClick}
@@ -116,7 +116,7 @@ const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected =
</header>
{/* Изображение объекта */}
<div className="relative flex-1 self-stretch w-full grow bg-[#f1f1f1] rounded-lg overflow-hidden min-h-[200px] sm:min-h-[250px]">
<div className="relative flex-1 self-stretch w-full grow bg-[#f1f1f1] rounded-lg overflow-hidden min-h-[170px] sm:min-h-[200px]">
<Image
className="absolute w-full h-full top-0 left-0 object-cover"
alt={object.title}

View File

@@ -0,0 +1,138 @@
'use client'
import React from 'react'
import Image from 'next/image'
import { useNavigationService } from '@/services/navigationService'
interface ObjectData {
object_id: string
title: string
description: string
image: string | null
location: string
floors?: number
area?: string
type?: string
status?: string
}
interface ObjectCardProps {
object: ObjectData
onSelect?: (objectId: string) => void
isSelected?: boolean
}
// Иконка редактирования
const EditIcon = ({ className }: { className?: string }) => (
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
</svg>
)
const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected = false }) => {
const navigationService = useNavigationService()
const handleCardClick = () => {
if (onSelect) {
onSelect(object.object_id)
}
// Навигация к дашборду с выбранным объектом
navigationService.selectObjectAndGoToDashboard(object.object_id, object.title)
}
const handleEditClick = (e: React.MouseEvent) => {
e.stopPropagation()
console.log('Edit object:', object.object_id)
// Логика редактирования объекта
}
// Возврат к тестовому изображению, если src отсутствует/некорректен; нормализация относительных путей
const resolveImageSrc = (src?: string | null): string => {
if (!src || typeof src !== 'string') return '/images/test_image.png'
let s = src.trim()
if (!s) return '/images/test_image.png'
// Нормализуем обратные слеши в стиле Windows
s = s.replace(/\\/g, '/')
const lower = s.toLowerCase()
// Обрабатываем явный плейсхолдер test_image.png только как заглушку
if (lower === 'test_image.png' || lower.endsWith('/test_image.png') || lower.includes('/public/images/test_image.png')) {
return '/images/test_image.png'
}
// Абсолютные URL
if (s.startsWith('http://') || s.startsWith('https://')) return s
// Пути, относительные к сайту
if (s.startsWith('/')) {
// Преобразуем /public/images/... в /images/...
if (/\/public\/images\//i.test(s)) {
return s.replace(/\/public\/images\//i, '/images/')
}
return s
}
// Нормализуем относительные имена ресурсов до путей сайта под /images
// Убираем ведущий 'public/', если он присутствует
s = s.replace(/^public\//i, '')
return s.startsWith('images/') ? `/${s}` : `/images/${s}`
}
const imgSrc = resolveImageSrc(object.image)
return (
<article
className={`flex flex-col w-full min-h-[414px] h-[414px] sm:h-auto sm:min-h-[350px] items-start gap-4 p-4 sm:p-6 relative bg-[#161824] rounded-[20px] overflow-hidden cursor-pointer transition-all duration-200 hover:bg-[#1a1d2e] ${
isSelected ? 'ring-2 ring-blue-500' : ''
}`}
onClick={handleCardClick}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleCardClick()
}
}}
>
<header className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-2 relative self-stretch w-full flex-[0_0_auto]">
<div className="flex-col items-start flex-1 grow flex relative min-w-0">
<h2 className="self-stretch mt-[-1.00px] font-medium text-white text-lg leading-7 relative tracking-[0] break-words">
{object.title}
</h2>
<p className="self-stretch font-normal text-[#71717a] text-sm leading-5 relative tracking-[0] break-words">
{object.description}
</p>
</div>
<button
className="inline-flex flex-shrink-0 bg-[#3193f5] h-10 items-center justify-center gap-2 px-3 sm:px-4 py-2 relative rounded-md transition-colors duration-200 hover:bg-[#2563eb] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 w-full sm:w-auto"
aria-label={`Изменить ${object.title}`}
onClick={handleEditClick}
>
<EditIcon className="!relative !w-4 !h-4 text-white flex-shrink-0" />
<span className="font-medium text-white text-sm leading-5 relative tracking-[0] sm:whitespace-nowrap">
Изменить
</span>
</button>
</header>
{/* Изображение объекта */}
<div className="relative flex-1 self-stretch w-full grow bg-[#f1f1f1] rounded-lg overflow-hidden min-h-[200px] sm:min-h-[250px]">
<Image
className="absolute w-full h-full top-0 left-0 object-cover"
alt={object.title}
src={imgSrc}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
onError={(e) => {
// Заглушка при ошибке загрузки изображения
const target = e.target as HTMLImageElement
target.src = '/images/test_image.png'
}}
/>
</div>
</article>
)
}
export default ObjectCard
export type { ObjectData, ObjectCardProps }

View File

@@ -11,12 +11,6 @@ interface ObjectGalleryProps {
className?: string
}
const BackIcon = ({ className }: { className?: string }) => (
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
</svg>
)
const ObjectGallery: React.FC<ObjectGalleryProps> = ({
objects,
title = 'Объекты',
@@ -31,42 +25,8 @@ const ObjectGallery: React.FC<ObjectGalleryProps> = ({
}
}
const handleBackClick = () => {
console.log('Back clicked')
}
return (
<div className={`flex flex-col items-start relative bg-[#0e111a] min-h-screen ${className}`}>
<main className="relative self-stretch w-full">
<div className="flex flex-col w-full items-start gap-6 p-4 sm:p-8 lg:p-16">
<header className="flex flex-col items-start gap-9 relative self-stretch w-full flex-[0_0_auto]">
<nav className="items-center gap-4 self-stretch w-full flex-[0_0_auto] flex relative">
<button
className="flex w-10 bg-[#161824] h-10 items-center justify-center gap-2 px-2 py-2 relative rounded-md transition-colors duration-200 hover:bg-[#1a1d2e] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
aria-label="Назад"
onClick={handleBackClick}
>
<BackIcon className="relative w-5 h-5 text-white" />
</button>
<div className="inline-flex flex-wrap items-center gap-2.5 relative flex-[0_0_auto]">
<div className="inline-flex items-center justify-center gap-2.5 relative flex-[0_0_auto]">
<span className="relative w-fit mt-[-1.00px] font-normal text-white text-sm tracking-[0] leading-5 whitespace-nowrap">
{title}
</span>
</div>
</div>
</nav>
<div className="flex items-start gap-4 relative self-stretch w-full flex-[0_0_auto]">
<h1 className="relative w-fit mt-[-1.00px] font-semibold text-white text-2xl tracking-[0] leading-8 whitespace-nowrap">
{title}
</h1>
</div>
</header>
<div className={`w-full ${className}`}>
{/* Галерея объектов */}
{objects.length > 0 ? (
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[18px] w-full">
@@ -93,8 +53,6 @@ const ObjectGallery: React.FC<ObjectGalleryProps> = ({
</div>
)}
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,102 @@
'use client'
import React from 'react'
import ObjectCard, { ObjectData } from './ObjectCard'
interface ObjectGalleryProps {
objects: ObjectData[]
title?: string
onObjectSelect?: (objectId: string) => void
selectedObjectId?: string | null
className?: string
}
const BackIcon = ({ className }: { className?: string }) => (
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
</svg>
)
const ObjectGallery: React.FC<ObjectGalleryProps> = ({
objects,
title = 'Объекты',
onObjectSelect,
selectedObjectId,
className = ''
}) => {
const handleObjectSelect = (objectId: string) => {
if (onObjectSelect) {
onObjectSelect(objectId)
}
}
const handleBackClick = () => {
console.log('Back clicked')
}
return (
<div className={`flex flex-col items-start relative bg-[#0e111a] min-h-screen ${className}`}>
<main className="relative self-stretch w-full">
<div className="flex flex-col w-full items-start gap-6 p-4 sm:p-8 lg:p-16">
<header className="flex flex-col items-start gap-9 relative self-stretch w-full flex-[0_0_auto]">
<nav className="items-center gap-4 self-stretch w-full flex-[0_0_auto] flex relative">
<button
className="flex w-10 bg-[#161824] h-10 items-center justify-center gap-2 px-2 py-2 relative rounded-md transition-colors duration-200 hover:bg-[#1a1d2e] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
aria-label="Назад"
onClick={handleBackClick}
>
<BackIcon className="relative w-5 h-5 text-white" />
</button>
<div className="inline-flex flex-wrap items-center gap-2.5 relative flex-[0_0_auto]">
<div className="inline-flex items-center justify-center gap-2.5 relative flex-[0_0_auto]">
<span className="relative w-fit mt-[-1.00px] font-normal text-white text-sm tracking-[0] leading-5 whitespace-nowrap">
{title}
</span>
</div>
</div>
</nav>
<div className="flex items-start gap-4 relative self-stretch w-full flex-[0_0_auto]">
<h1 className="relative w-fit mt-[-1.00px] font-semibold text-white text-2xl tracking-[0] leading-8 whitespace-nowrap">
{title}
</h1>
</div>
</header>
{/* Галерея объектов */}
{objects.length > 0 ? (
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[18px] w-full">
{objects.map((object) => (
<ObjectCard
key={object.object_id}
object={object}
onSelect={handleObjectSelect}
isSelected={selectedObjectId === object.object_id}
/>
))}
</section>
) : (
<div className="flex items-center justify-center h-64 w-full">
<div className="text-center">
<div className="text-[#71717a] mb-2">
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
</div>
<h3 className="text-lg font-medium text-white mb-2">Объекты не найдены</h3>
<p className="text-[#71717a]">Нет доступных объектов</p>
</div>
</div>
)}
</div>
</main>
</div>
)
}
export default ObjectGallery
export type { ObjectGalleryProps }

View File

@@ -75,6 +75,10 @@ const Building = ({ className }: { className?: string }) => (
<IconWrapper src="/icons/BookOpen.png" alt="Building" className={className} />
)
const Building3D = ({ className }: { className?: string }) => (
<IconWrapper src="/icons/Building3D.png" alt="Objects" className={className} />
)
// основные routes
const mainNavigationItems: NavigationItem[] = [
{
@@ -82,6 +86,11 @@ const mainNavigationItems: NavigationItem[] = [
icon: BookOpen,
label: 'Дашборд'
},
{
id: 10,
icon: Building3D,
label: 'Объекты'
},
{
id: 2,
icon: Bot,

View File

@@ -0,0 +1,504 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useRouter, usePathname } from 'next/navigation'
import Image from 'next/image'
import useUIStore from '../../app/store/uiStore'
import useNavigationStore from '../../app/store/navigationStore'
import { useNavigationService } from '@/services/navigationService'
import useUserStore from '../../app/store/userStore'
import { signOut } from 'next-auth/react'
interface NavigationItem {
id: number
label: string
icon: React.ComponentType<{ className?: string }>
}
interface SidebarProps {
navigationItems?: NavigationItem[]
logoSrc?: string
userInfo?: {
name: string
role: string
avatar?: string
}
activeItem?: number | null
onCustomItemClick?: (itemId: number) => boolean
}
const IconWrapper = ({ src, alt, className }: { src: string; alt: string; className?: string }) => (
<div className={`relative ${className}`}>
<Image
src={src}
alt={alt}
width={20}
height={20}
className="w-full h-full object-contain"
/>
</div>
)
const BookOpen = ({ className }: { className?: string }) => (
<IconWrapper src="/icons/BookOpen.png" alt="Dashboard" className={className} />
)
const Bot = ({ className }: { className?: string }) => (
<IconWrapper src="/icons/Bot.png" alt="Navigation" className={className} />
)
const SquareTerminal = ({ className }: { className?: string }) => (
<IconWrapper src="/icons/SquareTerminal.png" alt="Terminal" className={className} />
)
const CircleDot = ({ className }: { className?: string }) => (
<IconWrapper src="/icons/CircleDot.png" alt="Sensors" className={className} />
)
const BellDot = ({ className }: { className?: string }) => (
<IconWrapper src="/icons/BellDot.png" alt="Notifications" className={className} />
)
const History = ({ className }: { className?: string }) => (
<IconWrapper src="/icons/History.png" alt="History" className={className} />
)
const Settings2 = ({ className }: { className?: string }) => (
<IconWrapper src="/icons/Settings2.png" alt="Settings" className={className} />
)
const Monitor = ({ className }: { className?: string }) => (
<IconWrapper src="/icons/Bot.png" alt="Monitor" className={className} />
)
const Building = ({ className }: { className?: string }) => (
<IconWrapper src="/icons/BookOpen.png" alt="Building" className={className} />
)
// основные routes
const mainNavigationItems: NavigationItem[] = [
{
id: 1,
icon: BookOpen,
label: 'Дашборд'
},
{
id: 2,
icon: Bot,
label: 'Навигация по зданию'
},
{
id: 8,
icon: History,
label: 'История тревог'
},
{
id: 9,
icon: Settings2,
label: 'Отчеты'
}
]
// суб-меню под "Навигация по зданию"
const navigationSubItems: NavigationItem[] = [
{
id: 3,
icon: Monitor,
label: 'Зоны Мониторинга'
},
{
id: 4,
icon: Building,
label: 'Навигация по этажам'
},
{
id: 5,
icon: BellDot,
label: 'Уведомления'
},
{
id: 6,
icon: CircleDot,
label: 'Сенсоры'
},
{
id: 7,
icon: SquareTerminal,
label: 'Список датчиков'
}
]
const Sidebar: React.FC<SidebarProps> = ({
logoSrc,
userInfo = {
name: '—',
role: '—'
},
activeItem: propActiveItem,
onCustomItemClick
}) => {
const navigationService = useNavigationService()
const router = useRouter()
const pathname = usePathname()
const [internalActiveItem, setInternalActiveItem] = useState<number | null>(null)
const [isHydrated, setIsHydrated] = useState(false)
const [manuallyToggled, setManuallyToggled] = useState(false)
const activeItem = propActiveItem !== undefined ? propActiveItem : internalActiveItem
const {
isSidebarCollapsed: isCollapsed,
toggleSidebar,
isNavigationSubMenuExpanded: showNavigationSubItems,
setNavigationSubMenuExpanded: setShowNavigationSubItems,
toggleNavigationSubMenu
} = useUIStore()
const { user, logout } = useUserStore()
const roleLabelMap: Record<string, string> = {
engineer: 'Инженер',
operator: 'Оператор',
admin: 'Администратор',
}
const fullName = [user?.name, user?.surname].filter(Boolean).join(' ').trim()
const uiUserInfo = {
name: fullName || user?.login || userInfo?.name || '—',
role: roleLabelMap[(user?.account_type ?? '').toLowerCase()] || userInfo?.role || '—',
avatar: user?.image || userInfo?.avatar,
}
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
})
} catch (e) {
console.error('Logout request failed:', e)
} finally {
logout()
await signOut({ redirect: true, callbackUrl: '/login' })
}
}
const {
openMonitoring,
openFloorNavigation,
openNotifications,
openSensors,
openListOfDetectors,
closeSensors,
closeListOfDetectors,
closeMonitoring,
closeFloorNavigation,
closeNotifications,
showMonitoring,
showFloorNavigation,
showNotifications,
showSensors,
showListOfDetectors
} = useNavigationStore()
useEffect(() => {
setIsHydrated(true)
}, [])
// Чек если суб-меню активны
const isNavigationSubItemActive = activeItem && [3, 4, 5, 6, 7].includes(activeItem)
const shouldShowNavigationAsActive = activeItem === 2 || isNavigationSubItemActive
// Авто-расткрытие меню, если суб-меню стало активным (только если не было ручного переключения)
useEffect(() => {
if (isNavigationSubItemActive && !showNavigationSubItems && !manuallyToggled) {
setShowNavigationSubItems(true)
}
}, [isNavigationSubItemActive, showNavigationSubItems, manuallyToggled, setShowNavigationSubItems])
const handleItemClick = (itemId: number) => {
let handled = false
// Управление суб-меню через navigationStore (суб-меню - работают как отдельные элементы, но не страницы)
switch (itemId) {
case 2:
if (pathname !== '/navigation') {
router.push('/navigation')
}
handled = true
break
case 3: // Monitoring
if (pathname !== '/navigation') {
router.push('/navigation')
setTimeout(() => openMonitoring(), 100)
} else if (showMonitoring) {
closeMonitoring()
} else {
openMonitoring()
}
handled = true
break
case 4: // Floor Navigation
if (pathname !== '/navigation') {
router.push('/navigation')
setTimeout(() => openFloorNavigation(), 100)
} else if (showFloorNavigation) {
closeFloorNavigation()
} else {
openFloorNavigation()
}
handled = true
break
case 5: // Notifications
if (pathname !== '/navigation') {
router.push('/navigation')
setTimeout(() => openNotifications(), 100)
} else if (showNotifications) {
closeNotifications()
} else {
openNotifications()
}
handled = true
break
case 6: // Sensors
if (pathname !== '/navigation') {
router.push('/navigation')
setTimeout(() => openSensors(), 100)
} else if (showSensors) {
closeSensors()
} else {
openSensors()
}
handled = true
break
case 7: // Detector List
if (pathname !== '/navigation') {
router.push('/navigation')
setTimeout(() => openListOfDetectors(), 100)
} else if (showListOfDetectors) {
closeListOfDetectors()
} else {
openListOfDetectors()
}
handled = true
break
default:
// Для остального используем routes
if (navigationService) {
handled = navigationService.handleSidebarItemClick(itemId, pathname)
}
break
}
if (handled) {
if (propActiveItem === undefined) {
setInternalActiveItem(itemId)
}
if (onCustomItemClick) {
onCustomItemClick(itemId)
}
}
}
return (
<aside
className={`flex flex-col items-start gap-6 relative bg-[#161824] transition-all duration-300 h-screen ${
isCollapsed ? 'w-16' : 'w-64'
}`}
role="navigation"
aria-label="Main navigation"
>
<header className="flex items-center gap-2 pt-2 pb-2 px-4 relative self-stretch w-full flex-[0_0_auto] bg-[#161824]">
{!isCollapsed && (
<div className="relative">
<Image
className="w-auto h-[33px]"
alt="AerBIM Monitor Logo"
src={logoSrc || "/icons/logo.png"}
width={169}
height={33}
/>
</div>
)}
</header>
<nav className="flex flex-col items-end gap-2 relative flex-1 self-stretch w-full grow">
<div className="flex flex-col items-end gap-2 px-4 py-2 relative self-stretch w-full flex-[0_0_auto]">
<ul className="flex flex-col items-start gap-3 relative self-stretch w-full flex-[0_0_auto]" role="list">
{mainNavigationItems.map((item) => {
const IconComponent = item.icon
const isActive = item.id === 2 ? shouldShowNavigationAsActive : activeItem === item.id
return (
<li key={item.id} className="flex-col flex items-center relative self-stretch w-full" role="listitem">
<button
className={`gap-2 pt-2 pr-2 pb-2 pl-2 rounded-md flex h-9 items-center relative self-stretch w-full transition-all duration-200 hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset ${
isActive ? 'bg-gradient-to-r from-blue-600 to-cyan-500 shadow-lg shadow-blue-500/30' : ''
}`}
onClick={() => handleItemClick(item.id)}
aria-current={isActive ? 'page' : undefined}
type="button"
>
<IconComponent
className="!relative !w-5 !h-5 text-white"
aria-hidden="true"
/>
{!isCollapsed && (
<span className="flex-1 [font-family:'Inter-Regular',Helvetica] font-normal text-white text-sm leading-[14px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical] text-left">
{item.label}
</span>
)}
{item.id === 2 && !isCollapsed && (
// Закрыть все суб-меню при закрытии главного окна
<div
className="p-1.5 hover:bg-gray-600 rounded-md transition-colors duration-200 cursor-pointer bg-gray-700/50 border border-gray-600/50"
onClick={(e) => {
e.stopPropagation()
setManuallyToggled(true)
if (showNavigationSubItems || isNavigationSubItemActive) {
closeMonitoring()
closeFloorNavigation()
closeNotifications()
}
toggleNavigationSubMenu()
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
setManuallyToggled(true)
if (showNavigationSubItems || isNavigationSubItemActive) {
closeMonitoring()
closeFloorNavigation()
closeNotifications()
}
toggleNavigationSubMenu()
}
}}
aria-label={isHydrated ? (showNavigationSubItems || isNavigationSubItemActive ? 'Collapse navigation menu' : 'Expand navigation menu') : 'Toggle navigation menu'}
>
<svg
className={`!relative !w-4 !h-4 text-white transition-transform duration-200 drop-shadow-sm ${
isHydrated && (showNavigationSubItems || isNavigationSubItemActive) ? 'rotate-90' : ''
}`}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" />
</svg>
</div>
)}
</button>
{/* Суб-меню */}
{item.id === 2 && !isCollapsed && (showNavigationSubItems || isNavigationSubItemActive) && (
<ul className="flex flex-col items-start gap-1 mt-1 ml-6 relative w-full" role="list">
{navigationSubItems.map((subItem) => {
const SubIconComponent = subItem.icon
const isSubActive = activeItem === subItem.id
return (
<li key={subItem.id} className="flex-col flex h-8 items-center relative self-stretch w-full" role="listitem">
<button
className={`gap-2 pt-1.5 pr-2 pb-1.5 pl-2 rounded-md flex h-8 items-center relative self-stretch w-full transition-all duration-200 hover:bg-gray-600 focus:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset ${
isSubActive ? 'bg-gradient-to-r from-blue-600 to-cyan-500 shadow-lg shadow-blue-500/30' : ''
}`}
onClick={() => handleItemClick(subItem.id)}
aria-current={isSubActive ? 'page' : undefined}
type="button"
>
<SubIconComponent
className="!relative !w-4 !h-4 text-gray-300"
aria-hidden="true"
/>
<span className="flex-1 [font-family:'Inter-Regular',Helvetica] font-normal text-gray-300 text-sm leading-[14px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical] text-left">
{subItem.label}
</span>
</button>
</li>
)
})}
</ul>
)}
</li>
)
})}
</ul>
<button
className="!relative !w-8 !h-8 p-1.5 rounded-lg hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-200 bg-gray-800/60 border border-gray-600/40 shadow-lg hover:shadow-xl"
onClick={() => {
// Убираем суб-меню перед сворачиванием сайдбара
if (showNavigationSubItems) {
setShowNavigationSubItems(false)
setManuallyToggled(true)
closeMonitoring()
closeFloorNavigation()
closeNotifications()
}
// Убираем сайд-бар
toggleSidebar()
}}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
type="button"
>
<svg className={`!relative !w-5 !h-5 text-white transition-transform duration-200 drop-shadow-sm ${isCollapsed ? 'rotate-180' : ''}`} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
</svg>
</button>
</div>
</nav>
{!isCollapsed && (
<footer className="flex w-64 items-center gap-2 pt-2 pr-2 pb-2 pl-2 mt-auto bg-[#161824]">
<div className="inline-flex flex-[0_0_auto] items-center gap-2 relative rounded-md">
<div className="inline-flex items-center relative flex-[0_0_auto]">
<div
className="relative w-8 h-8 rounded-lg bg-white"
role="img"
aria-label="User avatar"
>
{uiUserInfo.avatar && (
<Image
src={uiUserInfo.avatar}
alt="User avatar"
className="w-full h-full rounded-lg object-cover"
fill
sizes="32px"
/>
)}
</div>
</div>
</div>
<div className="flex p-2 flex-1 grow items-center gap-2 relative rounded-md">
<div className="flex flex-col items-start justify-center gap-0.5 relative flex-1 grow">
<div className="self-stretch mt-[-1.00px] [font-family:'Inter-SemiBold',Helvetica] font-semibold text-white text-sm leading-[14px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical]">
{uiUserInfo.name}
</div>
<div className="self-stretch [font-family:'Inter-Regular',Helvetica] font-normal text-[#71717a] text-[10px] leading-[10px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical]">
{uiUserInfo.role}
</div>
</div>
</div>
<button
className="relative w-4 h-4 aspect-[1] p-1 rounded hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200"
aria-label="Logout"
title="Выйти"
type="button"
onClick={handleLogout}
>
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M10 17l-5-5 5-5v3h8v4h-8v3z" />
<path d="M20 3h-8v2h8v14h-8v2h8c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
</svg>
</button>
</footer>
)}
</aside>
)
}
export default Sidebar

View File

@@ -15,7 +15,8 @@ export const SIDEBAR_ITEM_MAP = {
1: { type: 'route', value: MainRoutes.DASHBOARD },
2: { type: 'route', value: MainRoutes.NAVIGATION },
8: { type: 'route', value: MainRoutes.ALERTS },
9: { type: 'route', value: MainRoutes.REPORTS }
9: { type: 'route', value: MainRoutes.REPORTS },
10: { type: 'route', value: MainRoutes.OBJECTS }
} as const
export class NavigationService {

View File

@@ -0,0 +1,94 @@
import React from 'react'
import { useRouter } from 'next/navigation'
import useNavigationStore from '@/app/store/navigationStore'
import type { NavigationStore } from '@/app/store/navigationStore'
export enum MainRoutes {
DASHBOARD = '/dashboard',
NAVIGATION = '/navigation',
ALERTS = '/alerts',
REPORTS = '/reports',
OBJECTS = '/objects'
}
export const SIDEBAR_ITEM_MAP = {
1: { type: 'route', value: MainRoutes.DASHBOARD },
2: { type: 'route', value: MainRoutes.NAVIGATION },
8: { type: 'route', value: MainRoutes.ALERTS },
9: { type: 'route', value: MainRoutes.REPORTS }
} as const
export class NavigationService {
private router!: ReturnType<typeof useRouter>
private navigationStore!: NavigationStore
private initialized = false
init(router: ReturnType<typeof useRouter>, navigationStore: NavigationStore) {
if (this.initialized) {
return // Предотвращаем повторную инициализацию
}
this.router = router
this.navigationStore = navigationStore
this.initialized = true
}
isInitialized(): boolean {
return this.initialized
}
navigateToRoute(route: MainRoutes) {
// Убираем подменю перед переходом на другую страницу
if (route !== MainRoutes.NAVIGATION) {
this.navigationStore.setCurrentSubmenu(null)
}
this.router.push(route)
}
handleSidebarItemClick(itemId: number, currentPath: string): boolean {
if (!this.initialized) {
console.error('NavigationService not initialized!')
return false
}
const mapping = SIDEBAR_ITEM_MAP[itemId as keyof typeof SIDEBAR_ITEM_MAP]
if (!mapping) {
return false
}
if (mapping.type === 'route') {
this.navigateToRoute(mapping.value as MainRoutes)
return true
}
return false
}
goBack() {
this.navigationStore.goBack()
}
selectObjectAndGoToDashboard(objectId: string, objectTitle: string) {
this.navigationStore.setCurrentObject(objectId, objectTitle)
// Проверяем, что подменю закрыто перед навигацией
this.navigationStore.setCurrentSubmenu(null)
const url = `${MainRoutes.DASHBOARD}?objectId=${encodeURIComponent(objectId)}&objectTitle=${encodeURIComponent(objectTitle)}`
this.router.push(url)
}
}
export const navigationService = new NavigationService()
export function useNavigationService() {
const router = useRouter()
const navigationStore = useNavigationStore()
React.useMemo(() => {
if (!navigationService.isInitialized()) {
navigationService.init(router, navigationStore)
}
}, [router, navigationStore])
return navigationService
}