From 2d0f236fa4f9f0c2cdd8c85134fb9546440124a6 Mon Sep 17 00:00:00 2001 From: sysadminix Date: Mon, 2 Feb 2026 11:00:40 +0300 Subject: [PATCH] first --- .../account/serializers/sensor_serializers.py | 4 +- backend/api/account/views/sensors_views.py | 19 + frontend/app/(auth)/login/page.tsx | 168 ++++++-- frontend/app/(protected)/alerts/page.tsx | 16 +- frontend/app/(protected)/navigation/page.tsx | 67 +-- frontend/app/(protected)/objects/page.tsx | 86 +++- frontend/app/(protected)/reports/page.tsx | 16 +- frontend/components/alerts/AlertsList.tsx | 46 +- frontend/components/alerts/DetectorList.tsx | 3 +- frontend/components/dashboard/AreaChart.tsx | 168 +++++++- frontend/components/dashboard/BarChart.tsx | 181 +++++++- frontend/components/dashboard/ChartCard.tsx | 9 +- frontend/components/dashboard/Dashboard.tsx | 108 ++--- frontend/components/model/ModelViewer.tsx | 399 ++++++++++-------- frontend/components/navigation/AlertMenu.tsx | 4 +- .../components/navigation/DetectorMenu.tsx | 8 +- frontend/components/navigation/Monitoring.tsx | 29 +- .../NotificationDetectorInfo.tsx | 76 +++- frontend/components/reports/ReportsList.tsx | 41 +- frontend/components/ui/Button.tsx | 31 +- frontend/components/ui/LoadingSpinner.tsx | 42 +- frontend/components/ui/Sidebar.tsx | 59 +-- 22 files changed, 1119 insertions(+), 461 deletions(-) diff --git a/backend/api/account/serializers/sensor_serializers.py b/backend/api/account/serializers/sensor_serializers.py index 376d5e0..960ed6a 100644 --- a/backend/api/account/serializers/sensor_serializers.py +++ b/backend/api/account/serializers/sensor_serializers.py @@ -36,7 +36,9 @@ class DetectorSerializer(serializers.ModelSerializer): fields = ('detector_id', 'type', 'detector_type', 'serial_number', 'name', 'object', 'status', 'zone', 'floor', 'notifications') def get_detector_id(self, obj): - return obj.name or f"{obj.sensor_type.code}-{obj.id}" + # Используем serial_number для совместимости с 3D моделью + # Если serial_number нет, используем ID с префиксом + return obj.serial_number or f"sensor_{obj.id}" def get_type(self, obj): sensor_type_mapping = { diff --git a/backend/api/account/views/sensors_views.py b/backend/api/account/views/sensors_views.py index 75ae12d..49c1b76 100644 --- a/backend/api/account/views/sensors_views.py +++ b/backend/api/account/views/sensors_views.py @@ -13,6 +13,7 @@ from api.utils.decorators import handle_exceptions class SensorView(APIView): permission_classes = [IsAuthenticated] serializer_class = DetectorsResponseSerializer + pagination_class = None # Отключаем пагинацию для получения всех датчиков @extend_schema( summary="Получение всех датчиков", @@ -54,7 +55,25 @@ class SensorView(APIView): 'alerts' ).all() + total_count = sensors.count() + print(f"[SensorView] Total sensors in DB: {total_count}") + + # Проверяем уникальность serial_number + serial_numbers = [s.serial_number for s in sensors if s.serial_number] + unique_serials = set(serial_numbers) + print(f"[SensorView] Unique serial_numbers: {len(unique_serials)} out of {len(serial_numbers)}") + + if len(serial_numbers) != len(unique_serials): + from collections import Counter + duplicates = {k: v for k, v in Counter(serial_numbers).items() if v > 1} + print(f"[SensorView] WARNING: Found duplicate serial_numbers: {duplicates}") + serializer = DetectorsResponseSerializer(sensors) + detectors_dict = serializer.data.get('detectors', {}) + + print(f"[SensorView] Serialized detectors count: {len(detectors_dict)}") + print(f"[SensorView] Sample detector_ids: {list(detectors_dict.keys())[:5]}") + return Response(serializer.data, status=status.HTTP_200_OK) except Sensor.DoesNotExist: return Response( diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index 0d365bd..363c6d2 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -71,61 +71,145 @@ const LoginPage = () => { return } + const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 } + const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 } + return ( -
-
+
+ + + {/* Фоновый градиент - многоуровневый */} +
+ + {/* Второй слой градиента */} +
+ + {/* Основные светящиеся орбиты */} +
+
+
+ + {/* Дополнительные акцентные элементы */} +
+
+ + {/* Сетка с градиентом */} +
+ + {/* Диагональные линии */} +
+ + {/* Центральный светящийся элемент */} +
+ + {/* Верхний логотип */} +
AerBIM Logo
-
+ + {/* Карточка формы с улучшенным стилем */} +
+ {/* Верхний градиент на карточке */} +
+
-
-

Авторизация

- +
+

+ Авторизация +

- - +
+ -
-
+ +
-

- - Забыли логин/пароль? - -

+
+

+ + Забыли логин/пароль? + +

+
) diff --git a/frontend/app/(protected)/alerts/page.tsx b/frontend/app/(protected)/alerts/page.tsx index c885c89..1912ae8 100644 --- a/frontend/app/(protected)/alerts/page.tsx +++ b/frontend/app/(protected)/alerts/page.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import Sidebar from '../../../components/ui/Sidebar' +import AnimatedBackground from '../../../components/ui/AnimatedBackground' import useNavigationStore from '../../store/navigationStore' import DetectorList from '../../../components/alerts/DetectorList' import AlertsList from '../../../components/alerts/AlertsList' @@ -141,12 +142,15 @@ const AlertsPage: React.FC = () => { } return ( -
- +
+ +
+ +
-
+
+ + {/* Статистика */} +
+
+
{filteredAlerts.length}
+
Всего
+
+
+
{statusCounts.normal}
+
Норма
+
+
+
{statusCounts.warning}
+
Предупреждения
+
+
+
{statusCounts.critical}
+
Критические
+
+
+
+
) } -export default Dashboard \ No newline at end of file +export default Dashboard diff --git a/frontend/components/model/ModelViewer.tsx b/frontend/components/model/ModelViewer.tsx index c051e45..dc2b4db 100644 --- a/frontend/components/model/ModelViewer.tsx +++ b/frontend/components/model/ModelViewer.tsx @@ -88,6 +88,8 @@ const ModelViewer: React.FC = ({ const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState< { sensorId: string; left: number; top: number; colorHex: string }[] >([]) + // NEW: State for tracking hovered sensor in overlay circles + const [hoveredSensorId, setHoveredSensorId] = useState(null) const handlePan = () => setPanActive(!panActive); @@ -223,6 +225,82 @@ const ModelViewer: React.FC = ({ ); } }; + + // 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 @@ -343,160 +421,168 @@ const ModelViewer: React.FC = ({ }, [onError]) useEffect(() => { - if (!isInitializedRef.current || isDisposedRef.current) { - return - } - - if (!modelPath || modelPath.trim() === '') { - console.warn('[ModelViewer] No model path provided') - // Не вызываем onError для пустого пути - это нормальное состояние при инициализации - setIsLoading(false) - return - } + if (!modelPath || !sceneRef.current || !engineRef.current) return + + const scene = sceneRef.current + + setIsLoading(true) + setLoadingProgress(0) + setShowModel(false) + setModelReady(false) const loadModel = async () => { - if (!sceneRef.current || isDisposedRef.current) { - return - } - - const currentModelPath = modelPath; - console.log('[ModelViewer] Starting model load:', currentModelPath); - - setIsLoading(true) - setLoadingProgress(0) - setShowModel(false) - setModelReady(false) - setPanActive(false) - - const oldMeshes = sceneRef.current.meshes.slice(); - const activeCameraId = sceneRef.current.activeCamera?.uniqueId; - console.log('[ModelViewer] Cleaning up old meshes. Total:', oldMeshes.length); - oldMeshes.forEach(m => { - if (m.uniqueId !== activeCameraId) { - m.dispose(); - } - }); - - console.log('[ModelViewer] Loading GLTF model:', currentModelPath) - - // UI элемент загрузчика (есть эффект замедленности) - const progressInterval = setInterval(() => { - setLoadingProgress(prev => { - if (prev >= 90) { - clearInterval(progressInterval) - return 90 - } - return prev + Math.random() * 15 - }) - }, 100) - try { - console.log('[ModelViewer] Calling ImportMeshAsync with path:', currentModelPath); + console.log('[ModelViewer] Starting to load model:', modelPath) - // Проверим доступность файла через fetch - try { - const testResponse = await fetch(currentModelPath, { method: 'HEAD' }); - console.log('[ModelViewer] File availability check:', { - url: currentModelPath, - status: testResponse.status, - statusText: testResponse.statusText, - ok: testResponse.ok - }); - } catch (fetchError) { - console.error('[ModelViewer] File fetch error:', fetchError); + // 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 } - - const result = await ImportMeshAsync(currentModelPath, sceneRef.current) - console.log('[ModelViewer] ImportMeshAsync completed successfully'); - console.log('[ModelViewer] Import result:', { + + console.log('[ModelViewer] Model loaded successfully:', { meshesCount: result.meshes.length, particleSystemsCount: result.particleSystems.length, skeletonsCount: result.skeletons.length, animationGroupsCount: result.animationGroups.length - }); - - if (isDisposedRef.current || modelPath !== currentModelPath) { - console.log('[ModelViewer] Model loading aborted - model changed during load') - clearInterval(progressInterval) - setIsLoading(false) - return; - } + }) importedMeshesRef.current = result.meshes - clearInterval(progressInterval) - setLoadingProgress(100) - - console.log('[ModelViewer] GLTF Model loaded successfully!', result) - if (result.meshes.length > 0) { - const boundingBox = result.meshes[0].getHierarchyBoundingVectors() - const size = boundingBox.max.subtract(boundingBox.min) - const maxDimension = Math.max(size.x, size.y, size.z) - const camera = sceneRef.current!.activeCamera as ArcRotateCamera - camera.radius = maxDimension * 2 - camera.target = result.meshes[0].position - - importedMeshesRef.current = result.meshes - setModelReady(true) - onModelLoaded?.({ meshes: result.meshes, boundingBox: { - min: boundingBox.min, - max: boundingBox.max - } + 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 }, + }, }) + } - // Плавное появление модели - setTimeout(() => { - if (!isDisposedRef.current && modelPath === currentModelPath) { - setShowModel(true) - setIsLoading(false) - } else { - console.log('Model display aborted - model changed during animation') - } - }, 500) - } else { - console.warn('No meshes found in model') - onError?.('В модели не найдена геометрия') - setIsLoading(false) - } - } catch (error) { - clearInterval(progressInterval) - if (!isDisposedRef.current && modelPath === currentModelPath) { - console.error('Error loading GLTF model:', error) - const errorMessage = error instanceof Error ? error.message : String(error) - onError?.(`Ошибка загрузки модели: ${errorMessage}`) - } else { - console.log('Error occurred but loading was aborted - model changed') - } + 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) } } - // Загрузка модлеи начинается после появления спиннера - requestIdleCallback(() => loadModel(), { timeout: 50 }) - }, [modelPath, onError, onModelLoaded]) + loadModel() + }, [modelPath, onModelLoaded, onError]) - useEffect(() => { - if (!sceneRef.current || isDisposedRef.current || !modelReady) return + useEffect(() => { + if (!highlightAllSensors || focusSensorId || !modelReady) { + setAllSensorsOverlayCircles([]) + return + } - if (highlightAllSensors) { - const allMeshes = importedMeshesRef.current || [] - const sensorMeshes = collectSensorMeshes(allMeshes) - applyHighlightToMeshes( - highlightLayerRef.current, - highlightedMeshesRef, - sensorMeshes, - mesh => { - const sid = getSensorIdFromMesh(mesh) - const status = sid ? sensorStatusMap?.[sid] : undefined - return statusToColor3(status ?? null) - }, - ) + 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) @@ -528,12 +614,14 @@ const ModelViewer: React.FC = ({ } 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', }) @@ -593,7 +681,6 @@ const ModelViewer: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [focusSensorId, modelReady, highlightAllSensors]) - // Включение выбора на основе взаимодействия с моделью только при готовности модели и включении выбора сенсоров useEffect(() => { const scene = sceneRef.current if (!scene || !modelReady || !isSensorSelectionEnabled) return @@ -621,7 +708,6 @@ const ModelViewer: React.FC = ({ } }, [modelReady, isSensorSelectionEnabled, onSensorPick]) - // Расчет позиции оверлея const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => { if (!sceneRef.current || !mesh) return null const scene = sceneRef.current @@ -644,49 +730,12 @@ const ModelViewer: React.FC = ({ } }, []) - // Позиция оверлея изначально useEffect(() => { if (!chosenMeshRef.current || !overlayData) return const pos = computeOverlayPosition(chosenMeshRef.current) setOverlayPos(pos) }, [overlayData, computeOverlayPosition]) - useEffect(() => { - const scene = sceneRef.current - const engine = engineRef.current - if (!scene || !engine || !modelReady) { - setAllSensorsOverlayCircles([]) - return - } - if (!highlightAllSensors || focusSensorId || !sensorStatusMap) { - 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 (!sceneRef.current || !chosenMeshRef.current || !overlayData) return const scene = sceneRef.current @@ -767,13 +816,19 @@ const ModelViewer: React.FC = ({ /> )} + {/* UPDATED: Interactive overlay circles with hover effects */} {allSensorsOverlayCircles.map(circle => { const size = 36 const radius = size / 2 const fill = hexWithAlpha(circle.colorHex, 0.2) + const isHovered = hoveredSensorId === circle.sensorId + return (
handleOverlayCircleClick(circle.sensorId)} + onMouseEnter={() => setHoveredSensorId(circle.sensorId)} + onMouseLeave={() => setHoveredSensorId(null)} style={{ position: 'absolute', left: circle.left - radius, @@ -783,8 +838,16 @@ const ModelViewer: React.FC = ({ borderRadius: '9999px', border: `2px solid ${circle.colorHex}`, backgroundColor: fill, - pointerEvents: 'none', + 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}`} /> ) })} diff --git a/frontend/components/navigation/AlertMenu.tsx b/frontend/components/navigation/AlertMenu.tsx index 3ff2033..a301b37 100644 --- a/frontend/components/navigation/AlertMenu.tsx +++ b/frontend/components/navigation/AlertMenu.tsx @@ -181,13 +181,13 @@ const AlertMenu: React.FC = ({ alert, isOpen, onClose, getStatus
{alert.detector_name}
- -
- - - -