New api and zone management; highligh occlusion and highlighAll functionality; improved search in reports and alerts history + autofill; refactored alert panel
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -96,4 +96,5 @@ frontend/data/*.json
|
|||||||
|
|
||||||
# Demo seed data: ignore demo mux outputs and the seeding command
|
# Demo seed data: ignore demo mux outputs and the seeding command
|
||||||
backend/api/management/commands/seed_demo_data.py
|
backend/api/management/commands/seed_demo_data.py
|
||||||
|
backend/api/management/commands/recreate_alerts.py
|
||||||
backend/data/multiplexors/DemoMux.csv
|
backend/data/multiplexors/DemoMux.csv
|
||||||
@@ -9,10 +9,11 @@ class AlertSerializer(serializers.ModelSerializer):
|
|||||||
object = serializers.SerializerMethodField()
|
object = serializers.SerializerMethodField()
|
||||||
metric_value = serializers.SerializerMethodField()
|
metric_value = serializers.SerializerMethodField()
|
||||||
detector_type = serializers.SerializerMethodField()
|
detector_type = serializers.SerializerMethodField()
|
||||||
|
detector_id = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Alert
|
model = Alert
|
||||||
fields = ('id', 'name', 'object', 'metric_value', 'detector_type', 'message', 'severity', 'created_at', 'resolved')
|
fields = ('id', 'name', 'object', 'metric_value', 'detector_type', 'detector_id', 'message', 'severity', 'created_at', 'resolved')
|
||||||
|
|
||||||
@extend_schema_field(OpenApiTypes.STR)
|
@extend_schema_field(OpenApiTypes.STR)
|
||||||
def get_name(self, obj) -> str:
|
def get_name(self, obj) -> str:
|
||||||
@@ -38,3 +39,9 @@ class AlertSerializer(serializers.ModelSerializer):
|
|||||||
if sensor_type is None:
|
if sensor_type is None:
|
||||||
return ''
|
return ''
|
||||||
return (getattr(sensor_type, 'code', '') or '').upper()
|
return (getattr(sensor_type, 'code', '') or '').upper()
|
||||||
|
|
||||||
|
@extend_schema_field(OpenApiTypes.STR)
|
||||||
|
def get_detector_id(self, obj) -> str:
|
||||||
|
if hasattr(obj, 'sensor') and obj.sensor:
|
||||||
|
return obj.sensor.name or f"{obj.sensor.sensor_type.code}-{obj.sensor.id}"
|
||||||
|
return ""
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class ZoneSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Zone
|
model = Zone
|
||||||
fields = ('id', 'name', 'sensors')
|
fields = ('id', 'name', 'floor', 'image_path', 'model_path', 'order', 'sensors')
|
||||||
|
|
||||||
class ObjectSerializer(serializers.ModelSerializer):
|
class ObjectSerializer(serializers.ModelSerializer):
|
||||||
zones = ZoneSerializer(many=True, read_only=True)
|
zones = ZoneSerializer(many=True, read_only=True)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from .views.objects_views import ObjectView
|
|||||||
from .views.sensors_views import SensorView
|
from .views.sensors_views import SensorView
|
||||||
from .views.alert_views import AlertView, ReportView
|
from .views.alert_views import AlertView, ReportView
|
||||||
from .views.dashboard_views import DashboardView
|
from .views.dashboard_views import DashboardView
|
||||||
|
from .views.zones_views import ZoneView
|
||||||
from drf_spectacular.views import (
|
from drf_spectacular.views import (
|
||||||
SpectacularAPIView,
|
SpectacularAPIView,
|
||||||
SpectacularSwaggerView,
|
SpectacularSwaggerView,
|
||||||
@@ -36,4 +37,6 @@ urlpatterns = [
|
|||||||
path("get-reports/", ReportView.as_view({'post': 'get_reports'}), name="reports"),
|
path("get-reports/", ReportView.as_view({'post': 'get_reports'}), name="reports"),
|
||||||
|
|
||||||
path("get-dashboard/", DashboardView.as_view(), name="dashboard"),
|
path("get-dashboard/", DashboardView.as_view(), name="dashboard"),
|
||||||
|
|
||||||
|
path("get-zones/", ZoneView.as_view(), name="zones"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -116,7 +116,11 @@ class ReportView(ViewSet):
|
|||||||
@extend_schema(
|
@extend_schema(
|
||||||
summary="Генерация отчета",
|
summary="Генерация отчета",
|
||||||
description="Генерирует отчет в выбранном формате (PDF или CSV)",
|
description="Генерирует отчет в выбранном формате (PDF или CSV)",
|
||||||
request={'application/json': {'type': 'object', 'properties': {'report_format': {'type': 'string', 'enum': ['pdf', 'csv']}}}},
|
request={'application/json': {'type': 'object', 'properties': {
|
||||||
|
'report_format': {'type': 'string', 'enum': ['pdf', 'csv']},
|
||||||
|
'hours': {'type': 'integer', 'description': 'Количество часов для фильтрации по времени'},
|
||||||
|
'detector_ids': {'type': 'array', 'items': {'type': 'string'}, 'description': 'Список ID датчиков для фильтрации (может быть числом или строкой вида "GLE-79")'}
|
||||||
|
}}},
|
||||||
methods=['POST'],
|
methods=['POST'],
|
||||||
responses={
|
responses={
|
||||||
200: OpenApiResponse(
|
200: OpenApiResponse(
|
||||||
@@ -153,6 +157,10 @@ class ReportView(ViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Получаем параметры фильтрации
|
||||||
|
hours = request.data.get('hours')
|
||||||
|
detector_ids = request.data.get('detector_ids', [])
|
||||||
|
|
||||||
alerts = Alert.objects.select_related(
|
alerts = Alert.objects.select_related(
|
||||||
'sensor',
|
'sensor',
|
||||||
'sensor__signal_format',
|
'sensor__signal_format',
|
||||||
@@ -163,6 +171,35 @@ class ReportView(ViewSet):
|
|||||||
'sensor__zones__object'
|
'sensor__zones__object'
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
# Фильтрация по времени (если указано количество часов)
|
||||||
|
if hours and isinstance(hours, (int, float)) and hours > 0:
|
||||||
|
from datetime import timedelta
|
||||||
|
cutoff_time = timezone.now() - timedelta(hours=hours)
|
||||||
|
alerts = alerts.filter(created_at__gte=cutoff_time)
|
||||||
|
|
||||||
|
# Фильтрация по датчикам (если указаны ID)
|
||||||
|
if detector_ids and isinstance(detector_ids, list) and len(detector_ids) > 0:
|
||||||
|
# Конвертируем строковые ID в числовые ID базы данных
|
||||||
|
numeric_ids = []
|
||||||
|
for detector_id in detector_ids:
|
||||||
|
if isinstance(detector_id, (int, float)):
|
||||||
|
numeric_ids.append(int(detector_id))
|
||||||
|
elif isinstance(detector_id, str):
|
||||||
|
# Парсим строковые ID вида "GLE-79" или "GA-123"
|
||||||
|
try:
|
||||||
|
if '-' in detector_id:
|
||||||
|
# Формат "TYPE-ID" - извлекаем числовую часть
|
||||||
|
numeric_ids.append(int(detector_id.split('-')[-1]))
|
||||||
|
else:
|
||||||
|
# Предполагаем, что это просто число в строковом формате
|
||||||
|
numeric_ids.append(int(detector_id))
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
# Пропускаем некорректные ID
|
||||||
|
continue
|
||||||
|
|
||||||
|
if numeric_ids:
|
||||||
|
alerts = alerts.filter(sensor_id__in=numeric_ids)
|
||||||
|
|
||||||
# текущая дата для имени файла
|
# текущая дата для имени файла
|
||||||
timestamp = timezone.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = timezone.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ class DashboardView(APIView):
|
|||||||
|
|
||||||
if time_period not in ['720', '168', '72', '24']:
|
if time_period not in ['720', '168', '72', '24']:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Неверный период. Допустимые значения: 720, 168, 72, 24"},
|
- {"error": "Неверный период. Допустимые значения: 720, 168, 72, 24"},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
+ {"error": "Неверный период. Допустимые значения: 24, 72, 168, 720"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# определяем начальную дату
|
# определяем начальную дату
|
||||||
|
|||||||
66
backend/api/account/views/zones_views.py
Normal file
66
backend/api/account/views/zones_views.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample
|
||||||
|
|
||||||
|
from api.account.serializers.objects_serializers import ZoneSerializer
|
||||||
|
from sitemanagement.models import Zone
|
||||||
|
|
||||||
|
from api.utils.decorators import handle_exceptions
|
||||||
|
|
||||||
|
@extend_schema(tags=['Зоны'])
|
||||||
|
class ZoneView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
serializer_class = ZoneSerializer
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="Получение зон для указанного объекта",
|
||||||
|
description="Возвращает список зон для объекта по query-параметру objectId",
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(
|
||||||
|
response=ZoneSerializer,
|
||||||
|
description="Зоны успешно получены",
|
||||||
|
examples=[
|
||||||
|
OpenApiExample(
|
||||||
|
'Успешный ответ',
|
||||||
|
value=[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Зона 1",
|
||||||
|
"floor": 1,
|
||||||
|
"image_path": "test_image_2.png",
|
||||||
|
"model_path": "/static-models/AerBIM-Monitor_ASM-HTViewer_Expo2017Astana_20250908_L_+76190.glb",
|
||||||
|
"order": 0,
|
||||||
|
"sensors": [
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"name": "Датчик 1",
|
||||||
|
"serial_number": "GA-123",
|
||||||
|
"sensor_type": "Тип датчика 1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@handle_exceptions
|
||||||
|
def get(self, request):
|
||||||
|
"""Получение всех зон для указанного объекта"""
|
||||||
|
object_id = request.query_params.get('objectId') or request.query_params.get('object_id') or request.query_params.get('object')
|
||||||
|
if not object_id:
|
||||||
|
return Response({"error": "objectId query parameter is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
try:
|
||||||
|
zones = Zone.objects.filter(object_id=object_id).prefetch_related(
|
||||||
|
'sensors',
|
||||||
|
'sensors__sensor_type'
|
||||||
|
).order_by('order', 'name')
|
||||||
|
|
||||||
|
serializer = ZoneSerializer(zones, many=True)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
except Zone.DoesNotExist:
|
||||||
|
return Response({"error": "Зоны не найдены"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
@@ -76,9 +76,9 @@ class ObjectAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(Zone)
|
@admin.register(Zone)
|
||||||
class ZoneAdmin(admin.ModelAdmin):
|
class ZoneAdmin(admin.ModelAdmin):
|
||||||
list_display = ('object', 'floor', 'name')
|
list_display = ('object', 'floor', 'order', 'name', 'image_path', 'model_path')
|
||||||
list_filter = ('object', 'floor')
|
list_filter = ('object', 'floor', 'order')
|
||||||
search_fields = ('object__title', 'name')
|
search_fields = ('object__title', 'name', 'image_path', 'model_path')
|
||||||
list_per_page = 10
|
list_per_page = 10
|
||||||
list_max_show_all = 100
|
list_max_show_all = 100
|
||||||
list_display_links = ('object',)
|
list_display_links = ('object',)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-12-22 17:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sitemanagement', '0007_alter_zone_options_zone_floor'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='zone',
|
||||||
|
options={'ordering': ['object', 'floor', 'order', 'name'], 'verbose_name': 'Зона', 'verbose_name_plural': 'Зоны'},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='zone',
|
||||||
|
name='image_path',
|
||||||
|
field=models.CharField(blank=True, default='test_image.png', help_text="Например 'public/images/test_image.png' или имя файла из public/images", max_length=255, verbose_name='Путь к изображению'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='zone',
|
||||||
|
name='model_path',
|
||||||
|
field=models.CharField(blank=True, help_text="Например 'frontend/assets/big-models/model.glb' или относительный путь к модели", max_length=255, null=True, verbose_name='Путь к 3D модели'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='zone',
|
||||||
|
name='order',
|
||||||
|
field=models.PositiveSmallIntegerField(default=0, verbose_name='Порядок'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-12-22 17:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sitemanagement', '0008_alter_zone_options_zone_image_path_zone_model_path_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='zone',
|
||||||
|
name='image_path',
|
||||||
|
field=models.CharField(blank=True, default='test_image.png', help_text="Например 'public/images/test_image.png' или имя файла из public/images", max_length=255, verbose_name='Путь к изображению'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-12-24 19:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sitemanagement', '0009_alter_zone_image_path'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='zone',
|
||||||
|
name='image_path',
|
||||||
|
field=models.CharField(blank=True, default='test_image.png', help_text="Например 'test_image_2.png'", max_length=255, verbose_name='Путь к изображению'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='zone',
|
||||||
|
name='model_path',
|
||||||
|
field=models.CharField(blank=True, help_text="Например '/static-models/AerBIM-Monitor_ASM-HTViewer_Expo2017Astana_20250908_L_+76190.glb'", max_length=255, null=True, verbose_name='Путь к 3D модели'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -175,6 +175,9 @@ class Zone(models.Model):
|
|||||||
object = models.ForeignKey(Object, on_delete=models.CASCADE, related_name="zones", verbose_name="Объект")
|
object = models.ForeignKey(Object, on_delete=models.CASCADE, related_name="zones", verbose_name="Объект")
|
||||||
name = models.CharField(max_length=255, verbose_name="Название")
|
name = models.CharField(max_length=255, verbose_name="Название")
|
||||||
floor = models.PositiveSmallIntegerField(verbose_name="Этаж")
|
floor = models.PositiveSmallIntegerField(verbose_name="Этаж")
|
||||||
|
image_path = models.CharField(max_length=255, verbose_name="Путь к изображению", blank=True, default="test_image.png", help_text="Например 'test_image_2.png'")
|
||||||
|
model_path = models.CharField(max_length=255, verbose_name="Путь к 3D модели", blank=True, null=True, help_text="Например '/static-models/AerBIM-Monitor_ASM-HTViewer_Expo2017Astana_20250908_L_+76190.glb'")
|
||||||
|
order = models.PositiveSmallIntegerField(default=0, verbose_name="Порядок")
|
||||||
sensors = models.ManyToManyField(Sensor, related_name="zones", verbose_name="Датчики")
|
sensors = models.ManyToManyField(Sensor, related_name="zones", verbose_name="Датчики")
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
@@ -182,7 +185,7 @@ class Zone(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Зона"
|
verbose_name = "Зона"
|
||||||
verbose_name_plural = "Зоны"
|
verbose_name_plural = "Зоны"
|
||||||
ordering = ["object", "floor", "name"] # сортировка сначала по объекту, потом по этажу
|
ordering = ["object", "floor", "order", "name"] # сортировка по объекту, этажу, порядку, названию
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { useRouter, useSearchParams } from 'next/navigation'
|
|||||||
import Sidebar from '../../../components/ui/Sidebar'
|
import Sidebar from '../../../components/ui/Sidebar'
|
||||||
import useNavigationStore from '../../store/navigationStore'
|
import useNavigationStore from '../../store/navigationStore'
|
||||||
import DetectorList from '../../../components/alerts/DetectorList'
|
import DetectorList from '../../../components/alerts/DetectorList'
|
||||||
|
import AlertsList from '../../../components/alerts/AlertsList'
|
||||||
import ExportMenu from '../../../components/ui/ExportMenu'
|
import ExportMenu from '../../../components/ui/ExportMenu'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
|
|
||||||
const AlertsPage: React.FC = () => {
|
const AlertsPage: React.FC = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const { currentObject, setCurrentObject } = useNavigationStore()
|
const { currentObject, setCurrentObject, selectedDetector } = useNavigationStore()
|
||||||
const [selectedDetectors, setSelectedDetectors] = useState<number[]>([])
|
const [selectedDetectors, setSelectedDetectors] = useState<number[]>([])
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
|
|
||||||
@@ -22,10 +23,9 @@ const AlertsPage: React.FC = () => {
|
|||||||
timestamp: string
|
timestamp: string
|
||||||
acknowledged: boolean
|
acknowledged: boolean
|
||||||
priority: string
|
priority: string
|
||||||
detector_id?: number
|
detector_id?: string
|
||||||
detector_name?: string
|
detector_name?: string
|
||||||
location?: string
|
location?: string
|
||||||
object?: string
|
|
||||||
}
|
}
|
||||||
const [alerts, setAlerts] = useState<AlertItem[]>([])
|
const [alerts, setAlerts] = useState<AlertItem[]>([])
|
||||||
|
|
||||||
@@ -40,6 +40,13 @@ const AlertsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject])
|
}, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject])
|
||||||
|
|
||||||
|
// Auto-select detector when it comes from navigation store
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDetector && !selectedDetectors.includes(selectedDetector.detector_id)) {
|
||||||
|
setSelectedDetectors(prev => [...prev, selectedDetector.detector_id])
|
||||||
|
}
|
||||||
|
}, [selectedDetector, selectedDetectors])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadAlerts = async () => {
|
const loadAlerts = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -55,7 +62,11 @@ const AlertsPage: React.FC = () => {
|
|||||||
})
|
})
|
||||||
const data = Array.isArray(payload?.data) ? payload.data : (payload?.data?.alerts || [])
|
const data = Array.isArray(payload?.data) ? payload.data : (payload?.data?.alerts || [])
|
||||||
console.log('[AlertsPage] parsed alerts:', data)
|
console.log('[AlertsPage] parsed alerts:', data)
|
||||||
setAlerts(data as AlertItem[])
|
console.log('[AlertsPage] Sample alert structure:', data[0])
|
||||||
|
console.log('[AlertsPage] Alerts with detector_id:', data.filter((alert: any) => alert.detector_id).length)
|
||||||
|
|
||||||
|
console.log('[AlertsPage] using transformed alerts:', data)
|
||||||
|
setAlerts(data)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load alerts:', e)
|
console.error('Failed to load alerts:', e)
|
||||||
}
|
}
|
||||||
@@ -177,87 +188,15 @@ const AlertsPage: React.FC = () => {
|
|||||||
objectId={objectId || undefined}
|
objectId={objectId || undefined}
|
||||||
selectedDetectors={selectedDetectors}
|
selectedDetectors={selectedDetectors}
|
||||||
onDetectorSelect={handleDetectorSelect}
|
onDetectorSelect={handleDetectorSelect}
|
||||||
|
initialSearchTerm={selectedDetector?.detector_id.toString() || ''}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* История тревог */}
|
{/* История тревог */}
|
||||||
<div className="mt-6 bg-[#161824] rounded-[20px] p-6">
|
<div className="mt-8">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<AlertsList
|
||||||
<h2 className="text-xl font-semibold text-white">История тревог</h2>
|
alerts={alerts}
|
||||||
<span className="text-sm text-gray-400">Всего: {alerts.length}</span>
|
onAcknowledgeToggle={handleAcknowledgeToggle}
|
||||||
</div>
|
initialSearchTerm={selectedDetector?.detector_id.toString() || ''}
|
||||||
<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">Детектор</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>
|
|
||||||
<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>
|
|
||||||
{alerts.map((item) => (
|
|
||||||
<tr key={item.id} className="border-b border-gray-700 hover:bg-gray-800/50 transition-colors">
|
|
||||||
<td className="py-4">
|
|
||||||
<div className="text-sm font-medium text-white">{item.detector_name || 'Детектор'}</div>
|
|
||||||
{item.detector_id ? (
|
|
||||||
<div className="text-sm 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: item.type === 'critical' ? '#b3261e' : item.type === 'warning' ? '#fd7c22' : '#00ff00' }}
|
|
||||||
></div>
|
|
||||||
<span className="text-sm text-gray-300">
|
|
||||||
{item.type === 'critical' ? 'Критический' : item.type === 'warning' ? 'Предупреждение' : 'Информация'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-4">
|
|
||||||
<div className="text-sm text-white">{item.message}</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-4">
|
|
||||||
<div className="text-sm text-white">{item.location || '-'}</div>
|
|
||||||
</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' ? '#b3261e' : item.priority === 'medium' ? '#fd7c22' : '#00ff00' }}
|
|
||||||
>
|
|
||||||
{item.priority === 'high' ? 'Высокий' : item.priority === 'medium' ? 'Средний' : 'Низкий'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-4">
|
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
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={() => handleAcknowledgeToggle(item.id)}
|
|
||||||
className="ml-2 inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-[#2a2e3e] text-white hover:bg-[#353a4d]"
|
|
||||||
>
|
|
||||||
{item.acknowledged ? 'Снять' : 'Подтвердить'}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td className="py-4">
|
|
||||||
<div className="text-sm text-gray-300">{new Date(item.timestamp).toLocaleString('ru-RU')}</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{alerts.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="py-8 text-center text-gray-400">Записей не найдено</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -107,6 +107,41 @@ const NavigationPage: React.FC = () => {
|
|||||||
const [detectorsError, setDetectorsError] = useState<string | null>(null)
|
const [detectorsError, setDetectorsError] = useState<string | null>(null)
|
||||||
const [modelError, setModelError] = useState<string | null>(null)
|
const [modelError, setModelError] = useState<string | null>(null)
|
||||||
const [isModelReady, setIsModelReady] = useState(false)
|
const [isModelReady, setIsModelReady] = useState(false)
|
||||||
|
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
||||||
|
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDetector === null && selectedAlert === null) {
|
||||||
|
setFocusedSensorId(null);
|
||||||
|
}
|
||||||
|
}, [selectedDetector, selectedAlert]);
|
||||||
|
|
||||||
|
// Управление выделением всех сенсоров при открытии/закрытии меню Sensors
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady)
|
||||||
|
if (showSensors && isModelReady) {
|
||||||
|
// При открытии меню Sensors - выделяем все сенсоры (только если модель готова)
|
||||||
|
console.log('[NavigationPage] Setting highlightAllSensors to TRUE')
|
||||||
|
setHighlightAllSensors(true)
|
||||||
|
setFocusedSensorId(null)
|
||||||
|
} else if (!showSensors) {
|
||||||
|
// При закрытии меню Sensors - сбрасываем выделение
|
||||||
|
console.log('[NavigationPage] Setting highlightAllSensors to FALSE')
|
||||||
|
setHighlightAllSensors(false)
|
||||||
|
}
|
||||||
|
}, [showSensors, isModelReady])
|
||||||
|
|
||||||
|
// Дополнительный эффект для задержки выделения сенсоров при открытии меню
|
||||||
|
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 urlObjectId = searchParams.get('objectId')
|
||||||
const urlObjectTitle = searchParams.get('objectTitle')
|
const urlObjectTitle = searchParams.get('objectTitle')
|
||||||
@@ -133,10 +168,10 @@ const NavigationPage: React.FC = () => {
|
|||||||
}, [selectedModelPath]);
|
}, [selectedModelPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (urlObjectId && urlObjectTitle && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
||||||
setCurrentObject(urlObjectId, urlObjectTitle)
|
setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined)
|
||||||
}
|
}
|
||||||
}, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject])
|
}, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDetectors = async () => {
|
const loadDetectors = async () => {
|
||||||
@@ -177,18 +212,28 @@ const NavigationPage: React.FC = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedDetector?.detector_id === detector.detector_id && showDetectorMenu) {
|
if (selectedDetector?.serial_number === detector.serial_number && showDetectorMenu) {
|
||||||
setShowDetectorMenu(false)
|
closeDetectorMenu()
|
||||||
setSelectedDetector(null)
|
|
||||||
} else {
|
} else {
|
||||||
setSelectedDetector(detector)
|
setSelectedDetector(detector)
|
||||||
setShowDetectorMenu(true)
|
setShowDetectorMenu(true)
|
||||||
|
setFocusedSensorId(detector.serial_number)
|
||||||
|
setShowAlertMenu(false)
|
||||||
|
setSelectedAlert(null)
|
||||||
|
// При открытии меню детектора - сбрасываем множественное выделение
|
||||||
|
setHighlightAllSensors(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeDetectorMenu = () => {
|
const closeDetectorMenu = () => {
|
||||||
setShowDetectorMenu(false)
|
setShowDetectorMenu(false)
|
||||||
setSelectedDetector(null)
|
setSelectedDetector(null)
|
||||||
|
setFocusedSensorId(null)
|
||||||
|
setSelectedAlert(null)
|
||||||
|
// При закрытии меню детектора из Sensors - выделяем все сенсоры снова
|
||||||
|
if (showSensors) {
|
||||||
|
setHighlightAllSensors(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNotificationClick = (notification: NotificationType) => {
|
const handleNotificationClick = (notification: NotificationType) => {
|
||||||
@@ -209,6 +254,12 @@ const NavigationPage: React.FC = () => {
|
|||||||
const closeAlertMenu = () => {
|
const closeAlertMenu = () => {
|
||||||
setShowAlertMenu(false)
|
setShowAlertMenu(false)
|
||||||
setSelectedAlert(null)
|
setSelectedAlert(null)
|
||||||
|
setFocusedSensorId(null)
|
||||||
|
setSelectedDetector(null)
|
||||||
|
// При закрытии меню алерта из Sensors - выделяем все сенсоры снова
|
||||||
|
if (showSensors) {
|
||||||
|
setHighlightAllSensors(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAlertClick = (alert: AlertType) => {
|
const handleAlertClick = (alert: AlertType) => {
|
||||||
@@ -219,19 +270,86 @@ const NavigationPage: React.FC = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (detector) {
|
if (detector) {
|
||||||
console.log('[NavigationPage] Found detector for alert:', detector)
|
if (selectedAlert?.id === alert.id && showAlertMenu) {
|
||||||
|
closeAlertMenu()
|
||||||
setSelectedAlert(alert)
|
} else {
|
||||||
setShowAlertMenu(true)
|
setSelectedAlert(alert)
|
||||||
|
setShowAlertMenu(true)
|
||||||
setSelectedDetector(detector)
|
setFocusedSensorId(detector.serial_number)
|
||||||
|
setShowDetectorMenu(false)
|
||||||
console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name)
|
setSelectedDetector(null)
|
||||||
|
// При открытии меню алерта - сбрасываем множественное выделение
|
||||||
|
setHighlightAllSensors(false)
|
||||||
|
console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id)
|
console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSensorSelection = (serialNumber: string | null) => {
|
||||||
|
if (serialNumber === null) {
|
||||||
|
setFocusedSensorId(null);
|
||||||
|
closeDetectorMenu();
|
||||||
|
closeAlertMenu();
|
||||||
|
// If we're in Sensors menu and no sensor is selected, highlight all sensors
|
||||||
|
if (showSensors) {
|
||||||
|
setHighlightAllSensors(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusedSensorId === serialNumber) {
|
||||||
|
setFocusedSensorId(null);
|
||||||
|
closeDetectorMenu();
|
||||||
|
closeAlertMenu();
|
||||||
|
// If we're in Sensors menu and deselected the current sensor, highlight all sensors
|
||||||
|
if (showSensors) {
|
||||||
|
setHighlightAllSensors(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// При выборе конкретного сенсора - сбрасываем множественное выделение
|
||||||
|
setHighlightAllSensors(false)
|
||||||
|
|
||||||
|
const detector = Object.values(detectorsData?.detectors || {}).find(
|
||||||
|
(d) => d.serial_number === serialNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (detector) {
|
||||||
|
if (showFloorNavigation || showListOfDetectors) {
|
||||||
|
handleDetectorMenuClick(detector);
|
||||||
|
} else if (detector.notifications && detector.notifications.length > 0) {
|
||||||
|
const sortedNotifications = [...detector.notifications].sort((a, b) => {
|
||||||
|
const priorityOrder: { [key: string]: number } = { critical: 0, warning: 1, info: 2 };
|
||||||
|
return priorityOrder[a.priority.toLowerCase()] - priorityOrder[b.priority.toLowerCase()];
|
||||||
|
});
|
||||||
|
const notification = sortedNotifications[0];
|
||||||
|
const alert: AlertType = {
|
||||||
|
...notification,
|
||||||
|
detector_id: detector.detector_id,
|
||||||
|
detector_name: detector.name,
|
||||||
|
location: detector.location,
|
||||||
|
object: detector.object,
|
||||||
|
status: detector.status,
|
||||||
|
type: notification.type || 'info',
|
||||||
|
};
|
||||||
|
handleAlertClick(alert);
|
||||||
|
} else {
|
||||||
|
handleDetectorMenuClick(detector);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFocusedSensorId(null);
|
||||||
|
closeDetectorMenu();
|
||||||
|
closeAlertMenu();
|
||||||
|
// If we're in Sensors menu and no valid detector found, highlight all sensors
|
||||||
|
if (showSensors) {
|
||||||
|
setHighlightAllSensors(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusText = (status: string) => {
|
const getStatusText = (status: string) => {
|
||||||
const s = (status || '').toLowerCase()
|
const s = (status || '').toLowerCase()
|
||||||
switch (s) {
|
switch (s) {
|
||||||
@@ -371,9 +489,9 @@ const NavigationPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<nav className="flex items-center gap-2 text-sm">
|
<nav className="flex items-center gap-2 text-sm">
|
||||||
<span className="text-gray-400">Дашборд</span>
|
<span className="text-gray-400">Дашборд</span>
|
||||||
<span className="text-gray-600">/</span>
|
<span className="text-gray-600">{'/'}</span>
|
||||||
<span className="text-white">{objectTitle || 'Объект'}</span>
|
<span className="text-white">{objectTitle || 'Объект'}</span>
|
||||||
<span className="text-gray-600">/</span>
|
<span className="text-gray-600">{'/'}</span>
|
||||||
<span className="text-white">Навигация</span>
|
<span className="text-white">Навигация</span>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -425,7 +543,11 @@ const NavigationPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
onModelLoaded={handleModelLoaded}
|
onModelLoaded={handleModelLoaded}
|
||||||
onError={handleModelError}
|
onError={handleModelError}
|
||||||
focusSensorId={selectedDetector?.serial_number ?? selectedAlert?.detector_id?.toString() ?? null}
|
activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null}
|
||||||
|
focusSensorId={focusedSensorId}
|
||||||
|
highlightAllSensors={highlightAllSensors}
|
||||||
|
isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors}
|
||||||
|
onSensorPick={handleSensorSelection}
|
||||||
renderOverlay={({ anchor }) => (
|
renderOverlay={({ anchor }) => (
|
||||||
<>
|
<>
|
||||||
{selectedAlert && showAlertMenu && anchor ? (
|
{selectedAlert && showAlertMenu && anchor ? (
|
||||||
@@ -449,12 +571,12 @@ const NavigationPage: React.FC = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,27 @@ import { useRouter } from 'next/navigation'
|
|||||||
const transformRawToObjectData = (raw: any): ObjectData => {
|
const transformRawToObjectData = (raw: any): ObjectData => {
|
||||||
const rawId = raw?.id ?? raw?.object_id ?? raw?.uuid ?? raw?.name
|
const rawId = raw?.id ?? raw?.object_id ?? raw?.uuid ?? raw?.name
|
||||||
const object_id = typeof rawId === 'number' ? `object_${rawId}` : String(rawId ?? '')
|
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 {
|
return {
|
||||||
object_id,
|
object_id,
|
||||||
title: raw?.title ?? `Объект ${object_id}`,
|
title: deriveTitle(),
|
||||||
description: raw?.description ?? `Описание объекта ${raw?.title ?? object_id}`,
|
description: raw?.description ?? `Описание объекта ${raw?.title ?? object_id}`,
|
||||||
image: raw?.image ?? '/images/test_image.png',
|
image: raw?.image ?? null,
|
||||||
location: raw?.location ?? raw?.address ?? 'Не указано',
|
location: raw?.location ?? raw?.address ?? 'Не указано',
|
||||||
floors: Number(raw?.floors ?? 0),
|
floors: Number(raw?.floors ?? 0),
|
||||||
area: String(raw?.area ?? ''),
|
area: String(raw?.area ?? ''),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const ReportsPage: React.FC = () => {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const { currentObject, setCurrentObject } = useNavigationStore()
|
const { currentObject, setCurrentObject, selectedDetector } = useNavigationStore()
|
||||||
const [selectedDetectors, setSelectedDetectors] = useState<number[]>([])
|
const [selectedDetectors, setSelectedDetectors] = useState<number[]>([])
|
||||||
const [detectorsData, setDetectorsData] = useState<any>({ detectors: {} })
|
const [detectorsData, setDetectorsData] = useState<any>({ detectors: {} })
|
||||||
|
|
||||||
@@ -28,6 +28,13 @@ const ReportsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject])
|
}, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject])
|
||||||
|
|
||||||
|
// Автовыбор detector id из стора
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDetector && !selectedDetectors.includes(selectedDetector.detector_id)) {
|
||||||
|
setSelectedDetectors(prev => [...prev, selectedDetector.detector_id])
|
||||||
|
}
|
||||||
|
}, [selectedDetector, selectedDetectors])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDetectors = async () => {
|
const loadDetectors = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -140,14 +147,20 @@ const ReportsPage: React.FC = () => {
|
|||||||
<ExportMenu onExport={handleExport} />
|
<ExportMenu onExport={handleExport} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selection table to choose detectors to include in the report */}
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<DetectorList objectId={objectId || undefined} selectedDetectors={selectedDetectors} onDetectorSelect={handleDetectorSelect} />
|
<DetectorList
|
||||||
|
objectId={objectId || undefined}
|
||||||
|
selectedDetectors={selectedDetectors}
|
||||||
|
onDetectorSelect={handleDetectorSelect}
|
||||||
|
initialSearchTerm={selectedDetector?.detector_id.toString() || ''}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Existing notifications-based list */}
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<ReportsList detectorsData={detectorsData} />
|
<ReportsList
|
||||||
|
detectorsData={detectorsData}
|
||||||
|
initialSearchTerm={selectedDetector?.detector_id.toString() || ''}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const priority = severity === 'critical' ? 'high' : severity === 'warning' ? 'medium' : 'low'
|
const priority = severity === 'critical' ? 'high' : severity === 'warning' ? 'medium' : 'low'
|
||||||
return {
|
return {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
|
detector_id: a.detector_id,
|
||||||
detector_name: a.name || a.detector_name,
|
detector_name: a.name || a.detector_name,
|
||||||
message: a.message,
|
message: a.message,
|
||||||
type,
|
type,
|
||||||
|
|||||||
@@ -45,15 +45,22 @@ export async function POST(req: NextRequest) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json().catch(() => ({ })) as { format?: 'csv' | 'pdf', hours?: number }
|
const body = await req.json().catch(() => ({ })) as { format?: 'csv' | 'pdf', hours?: number, detector_ids?: number[] }
|
||||||
const reportFormat = (body.format || '').toLowerCase()
|
const reportFormat = (body.format || '').toLowerCase()
|
||||||
|
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
const qpFormat = (url.searchParams.get('format') || '').toLowerCase()
|
const qpFormat = (url.searchParams.get('format') || '').toLowerCase()
|
||||||
const qpHoursRaw = url.searchParams.get('hours')
|
const qpHoursRaw = url.searchParams.get('hours')
|
||||||
|
const qpDetectorIds = url.searchParams.get('detector_ids')
|
||||||
const qpHours = qpHoursRaw ? Number(qpHoursRaw) : undefined
|
const qpHours = qpHoursRaw ? Number(qpHoursRaw) : undefined
|
||||||
const finalFormat = reportFormat || qpFormat
|
const finalFormat = reportFormat || qpFormat
|
||||||
const finalHours = typeof body.hours === 'number' ? body.hours : (typeof qpHours === 'number' && !Number.isNaN(qpHours) ? qpHours : 168)
|
const finalHours = typeof body.hours === 'number' ? body.hours : (typeof qpHours === 'number' && !Number.isNaN(qpHours) ? qpHours : 168)
|
||||||
|
const finalDetectorIds = body.detector_ids || (qpDetectorIds ? qpDetectorIds.split(',').map(id => Number(id)) : undefined)
|
||||||
|
|
||||||
|
const requestBody: any = { report_format: finalFormat, hours: finalHours }
|
||||||
|
if (finalDetectorIds && finalDetectorIds.length > 0) {
|
||||||
|
requestBody.detector_ids = finalDetectorIds
|
||||||
|
}
|
||||||
|
|
||||||
let backendRes = await fetch(`${backendUrl}/account/get-reports/`, {
|
let backendRes = await fetch(`${backendUrl}/account/get-reports/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -61,7 +68,7 @@ export async function POST(req: NextRequest) {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ report_format: finalFormat, hours: finalHours }),
|
body: JSON.stringify(requestBody),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!backendRes.ok && backendRes.status === 404) {
|
if (!backendRes.ok && backendRes.status === 404) {
|
||||||
|
|||||||
184
frontend/app/api/get-zones/route.ts
Normal file
184
frontend/app/api/get-zones/route.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { NextResponse, NextRequest } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { getToken } from 'next-auth/jwt'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
|
||||||
|
const authHeader = req.headers.get('authorization') || req.headers.get('Authorization')
|
||||||
|
const bearer = authHeader && authHeader.toLowerCase().startsWith('bearer ') ? authHeader.slice(7) : undefined
|
||||||
|
|
||||||
|
const secret = process.env.NEXTAUTH_SECRET
|
||||||
|
const token = secret ? (await getToken({ req, secret }).catch(() => null)) : null
|
||||||
|
|
||||||
|
let accessToken: string | undefined = session?.accessToken || bearer || (token as any)?.accessToken
|
||||||
|
const refreshToken: string | undefined = session?.refreshToken || (token as any)?.refreshToken
|
||||||
|
|
||||||
|
if (!accessToken && refreshToken) {
|
||||||
|
try {
|
||||||
|
const refreshRes = await fetch(`${process.env.BACKEND_URL}/auth/refresh/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refresh: refreshToken }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (refreshRes.ok) {
|
||||||
|
const refreshed = await refreshRes.json()
|
||||||
|
accessToken = refreshed.access
|
||||||
|
} else {
|
||||||
|
const errorText = await refreshRes.text()
|
||||||
|
let errorData: { error?: string; detail?: string; code?: string } = {}
|
||||||
|
try {
|
||||||
|
errorData = JSON.parse(errorText)
|
||||||
|
} catch {
|
||||||
|
errorData = { error: errorText }
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = (errorData.error as string) || (errorData.detail as string) || ''
|
||||||
|
if (typeof errorMessage === 'string' &&
|
||||||
|
(errorMessage.includes('Token is expired') ||
|
||||||
|
errorMessage.includes('expired') ||
|
||||||
|
errorData.code === 'token_not_valid')) {
|
||||||
|
console.warn('Refresh token expired, user needs to re-authenticate')
|
||||||
|
} else {
|
||||||
|
console.error('Token refresh failed:', errorData.error || errorData.detail || 'Unknown error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during token refresh:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendUrl = process.env.BACKEND_URL
|
||||||
|
if (!backendUrl) {
|
||||||
|
return NextResponse.json({ success: false, error: 'BACKEND_URL is not configured' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const objectId = searchParams.get('objectId')
|
||||||
|
if (!objectId) {
|
||||||
|
return NextResponse.json({ success: false, error: 'objectId query parameter is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нормализуем objectId с фронтенда вида "object_2" к числовому идентификатору бэкенда "2"
|
||||||
|
const normalizedObjectId = /^object_(\d+)$/.test(objectId) ? objectId.replace(/^object_/, '') : objectId
|
||||||
|
|
||||||
|
const zonesRes = await fetch(`${backendUrl}/account/get-zones/?objectId=${encodeURIComponent(normalizedObjectId)}`, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
|
|
||||||
|
const payloadText = await zonesRes.text()
|
||||||
|
let payload: any
|
||||||
|
try { payload = JSON.parse(payloadText) } catch { payload = payloadText }
|
||||||
|
|
||||||
|
// Отладка: наблюдаем статус ответа и предполагаемую длину
|
||||||
|
console.log(
|
||||||
|
'[api/get-zones] objectId=%s normalized=%s status=%d payloadType=%s length=%d',
|
||||||
|
objectId,
|
||||||
|
normalizedObjectId,
|
||||||
|
zonesRes.status,
|
||||||
|
typeof payload,
|
||||||
|
Array.isArray((payload as any)?.data)
|
||||||
|
? (payload as any).data.length
|
||||||
|
: Array.isArray(payload)
|
||||||
|
? payload.length
|
||||||
|
: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!zonesRes.ok) {
|
||||||
|
if (payload && typeof payload === 'object') {
|
||||||
|
if (payload.code === 'token_not_valid' ||
|
||||||
|
(payload.detail && typeof payload.detail === 'string' && (payload.detail.includes('Token is expired') || payload.detail.includes('Given token not valid'))) ||
|
||||||
|
(payload.messages && Array.isArray(payload.messages) && payload.messages.some((msg: any) =>
|
||||||
|
msg.message && typeof msg.message === 'string' && msg.message.includes('Token is expired')
|
||||||
|
))) {
|
||||||
|
console.warn('Access token expired, user needs to re-authenticate')
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Authentication required - please log in again'
|
||||||
|
}, { status: 401 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Резервный путь: пробуем получить список объектов и извлечь зоны для указанного objectId
|
||||||
|
try {
|
||||||
|
const objectsRes = await fetch(`${backendUrl}/account/get-objects/`, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
|
|
||||||
|
const objectsText = await objectsRes.text()
|
||||||
|
let objectsPayload: any
|
||||||
|
try { objectsPayload = JSON.parse(objectsText) } catch { objectsPayload = objectsText }
|
||||||
|
|
||||||
|
if (objectsRes.ok) {
|
||||||
|
const objectsList: any[] = Array.isArray(objectsPayload?.data) ? objectsPayload.data
|
||||||
|
: (Array.isArray(objectsPayload) ? objectsPayload : (Array.isArray(objectsPayload?.objects) ? objectsPayload.objects : []))
|
||||||
|
|
||||||
|
const target = objectsList.find((o: any) => (
|
||||||
|
o?.id === normalizedObjectId || o?.object_id === normalizedObjectId || o?.slug === normalizedObjectId || o?.identifier === normalizedObjectId
|
||||||
|
))
|
||||||
|
|
||||||
|
const rawZones: any[] = target?.zones || target?.zone_list || target?.areas || target?.Зоны || []
|
||||||
|
const normalized = Array.isArray(rawZones) ? rawZones.map((z: any, idx: number) => ({
|
||||||
|
id: z?.id ?? z?.zone_id ?? `${normalizedObjectId}_zone_${idx}`,
|
||||||
|
name: z?.name ?? z?.zone_name ?? `Зона ${idx + 1}`,
|
||||||
|
floor: typeof z?.floor === 'number' ? z.floor : (typeof z?.level === 'number' ? z.level : 0),
|
||||||
|
image_path: z?.image_path ?? z?.image ?? z?.preview_image ?? null,
|
||||||
|
model_path: z?.model_path ?? z?.model ?? z?.modelUrl ?? null,
|
||||||
|
order: typeof z?.order === 'number' ? z.order : 0,
|
||||||
|
sensors: Array.isArray(z?.sensors) ? z.sensors : (Array.isArray(z?.detectors) ? z.detectors : []),
|
||||||
|
})) : []
|
||||||
|
|
||||||
|
// Отладка: длина массива в резервном пути
|
||||||
|
console.log('[api/get-zones:fallback] normalized length=%d', normalized.length)
|
||||||
|
|
||||||
|
// Возвращаем успешный ответ с нормализованными зонами (может быть пустой массив)
|
||||||
|
return NextResponse.json({ success: true, data: normalized }, { status: 200 })
|
||||||
|
}
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.warn('Fallback get-objects failed:', fallbackErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если дошли до сюда, возвращаем успешный ответ с пустым списком, чтобы не ломать UI
|
||||||
|
return NextResponse.json({ success: true, data: [] }, { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Распаковываем массив зон от бэкенда в плоский список в поле data
|
||||||
|
const zonesData: any[] = Array.isArray((payload as any)?.data)
|
||||||
|
? (payload as any).data
|
||||||
|
: Array.isArray(payload)
|
||||||
|
? (payload as any)
|
||||||
|
: Array.isArray((payload as any)?.zones)
|
||||||
|
? (payload as any).zones
|
||||||
|
: []
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: zonesData }, { status: 200 })
|
||||||
|
// Нормализация: при необходимости используем запасной image_path на стороне клиента
|
||||||
|
return NextResponse.json({ success: true, data: payload })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching zones data:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch zones data',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
|
import type { Zone } from '@/app/types'
|
||||||
|
|
||||||
export interface DetectorType {
|
export interface DetectorType {
|
||||||
detector_id: number
|
detector_id: number
|
||||||
@@ -56,6 +57,12 @@ export interface NavigationStore {
|
|||||||
currentSubmenu: string | null
|
currentSubmenu: string | null
|
||||||
currentModelPath: string | null
|
currentModelPath: string | null
|
||||||
|
|
||||||
|
// Состояния Зон
|
||||||
|
currentZones: Zone[]
|
||||||
|
zonesCache: Record<string, Zone[]>
|
||||||
|
zonesLoading: boolean
|
||||||
|
zonesError: string | null
|
||||||
|
|
||||||
showMonitoring: boolean
|
showMonitoring: boolean
|
||||||
showFloorNavigation: boolean
|
showFloorNavigation: boolean
|
||||||
showNotifications: boolean
|
showNotifications: boolean
|
||||||
@@ -78,6 +85,11 @@ export interface NavigationStore {
|
|||||||
setCurrentSubmenu: (submenu: string | null) => void
|
setCurrentSubmenu: (submenu: string | null) => void
|
||||||
clearSubmenu: () => void
|
clearSubmenu: () => void
|
||||||
|
|
||||||
|
// Действия с зонами
|
||||||
|
loadZones: (objectId: string) => Promise<void>
|
||||||
|
setZones: (zones: Zone[]) => void
|
||||||
|
clearZones: () => void
|
||||||
|
|
||||||
openMonitoring: () => void
|
openMonitoring: () => void
|
||||||
closeMonitoring: () => void
|
closeMonitoring: () => void
|
||||||
openFloorNavigation: () => void
|
openFloorNavigation: () => void
|
||||||
@@ -90,6 +102,7 @@ export interface NavigationStore {
|
|||||||
closeSensors: () => void
|
closeSensors: () => void
|
||||||
|
|
||||||
closeAllMenus: () => void
|
closeAllMenus: () => void
|
||||||
|
clearSelections: () => void
|
||||||
|
|
||||||
setSelectedDetector: (detector: DetectorType | null) => void
|
setSelectedDetector: (detector: DetectorType | null) => void
|
||||||
setShowDetectorMenu: (show: boolean) => void
|
setShowDetectorMenu: (show: boolean) => void
|
||||||
@@ -101,7 +114,6 @@ export interface NavigationStore {
|
|||||||
isOnNavigationPage: () => boolean
|
isOnNavigationPage: () => boolean
|
||||||
getCurrentRoute: () => string | null
|
getCurrentRoute: () => string | null
|
||||||
getActiveSidebarItem: () => number
|
getActiveSidebarItem: () => number
|
||||||
PREFERRED_MODEL: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const useNavigationStore = create<NavigationStore>()(
|
const useNavigationStore = create<NavigationStore>()(
|
||||||
@@ -115,6 +127,11 @@ const useNavigationStore = create<NavigationStore>()(
|
|||||||
currentSubmenu: null,
|
currentSubmenu: null,
|
||||||
currentModelPath: null,
|
currentModelPath: null,
|
||||||
|
|
||||||
|
currentZones: [],
|
||||||
|
zonesCache: {},
|
||||||
|
zonesLoading: false,
|
||||||
|
zonesError: null,
|
||||||
|
|
||||||
showMonitoring: false,
|
showMonitoring: false,
|
||||||
showFloorNavigation: false,
|
showFloorNavigation: false,
|
||||||
showNotifications: false,
|
showNotifications: false,
|
||||||
@@ -128,8 +145,6 @@ const useNavigationStore = create<NavigationStore>()(
|
|||||||
selectedAlert: null,
|
selectedAlert: null,
|
||||||
showAlertMenu: false,
|
showAlertMenu: false,
|
||||||
|
|
||||||
PREFERRED_MODEL: 'AerBIM-Monitor_ASM-HT-Viewer_Expo2017Astana_20250910',
|
|
||||||
|
|
||||||
setCurrentObject: (id: string | undefined, title: string | undefined) =>
|
setCurrentObject: (id: string | undefined, title: string | undefined) =>
|
||||||
set({ currentObject: { id, title } }),
|
set({ currentObject: { id, title } }),
|
||||||
|
|
||||||
@@ -165,17 +180,63 @@ const useNavigationStore = create<NavigationStore>()(
|
|||||||
clearSubmenu: () =>
|
clearSubmenu: () =>
|
||||||
set({ currentSubmenu: null }),
|
set({ currentSubmenu: null }),
|
||||||
|
|
||||||
openMonitoring: () => set({
|
loadZones: async (objectId: string) => {
|
||||||
showMonitoring: true,
|
const cache = get().zonesCache
|
||||||
showFloorNavigation: false,
|
const cached = cache[objectId]
|
||||||
showNotifications: false,
|
const hasCached = Array.isArray(cached) && cached.length > 0
|
||||||
showListOfDetectors: false,
|
if (hasCached) {
|
||||||
currentSubmenu: 'monitoring',
|
// Показываем кэшированные зоны сразу, но обновляем в фоне
|
||||||
showDetectorMenu: false,
|
set({ currentZones: cached, zonesLoading: true, zonesError: null })
|
||||||
selectedDetector: null,
|
} else {
|
||||||
showNotificationDetectorInfo: false,
|
set({ zonesLoading: true, zonesError: null })
|
||||||
selectedNotification: null
|
}
|
||||||
}),
|
try {
|
||||||
|
const res = await fetch(`/api/get-zones?objectId=${encodeURIComponent(objectId)}`, { cache: 'no-store' })
|
||||||
|
const text = await res.text()
|
||||||
|
let payload: string | Record<string, unknown>
|
||||||
|
try { payload = JSON.parse(text) } catch { payload = text }
|
||||||
|
if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error as string || 'Не удалось получить зоны'))
|
||||||
|
const zones: Zone[] = typeof payload === 'string' ? [] :
|
||||||
|
Array.isArray(payload?.data) ? payload.data as Zone[] :
|
||||||
|
(payload?.data && typeof payload.data === 'object' && 'zones' in payload.data ? (payload.data as { zones?: Zone[] }).zones :
|
||||||
|
payload?.zones ? payload.zones as Zone[] : []) || []
|
||||||
|
const normalized = zones.map((z) => ({
|
||||||
|
...z,
|
||||||
|
image_path: z.image_path ?? null,
|
||||||
|
}))
|
||||||
|
set((state) => ({
|
||||||
|
currentZones: normalized,
|
||||||
|
zonesCache: { ...state.zonesCache, [objectId]: normalized },
|
||||||
|
zonesLoading: false,
|
||||||
|
zonesError: null,
|
||||||
|
}))
|
||||||
|
} catch (e: unknown) {
|
||||||
|
set({ zonesLoading: false, zonesError: (e as Error)?.message || 'Ошибка при загрузке зон' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setZones: (zones: Zone[]) => set({ currentZones: zones }),
|
||||||
|
clearZones: () => set({ currentZones: [] }),
|
||||||
|
|
||||||
|
openMonitoring: () => {
|
||||||
|
set({
|
||||||
|
showMonitoring: true,
|
||||||
|
showFloorNavigation: false,
|
||||||
|
showNotifications: false,
|
||||||
|
showListOfDetectors: false,
|
||||||
|
currentSubmenu: 'monitoring',
|
||||||
|
showDetectorMenu: false,
|
||||||
|
selectedDetector: null,
|
||||||
|
showNotificationDetectorInfo: false,
|
||||||
|
selectedNotification: null,
|
||||||
|
zonesError: null // Очищаем ошибку зон при открытии мониторинга
|
||||||
|
})
|
||||||
|
const objId = get().currentObject.id
|
||||||
|
if (objId) {
|
||||||
|
// Вызываем загрузку зон сразу, но обновляем в фоне
|
||||||
|
get().loadZones(objId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
closeMonitoring: () => set({
|
closeMonitoring: () => set({
|
||||||
showMonitoring: false,
|
showMonitoring: false,
|
||||||
@@ -255,19 +316,23 @@ const useNavigationStore = create<NavigationStore>()(
|
|||||||
currentSubmenu: null
|
currentSubmenu: null
|
||||||
}),
|
}),
|
||||||
|
|
||||||
closeAllMenus: () => set({
|
closeAllMenus: () => {
|
||||||
showMonitoring: false,
|
set({
|
||||||
showFloorNavigation: false,
|
showMonitoring: false,
|
||||||
showNotifications: false,
|
showFloorNavigation: false,
|
||||||
showListOfDetectors: false,
|
showNotifications: false,
|
||||||
showSensors: false,
|
showListOfDetectors: false,
|
||||||
showDetectorMenu: false,
|
showSensors: false,
|
||||||
|
currentSubmenu: null,
|
||||||
|
});
|
||||||
|
get().clearSelections();
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSelections: () => set({
|
||||||
selectedDetector: null,
|
selectedDetector: null,
|
||||||
showNotificationDetectorInfo: false,
|
showDetectorMenu: false,
|
||||||
selectedNotification: null,
|
|
||||||
showAlertMenu: false,
|
|
||||||
selectedAlert: null,
|
selectedAlert: null,
|
||||||
currentSubmenu: null
|
showAlertMenu: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setSelectedDetector: (detector: DetectorType | null) => set({ selectedDetector: detector }),
|
setSelectedDetector: (detector: DetectorType | null) => set({ selectedDetector: detector }),
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
import type { ValidationRules, ValidationErrors, User, UserState } from './types/index'
|
import type { ValidationRules, ValidationErrors, User, UserState, Zone } from './types/index'
|
||||||
export type { ValidationRules, ValidationErrors, User, UserState }
|
export type { ValidationRules, ValidationErrors, User, UserState, Zone }
|
||||||
@@ -64,3 +64,18 @@ export interface TextInputProps {
|
|||||||
error?: string
|
error?: string
|
||||||
min?: string
|
min?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Zone {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
floor: number
|
||||||
|
image_path?: string | null
|
||||||
|
model_path?: string | null
|
||||||
|
order?: number
|
||||||
|
sensors?: Array<{
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
serial_number: string
|
||||||
|
sensor_type: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|||||||
145
frontend/components/alerts/AlertsList.tsx
Normal file
145
frontend/components/alerts/AlertsList.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import React, { useState, useMemo } from 'react'
|
||||||
|
|
||||||
|
interface AlertItem {
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
detector_id?: string
|
||||||
|
detector_name?: string
|
||||||
|
location?: string
|
||||||
|
object?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertsListProps {
|
||||||
|
alerts: AlertItem[]
|
||||||
|
onAcknowledgeToggle: (alertId: number) => void
|
||||||
|
initialSearchTerm?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, initialSearchTerm = '' }) => {
|
||||||
|
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 getStatusColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'critical': return '#b3261e'
|
||||||
|
case 'warning': return '#fd7c22'
|
||||||
|
case 'info': return '#00ff00'
|
||||||
|
default: return '#666'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 className="text-xl font-semibold text-white">История тревог</h2>
|
||||||
|
<span 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 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>
|
||||||
|
<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>
|
||||||
|
{filteredAlerts.map((item) => (
|
||||||
|
<tr key={item.id} className="border-b border-gray-700 hover:bg-gray-800/50 transition-colors">
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="text-sm font-medium text-white">{item.detector_name || 'Детектор'}</div>
|
||||||
|
{item.detector_id ? (
|
||||||
|
<div className="text-sm 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 className="text-sm text-gray-300">
|
||||||
|
{item.type === 'critical' ? 'Критический' : item.type === 'warning' ? 'Предупреждение' : 'Информация'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="text-sm text-white">{item.message}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="text-sm text-white">{item.location || '-'}</div>
|
||||||
|
</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' ? '#b3261e' : item.priority === 'medium' ? '#fd7c22' : '#00ff00' }}
|
||||||
|
>
|
||||||
|
{item.priority === 'high' ? 'Высокий' : item.priority === 'medium' ? 'Средний' : 'Низкий'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
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)}
|
||||||
|
className="ml-2 inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-[#2a2e3e] text-white hover:bg-[#353a4d]"
|
||||||
|
>
|
||||||
|
{item.acknowledged ? 'Снять' : 'Подтвердить'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="text-sm text-gray-300">{new Date(item.timestamp).toLocaleString('ru-RU')}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{filteredAlerts.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="py-8 text-center text-gray-400">
|
||||||
|
Записей не найдено
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlertsList
|
||||||
@@ -31,18 +31,16 @@ interface RawDetector {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterType = 'all' | 'critical' | 'warning' | 'normal'
|
|
||||||
|
|
||||||
interface DetectorListProps {
|
interface DetectorListProps {
|
||||||
objectId?: string
|
objectId?: string
|
||||||
selectedDetectors: number[]
|
selectedDetectors: number[]
|
||||||
onDetectorSelect: (detectorId: number, selected: boolean) => void
|
onDetectorSelect: (detectorId: number, selected: boolean) => void
|
||||||
|
initialSearchTerm?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors, onDetectorSelect }) => {
|
const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors, onDetectorSelect, initialSearchTerm = '' }) => {
|
||||||
const [detectors, setDetectors] = useState<Detector[]>([])
|
const [detectors, setDetectors] = useState<Detector[]>([])
|
||||||
const [selectedFilter, setSelectedFilter] = useState<FilterType>('all')
|
const [searchTerm, setSearchTerm] = useState<string>(initialSearchTerm)
|
||||||
const [searchTerm, setSearchTerm] = useState<string>('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDetectors = async () => {
|
const loadDetectors = async () => {
|
||||||
@@ -73,13 +71,7 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
|||||||
}, [objectId])
|
}, [objectId])
|
||||||
|
|
||||||
const filteredDetectors = detectors.filter(detector => {
|
const filteredDetectors = detectors.filter(detector => {
|
||||||
const matchesSearch = detector.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = searchTerm === '' || detector.detector_id.toString() === searchTerm
|
||||||
detector.location.toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
|
|
||||||
if (selectedFilter === 'all') return matchesSearch
|
|
||||||
if (selectedFilter === 'critical') return matchesSearch && detector.status === '#b3261e'
|
|
||||||
if (selectedFilter === 'warning') return matchesSearch && detector.status === '#fd7c22'
|
|
||||||
if (selectedFilter === 'normal') return matchesSearch && detector.status === '#00ff00'
|
|
||||||
|
|
||||||
return matchesSearch
|
return matchesSearch
|
||||||
})
|
})
|
||||||
@@ -88,53 +80,13 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
|
||||||
onClick={() => setSelectedFilter('all')}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
selectedFilter === 'all'
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Все ({detectors.length})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedFilter('critical')}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
selectedFilter === 'critical'
|
|
||||||
? 'bg-red-600 text-white'
|
|
||||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Критические ({detectors.filter(d => d.status === '#b3261e').length})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedFilter('warning')}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
selectedFilter === 'warning'
|
|
||||||
? 'bg-orange-600 text-white'
|
|
||||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Предупреждения ({detectors.filter(d => d.status === '#fd7c22').length})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedFilter('normal')}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
selectedFilter === 'normal'
|
|
||||||
? 'bg-green-600 text-white'
|
|
||||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Норма ({detectors.filter(d => d.status === '#00ff00').length})
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Поиск детекторов..."
|
placeholder="Поиск по ID детектора..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
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"
|
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"
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const Dashboard: React.FC = () => {
|
|||||||
loadDashboard()
|
loadDashboard()
|
||||||
}, [objectTitle, selectedChartPeriod, selectedSensorType])
|
}, [objectTitle, selectedChartPeriod, selectedSensorType])
|
||||||
|
|
||||||
// Separate effect for table data based on table period
|
// Отдельный эффект для загрузки таблицы по выбранному периоду
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadTableData = async () => {
|
const loadTableData = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -208,14 +208,12 @@ const Dashboard: React.FC = () => {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px]">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px]">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title="Показатель"
|
title="Показатель"
|
||||||
// subtitle removed
|
|
||||||
>
|
>
|
||||||
<AreaChart data={chartData} />
|
<AreaChart data={chartData} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title="Статистика"
|
title="Статистика"
|
||||||
// subtitle removed
|
|
||||||
>
|
>
|
||||||
<BarChart data={chartData?.map((d: any) => ({ value: d.value }))} />
|
<BarChart data={chartData?.map((d: any) => ({ value: d.value }))} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|||||||
@@ -39,8 +39,12 @@ export interface ModelViewerProps {
|
|||||||
}
|
}
|
||||||
}) => void
|
}) => void
|
||||||
onError?: (error: string) => void
|
onError?: (error: string) => void
|
||||||
|
activeMenu?: string | null
|
||||||
focusSensorId?: string | null
|
focusSensorId?: string | null
|
||||||
renderOverlay?: (params: { anchor: { left: number; top: number } | null; info?: { name?: string; sensorId?: string } | null }) => React.ReactNode
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModelViewer: React.FC<ModelViewerProps> = ({
|
const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||||
@@ -50,6 +54,9 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
onError,
|
onError,
|
||||||
focusSensorId,
|
focusSensorId,
|
||||||
renderOverlay,
|
renderOverlay,
|
||||||
|
isSensorSelectionEnabled,
|
||||||
|
onSensorPick,
|
||||||
|
highlightAllSensors,
|
||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
const engineRef = useRef<Nullable<Engine>>(null)
|
const engineRef = useRef<Nullable<Engine>>(null)
|
||||||
@@ -61,6 +68,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
const isDisposedRef = useRef(false)
|
const isDisposedRef = useRef(false)
|
||||||
const importedMeshesRef = useRef<AbstractMesh[]>([])
|
const importedMeshesRef = useRef<AbstractMesh[]>([])
|
||||||
const highlightLayerRef = useRef<HighlightLayer | null>(null)
|
const highlightLayerRef = useRef<HighlightLayer | null>(null)
|
||||||
|
const highlightedMeshesRef = useRef<AbstractMesh[]>([])
|
||||||
const chosenMeshRef = useRef<AbstractMesh | null>(null)
|
const chosenMeshRef = useRef<AbstractMesh | null>(null)
|
||||||
const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null)
|
const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null)
|
||||||
const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null)
|
const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null)
|
||||||
@@ -214,7 +222,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
if (!canvasRef.current || isInitializedRef.current) return
|
if (!canvasRef.current || isInitializedRef.current) return
|
||||||
|
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
const engine = new Engine(canvas, true)
|
const engine = new Engine(canvas, true, { stencil: true })
|
||||||
engineRef.current = engine
|
engineRef.current = engine
|
||||||
|
|
||||||
engine.runRenderLoop(() => {
|
engine.runRenderLoop(() => {
|
||||||
@@ -419,29 +427,40 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
}, [modelPath, onError, onModelLoaded])
|
}, [modelPath, onError, onModelLoaded])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log('[ModelViewer] highlightAllSensors effect triggered:', { highlightAllSensors, modelReady, sceneReady: !!sceneRef.current })
|
||||||
if (!sceneRef.current || isDisposedRef.current || !modelReady) return
|
if (!sceneRef.current || isDisposedRef.current || !modelReady) return
|
||||||
|
|
||||||
|
// Если включено выделение всех сенсоров - выделяем их все
|
||||||
|
if (highlightAllSensors) {
|
||||||
|
console.log('[ModelViewer] Calling highlightAllSensorMeshes()')
|
||||||
|
highlightAllSensorMeshes()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const sensorId = (focusSensorId ?? '').trim()
|
const sensorId = (focusSensorId ?? '').trim()
|
||||||
if (!sensorId) {
|
if (!sensorId) {
|
||||||
console.log('[ModelViewer] Focus cleared (no Sensor_ID provided)')
|
console.log('[ModelViewer] Focus cleared (no Sensor_ID provided)')
|
||||||
|
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
|
||||||
|
highlightedMeshesRef.current = []
|
||||||
highlightLayerRef.current?.removeAllMeshes()
|
highlightLayerRef.current?.removeAllMeshes()
|
||||||
chosenMeshRef.current = null
|
chosenMeshRef.current = null
|
||||||
setOverlayPos(null)
|
setOverlayPos(null)
|
||||||
setOverlayData(null)
|
setOverlayData(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const allMeshes = importedMeshesRef.current || []
|
const allMeshes = importedMeshesRef.current || []
|
||||||
|
|
||||||
if (allMeshes.length === 0) {
|
if (allMeshes.length === 0) {
|
||||||
console.warn('[ModelViewer] No meshes available for sensor matching')
|
console.warn('[ModelViewer] No meshes available for sensor matching')
|
||||||
|
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
|
||||||
|
highlightedMeshesRef.current = []
|
||||||
highlightLayerRef.current?.removeAllMeshes()
|
highlightLayerRef.current?.removeAllMeshes()
|
||||||
chosenMeshRef.current = null
|
chosenMeshRef.current = null
|
||||||
setOverlayPos(null)
|
setOverlayPos(null)
|
||||||
setOverlayData(null)
|
setOverlayData(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const sensorMeshes = allMeshes.filter((m: any) => {
|
const sensorMeshes = allMeshes.filter((m: any) => {
|
||||||
try {
|
try {
|
||||||
@@ -516,15 +535,26 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
|
|
||||||
const hl = highlightLayerRef.current
|
const hl = highlightLayerRef.current
|
||||||
if (hl) {
|
if (hl) {
|
||||||
|
// Переключаем группу рендеринга для предыдущего выделенного меша
|
||||||
|
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
|
||||||
|
highlightedMeshesRef.current = []
|
||||||
|
|
||||||
hl.removeAllMeshes()
|
hl.removeAllMeshes()
|
||||||
if (chosen instanceof Mesh) {
|
if (chosen instanceof Mesh) {
|
||||||
|
chosen.renderingGroupId = 1
|
||||||
|
highlightedMeshesRef.current.push(chosen)
|
||||||
hl.addMesh(chosen, new Color3(1, 1, 0))
|
hl.addMesh(chosen, new Color3(1, 1, 0))
|
||||||
} else if (chosen instanceof InstancedMesh) {
|
} else if (chosen instanceof InstancedMesh) {
|
||||||
|
// Сохраняем исходный меш для инстанса
|
||||||
|
chosen.sourceMesh.renderingGroupId = 1
|
||||||
|
highlightedMeshesRef.current.push(chosen.sourceMesh)
|
||||||
hl.addMesh(chosen.sourceMesh, new Color3(1, 1, 0))
|
hl.addMesh(chosen.sourceMesh, new Color3(1, 1, 0))
|
||||||
} else {
|
} else {
|
||||||
const children = typeof (chosen as any)?.getChildMeshes === 'function' ? (chosen as any).getChildMeshes() : []
|
const children = typeof (chosen as any)?.getChildMeshes === 'function' ? (chosen as any).getChildMeshes() : []
|
||||||
for (const cm of children) {
|
for (const cm of children) {
|
||||||
if (cm instanceof Mesh) {
|
if (cm instanceof Mesh) {
|
||||||
|
cm.renderingGroupId = 1
|
||||||
|
highlightedMeshesRef.current.push(cm)
|
||||||
hl.addMesh(cm, new Color3(1, 1, 0))
|
hl.addMesh(cm, new Color3(1, 1, 0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -534,18 +564,144 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
setOverlayData({ name: chosen.name, sensorId })
|
setOverlayData({ name: chosen.name, sensorId })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ModelViewer] Error focusing on sensor mesh:', error)
|
console.error('[ModelViewer] Error focusing on sensor mesh:', error)
|
||||||
|
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()
|
highlightLayerRef.current?.removeAllMeshes()
|
||||||
chosenMeshRef.current = null
|
chosenMeshRef.current = null
|
||||||
setOverlayPos(null)
|
setOverlayPos(null)
|
||||||
setOverlayData(null)
|
setOverlayData(null)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
highlightLayerRef.current?.removeAllMeshes()
|
|
||||||
chosenMeshRef.current = null
|
|
||||||
setOverlayPos(null)
|
|
||||||
setOverlayData(null)
|
|
||||||
}
|
}
|
||||||
}, [focusSensorId, modelReady])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [focusSensorId, modelReady, highlightAllSensors])
|
||||||
|
|
||||||
|
// Помощь: извлечь Sensor_ID из метаданных меша (совпадающая логика с фокусом)
|
||||||
|
const getSensorIdFromMesh = React.useCallback((m: AbstractMesh | null): string | null => {
|
||||||
|
if (!m) return null
|
||||||
|
try {
|
||||||
|
const meta: any = (m as any)?.metadata
|
||||||
|
const extras: any = meta?.gltf?.extras ?? meta?.extras ?? (m as any)?.extras
|
||||||
|
const sid = extras?.Sensor_ID ?? extras?.sensor_id ?? extras?.SERIAL_NUMBER ?? extras?.serial_number ?? extras?.detector_id
|
||||||
|
if (sid != null) return String(sid).trim()
|
||||||
|
const monitoringSensorInstance = extras?.bonsaiPset_ARBM_PSet_MonitoringSensor_Instance
|
||||||
|
if (monitoringSensorInstance && typeof monitoringSensorInstance === 'string') {
|
||||||
|
try {
|
||||||
|
const parsedInstance = JSON.parse(monitoringSensorInstance)
|
||||||
|
const instanceSensorId = parsedInstance?.Sensor_ID
|
||||||
|
if (instanceSensorId != null) return String(instanceSensorId).trim()
|
||||||
|
} catch (parseError) {
|
||||||
|
console.warn('[ModelViewer] Error parsing MonitoringSensor_Instance JSON in pick:', parseError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Функция для выделения всех сенсоров на модели
|
||||||
|
const highlightAllSensorMeshes = React.useCallback(() => {
|
||||||
|
console.log('[ModelViewer] highlightAllSensorMeshes called')
|
||||||
|
const scene = sceneRef.current
|
||||||
|
if (!scene || !highlightLayerRef.current) {
|
||||||
|
console.log('[ModelViewer] Cannot highlight - scene or highlightLayer not ready:', {
|
||||||
|
sceneReady: !!scene,
|
||||||
|
highlightLayerReady: !!highlightLayerRef.current
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const allMeshes = importedMeshesRef.current || []
|
||||||
|
|
||||||
|
// Use the same logic as getSensorIdFromMesh to identify sensor meshes
|
||||||
|
const sensorMeshes = allMeshes.filter((m: any) => {
|
||||||
|
try {
|
||||||
|
const sensorId = getSensorIdFromMesh(m)
|
||||||
|
if (sensorId) {
|
||||||
|
console.log(`[ModelViewer] Found sensor mesh: ${m.name} (id: ${m.id}, sensorId: ${sensorId})`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ModelViewer] Error filtering sensor mesh:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[ModelViewer] Found ${sensorMeshes.length} sensor meshes out of ${allMeshes.length} total meshes`)
|
||||||
|
|
||||||
|
if (sensorMeshes.length === 0) {
|
||||||
|
console.log('[ModelViewer] No sensor meshes found to highlight')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear previous highlights
|
||||||
|
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
|
||||||
|
highlightedMeshesRef.current = []
|
||||||
|
highlightLayerRef.current?.removeAllMeshes()
|
||||||
|
|
||||||
|
// Highlight all sensor meshes
|
||||||
|
sensorMeshes.forEach((mesh: any) => {
|
||||||
|
try {
|
||||||
|
if (mesh instanceof Mesh) {
|
||||||
|
mesh.renderingGroupId = 1
|
||||||
|
highlightedMeshesRef.current.push(mesh)
|
||||||
|
highlightLayerRef.current?.addMesh(mesh, new Color3(1, 1, 0))
|
||||||
|
} else if (mesh instanceof InstancedMesh) {
|
||||||
|
mesh.sourceMesh.renderingGroupId = 1
|
||||||
|
highlightedMeshesRef.current.push(mesh.sourceMesh)
|
||||||
|
highlightLayerRef.current?.addMesh(mesh.sourceMesh, new Color3(1, 1, 0))
|
||||||
|
} else {
|
||||||
|
const children = typeof mesh.getChildMeshes === 'function' ? mesh.getChildMeshes() : []
|
||||||
|
for (const cm of children) {
|
||||||
|
if (cm instanceof Mesh) {
|
||||||
|
cm.renderingGroupId = 1
|
||||||
|
highlightedMeshesRef.current.push(cm)
|
||||||
|
highlightLayerRef.current?.addMesh(cm, new Color3(1, 1, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ModelViewer] Error highlighting sensor mesh:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[ModelViewer] Successfully highlighted ${highlightedMeshesRef.current.length} sensor meshes`)
|
||||||
|
}, [getSensorIdFromMesh])
|
||||||
|
|
||||||
|
// Включение выбора на основе взаимодействия с моделью только при готовности модели и включении выбора сенсоров
|
||||||
|
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, getSensorIdFromMesh])
|
||||||
|
|
||||||
// Расчет позиции оверлея
|
// Расчет позиции оверлея
|
||||||
const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => {
|
const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import useNavigationStore from '@/app/store/navigationStore';
|
import useNavigationStore from '@/app/store/navigationStore';
|
||||||
|
import type { Zone } from '@/app/types';
|
||||||
|
|
||||||
interface ToolbarButton {
|
interface ToolbarButton {
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -32,7 +33,7 @@ const SceneToolbar: React.FC<SceneToolbarProps> = ({
|
|||||||
navMenuActive = false,
|
navMenuActive = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [isZoomOpen, setIsZoomOpen] = useState(false);
|
const [isZoomOpen, setIsZoomOpen] = useState(false);
|
||||||
const { PREFERRED_MODEL, showMonitoring, openMonitoring, closeMonitoring } = useNavigationStore();
|
const { showMonitoring, openMonitoring, closeMonitoring, currentZones, loadZones, currentObject } = useNavigationStore();
|
||||||
|
|
||||||
const handleToggleNavMenu = () => {
|
const handleToggleNavMenu = () => {
|
||||||
if (showMonitoring) {
|
if (showMonitoring) {
|
||||||
@@ -43,25 +44,46 @@ const SceneToolbar: React.FC<SceneToolbarProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleHomeClick = async () => {
|
const handleHomeClick = async () => {
|
||||||
if (onSelectModel) {
|
if (!onSelectModel) return;
|
||||||
try {
|
|
||||||
const res = await fetch('/api/big-models/list');
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error('Failed to fetch models list');
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
const items: { name: string; path: string }[] = Array.isArray(data?.models) ? data.models : [];
|
|
||||||
const preferredModelName = PREFERRED_MODEL.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
|
|
||||||
const preferredModel = items.find(model => (model.path.split('/').pop()?.split('.').slice(0, -1).join('.') || '') === preferredModelName);
|
|
||||||
|
|
||||||
if (preferredModel) {
|
try {
|
||||||
onSelectModel(preferredModel.path);
|
let zones: Zone[] = Array.isArray(currentZones) ? currentZones : [];
|
||||||
} else {
|
|
||||||
console.error('Preferred model not found in the list');
|
// Если зоны ещё не загружены, откройте Monitoring и загрузите зоны для текущего объекта
|
||||||
|
if ((!zones || zones.length === 0) && currentObject?.id) {
|
||||||
|
if (!showMonitoring) {
|
||||||
|
openMonitoring();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
await loadZones(currentObject.id);
|
||||||
console.error('Error fetching models list:', error);
|
zones = useNavigationStore.getState().currentZones || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(zones) || zones.length === 0) {
|
||||||
|
console.warn('No zones available to select a model from.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = zones.slice().sort((a: Zone, b: Zone) => {
|
||||||
|
const oa = typeof a.order === 'number' ? a.order : 0;
|
||||||
|
const ob = typeof b.order === 'number' ? b.order : 0;
|
||||||
|
if (oa !== ob) return oa - ob;
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
const top = sorted[0];
|
||||||
|
let chosenPath: string | null = top?.model_path && String(top.model_path).trim() ? top.model_path! : null;
|
||||||
|
if (!chosenPath) {
|
||||||
|
const nextWithModel = sorted.find((z) => z.model_path && String(z.model_path).trim());
|
||||||
|
chosenPath = nextWithModel?.model_path ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chosenPath) {
|
||||||
|
onSelectModel(chosenPath);
|
||||||
|
} else {
|
||||||
|
console.warn('No zone has a valid model_path to open.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error selecting top zone model:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useNavigationStore from '@/app/store/navigationStore'
|
||||||
|
import AreaChart from '../dashboard/AreaChart'
|
||||||
|
|
||||||
interface AlertType {
|
interface AlertType {
|
||||||
id: number
|
id: number
|
||||||
@@ -26,6 +29,9 @@ interface AlertMenuProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AlertMenu: React.FC<AlertMenuProps> = ({ alert, isOpen, onClose, getStatusText, compact = false, anchor = null }) => {
|
const AlertMenu: React.FC<AlertMenuProps> = ({ alert, isOpen, onClose, getStatusText, compact = false, anchor = null }) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const { setSelectedDetector, currentObject } = useNavigationStore()
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
@@ -39,16 +45,8 @@ const AlertMenu: React.FC<AlertMenuProps> = ({ alert, isOpen, onClose, getStatus
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPriorityColor = (priority: string) => {
|
|
||||||
switch (priority.toLowerCase()) {
|
|
||||||
case 'high': return 'text-red-400'
|
|
||||||
case 'medium': return 'text-orange-400'
|
|
||||||
case 'low': return 'text-green-400'
|
|
||||||
default: return 'text-gray-400'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPriorityText = (priority: string) => {
|
const getPriorityText = (priority: string) => {
|
||||||
|
if (typeof priority !== 'string') return 'Неизвестно';
|
||||||
switch (priority.toLowerCase()) {
|
switch (priority.toLowerCase()) {
|
||||||
case 'high': return 'Высокий'
|
case 'high': return 'Высокий'
|
||||||
case 'medium': return 'Средний'
|
case 'medium': return 'Средний'
|
||||||
@@ -57,14 +55,141 @@ const AlertMenu: React.FC<AlertMenuProps> = ({ alert, isOpen, onClose, getStatus
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStatusColorCircle = (status: string) => {
|
||||||
|
// Use hex colors from Alerts submenu system
|
||||||
|
if (status === '#b3261e') return 'bg-red-500'
|
||||||
|
if (status === '#fd7c22') return 'bg-orange-500'
|
||||||
|
if (status === '#00ff00') return 'bg-green-500'
|
||||||
|
|
||||||
|
// Fallback for text-based status
|
||||||
|
switch (status?.toLowerCase()) {
|
||||||
|
case 'critical': return 'bg-red-500'
|
||||||
|
case 'warning': return 'bg-orange-500'
|
||||||
|
case 'normal': return 'bg-green-500'
|
||||||
|
default: return 'bg-gray-500'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTimeAgo = (timestamp: string) => {
|
||||||
|
const now = new Date()
|
||||||
|
const alertTime = new Date(timestamp)
|
||||||
|
const diffMs = now.getTime() - alertTime.getTime()
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||||
|
const diffMonths = Math.floor(diffDays / 30)
|
||||||
|
|
||||||
|
if (diffMonths > 0) {
|
||||||
|
return `${diffMonths} ${diffMonths === 1 ? 'месяц' : diffMonths < 5 ? 'месяца' : 'месяцев'}`
|
||||||
|
} else if (diffDays > 0) {
|
||||||
|
return `${diffDays} ${diffDays === 1 ? 'день' : diffDays < 5 ? 'дня' : 'дней'}`
|
||||||
|
} else {
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||||
|
if (diffHours > 0) {
|
||||||
|
return `${diffHours} ${diffHours === 1 ? 'час' : diffHours < 5 ? 'часа' : 'часов'}`
|
||||||
|
} else {
|
||||||
|
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||||
|
return `${diffMinutes} ${diffMinutes === 1 ? 'минута' : diffMinutes < 5 ? 'минуты' : 'минут'}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_id: alert.detector_id,
|
||||||
|
name: alert.detector_name,
|
||||||
|
serial_number: '',
|
||||||
|
object: alert.object || '',
|
||||||
|
status: '',
|
||||||
|
type: '',
|
||||||
|
detector_type: '',
|
||||||
|
location: alert.location || '',
|
||||||
|
floor: 0,
|
||||||
|
checked: false,
|
||||||
|
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_id: alert.detector_id,
|
||||||
|
name: alert.detector_name,
|
||||||
|
serial_number: '',
|
||||||
|
object: alert.object || '',
|
||||||
|
status: '',
|
||||||
|
type: '',
|
||||||
|
detector_type: '',
|
||||||
|
location: alert.location || '',
|
||||||
|
floor: 0,
|
||||||
|
checked: false,
|
||||||
|
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 chartData: { timestamp: string; value: number }[] = [
|
||||||
|
{ timestamp: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(), value: 75 },
|
||||||
|
{ timestamp: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), value: 82 },
|
||||||
|
{ timestamp: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(), value: 78 },
|
||||||
|
{ timestamp: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), value: 85 },
|
||||||
|
{ timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), value: 90 },
|
||||||
|
{ timestamp: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), value: 88 },
|
||||||
|
{ timestamp: new Date().toISOString(), value: 92 }
|
||||||
|
]
|
||||||
|
|
||||||
if (compact && anchor) {
|
if (compact && anchor) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute z-40" style={{ left: anchor.left, top: anchor.top }}>
|
<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-[240px] max-w-[300px]">
|
<div className="rounded-[10px] bg-black/80 text-white text-xs px-4 py-3 shadow-xl min-w-[420px] max-w-[454px]">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-4 mb-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-semibold truncate">Датч.{alert.detector_name}</div>
|
<div className="font-semibold truncate text-base">{alert.detector_name}</div>
|
||||||
<div className="opacity-80">{getStatusText(alert.status)}</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleReportsClick} className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-4 py-2 rounded-[6px] text-sm font-medium transition-colors flex items-center gap-2 flex-1">
|
||||||
|
<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-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-4 py-2 rounded-[6px] text-sm font-medium transition-colors flex items-center gap-2 flex-1">
|
||||||
|
<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>
|
||||||
<button onClick={onClose} className="text-gray-300 hover:text-white transition-colors">
|
<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">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -73,47 +198,40 @@ const AlertMenu: React.FC<AlertMenuProps> = ({ alert, isOpen, onClose, getStatus
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 space-y-1">
|
{/* Тело: 3 строки / 2 колонки */}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="space-y-2 mb-6">
|
||||||
<div>
|
{/* Строка 1: Статус */}
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Приоритет</div>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className={`text-xs font-medium ${getPriorityColor(alert.priority)}`}>
|
<div className="text-[rgb(113,113,122)] text-xs">Статус</div>
|
||||||
{getPriorityText(alert.priority)}
|
<div className="flex items-center gap-1 justify-end">
|
||||||
</div>
|
<div className={`w-2 h-2 rounded-full ${getStatusColorCircle(alert.status)}`}></div>
|
||||||
</div>
|
<div className="text-white text-xs">{getStatusText(alert.status)}</div>
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Время</div>
|
|
||||||
<div className="text-white text-xs truncate">{formatDate(alert.timestamp)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Строка 2: Причина тревоги */}
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Сообщение</div>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="text-white text-xs">{alert.message}</div>
|
<div className="text-[rgb(113,113,122)] text-xs">Причина тревоги</div>
|
||||||
|
<div className="text-white text-xs truncate">{alert.message}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Строка 3: Временная метка */}
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Местоположение</div>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="text-white text-xs">{alert.location}</div>
|
<div className="text-[rgb(113,113,122)] text-xs">Временная метка</div>
|
||||||
|
<div className="text-white text-xs text-right">{getTimeAgo(alert.timestamp)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
{/* Добавлен раздел дата/время и график для компактного режима */}
|
||||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
<div className="space-y-6">
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="text-[rgb(113,113,122)] text-xs text-center">{formatDate(alert.timestamp)}</div>
|
||||||
<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>
|
<div className="grid grid-cols-1 gap-6 min-h-[70px]">
|
||||||
Отчет
|
<AreaChart data={chartData} />
|
||||||
</button>
|
</div>
|
||||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,64 +240,89 @@ const AlertMenu: React.FC<AlertMenuProps> = ({ alert, isOpen, onClose, getStatus
|
|||||||
<div className="h-full overflow-auto p-5">
|
<div className="h-full overflow-auto p-5">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-white text-lg font-medium">
|
<h3 className="text-white text-lg font-medium">
|
||||||
Датч.{alert.detector_name}
|
{alert.detector_name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2">
|
<button
|
||||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
onClick={onClose}
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
<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>
|
<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 6l12 12" />
|
||||||
</button>
|
</svg>
|
||||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
</button>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</div>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
{/* Тело: Две колонки - Три строки */}
|
||||||
История
|
<div className="grid grid-cols-2 gap-16 mb-12 flex-grow">
|
||||||
</button>
|
{/* Колонка 1 - Строка 1: Статус */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm">Статус</div>
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<div className={`w-3 h-3 rounded-full ${getStatusColorCircle(alert.status)}`}></div>
|
||||||
|
<span className="text-white text-sm">{getStatusText(alert.status)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Колонка 1 - Строка 2: Причина тревоги */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm">Причина тревоги</div>
|
||||||
|
<div className="text-white text-sm">{alert.message}</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm">Причина тревоги</div>
|
||||||
|
<div className="text-white text-sm">{alert.message}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Колонка 1 - Строка 3: Временная метка */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm">Временная метка</div>
|
||||||
|
<div className="text-white text-sm">{getTimeAgo(alert.timestamp)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Колонка 2 - Строка 1: Приоритет */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm">Приоритет</div>
|
||||||
|
<div className="text-white text-sm text-right">{getPriorityText(alert.priority)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Колонка 2 - Строка 2: Локация */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm">Локация</div>
|
||||||
|
<div className="text-white text-sm text-right">{alert.location}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Колонка 2 - Строка 3: Объект */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm">Объект</div>
|
||||||
|
<div className="text-white text-sm text-right">{alert.object}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Колонка 2 - Строка 4: Отчет */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm">Отчет</div>
|
||||||
|
<div className="text-white text-sm text-right">Доступен</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Колонка 2 - Строка 5: История */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm">История</div>
|
||||||
|
<div className="text-white text-sm text-right">Просмотр</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Табличка с информацией об алерте */}
|
{/* Низ: Две строки - первая содержит дату/время, вторая строка ниже - наш график */}
|
||||||
<div className="space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden">
|
<div className="space-y-8 flex-shrink-0">
|
||||||
<div className="flex">
|
{/* Строка 1: Дата/время */}
|
||||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
<div className="p-8 bg-[rgb(22,24,36)] rounded-lg">
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
|
<div className="text-[rgb(113,113,122)] text-base font-medium mb-4">Дата/время</div>
|
||||||
<div className="text-white text-sm">{alert.detector_name}</div>
|
<div className="text-white text-base">{formatDate(alert.timestamp)}</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-sm font-medium ${getPriorityColor(alert.priority)}`}>
|
|
||||||
{getPriorityText(alert.priority)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
{/* Charts */}
|
||||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
<div className="grid grid-cols-1 gap-6 min-h-[300px]">
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Местоположение</div>
|
<div className="p-2 min-h-[30px]">
|
||||||
<div className="text-white text-sm">{alert.location}</div>
|
<AreaChart data={chartData} />
|
||||||
</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(alert.status)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">{formatDate(alert.timestamp)}</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">{alert.type}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-[rgb(30,31,36)] p-4">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Сообщение</div>
|
|
||||||
<div className="text-white text-sm">{alert.message}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useNavigationStore from '@/app/store/navigationStore'
|
||||||
|
|
||||||
interface DetectorType {
|
interface DetectorType {
|
||||||
detector_id: number
|
detector_id: number
|
||||||
@@ -13,7 +15,7 @@ interface DetectorType {
|
|||||||
detector_type: string
|
detector_type: string
|
||||||
location: string
|
location: string
|
||||||
floor: number
|
floor: number
|
||||||
notifications?: Array<{
|
notifications: Array<{
|
||||||
id: number
|
id: number
|
||||||
type: string
|
type: string
|
||||||
message: string
|
message: string
|
||||||
@@ -32,10 +34,14 @@ interface DetectorMenuProps {
|
|||||||
anchor?: { left: number; top: number } | null
|
anchor?: { left: number; top: number } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Главный компонент меню детектора
|
||||||
|
// Показывает детальную информацию о датчике с возможностью навигации к отчетам и истории
|
||||||
const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = 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
|
if (!isOpen) return null
|
||||||
|
|
||||||
// Получаем самую свежую временную метку из уведомлений
|
// Определение последней временной метки из уведомлений детектора
|
||||||
const latestTimestamp = (() => {
|
const latestTimestamp = (() => {
|
||||||
const list = detector.notifications ?? []
|
const list = detector.notifications ?? []
|
||||||
if (!Array.isArray(list) || list.length === 0) return null
|
if (!Array.isArray(list) || list.length === 0) return null
|
||||||
@@ -48,6 +54,7 @@ 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' })
|
? 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 rawDetectorTypeCode = (detector.detector_type || '').toUpperCase()
|
||||||
const deriveCodeFromType = (): string => {
|
const deriveCodeFromType = (): string => {
|
||||||
const t = (detector.type || '').toLowerCase()
|
const t = (detector.type || '').toLowerCase()
|
||||||
@@ -58,16 +65,73 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
const effectiveDetectorTypeCode = rawDetectorTypeCode || deriveCodeFromType()
|
const effectiveDetectorTypeCode = rawDetectorTypeCode || deriveCodeFromType()
|
||||||
|
|
||||||
|
// Карта соответствия кодов типов детекторов их русским названиям
|
||||||
const detectorTypeLabelMap: Record<string, string> = {
|
const detectorTypeLabelMap: Record<string, string> = {
|
||||||
GA: 'Инклинометр',
|
GA: 'Инклинометр',
|
||||||
PE: 'Тензометр',
|
PE: 'Тензометр',
|
||||||
GLE: 'Гидроуровень',
|
GLE: 'Гидроуровень',
|
||||||
}
|
}
|
||||||
const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—'
|
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 }) => (
|
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'}>
|
<div className={compact ? 'mt-2 space-y-1' : 'space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden'}>
|
||||||
{compact ? (
|
{compact ? (
|
||||||
|
// Компактный режим: 4 строки по 2 колонки с основной информацией
|
||||||
<>
|
<>
|
||||||
|
{/* Строка 1: Маркировка и тип детектора */}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Маркировка по проекту</div>
|
<div className="text-[rgb(113,113,122)] text-[11px]">Маркировка по проекту</div>
|
||||||
@@ -78,6 +142,7 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
<div className="text-white text-xs truncate">{displayDetectorTypeLabel}</div>
|
<div className="text-white text-xs truncate">{displayDetectorTypeLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Строка 2: Местоположение и статус */}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Местоположение</div>
|
<div className="text-[rgb(113,113,122)] text-[11px]">Местоположение</div>
|
||||||
@@ -88,6 +153,7 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
<div className="text-white text-xs truncate">{getStatusText(detector.status)}</div>
|
<div className="text-white text-xs truncate">{getStatusText(detector.status)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Строка 3: Временная метка и этаж */}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Временная метка</div>
|
<div className="text-[rgb(113,113,122)] text-[11px]">Временная метка</div>
|
||||||
@@ -98,6 +164,7 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
<div className="text-white text-xs truncate">{detector.floor}</div>
|
<div className="text-white text-xs truncate">{detector.floor}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Строка 4: Серийный номер */}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Серийный номер</div>
|
<div className="text-[rgb(113,113,122)] text-[11px]">Серийный номер</div>
|
||||||
@@ -106,7 +173,9 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
// Полный режим: 3 строки по 2 колонки с рамками между элементами
|
||||||
<>
|
<>
|
||||||
|
{/* Строка 1: Маркировка по проекту и тип детектора */}
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex-1 p-4 border-r 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-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
|
||||||
@@ -117,6 +186,7 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
<div className="text-white text-sm">{displayDetectorTypeLabel}</div>
|
<div className="text-white text-sm">{displayDetectorTypeLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Строка 2: Местоположение и статус */}
|
||||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
<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="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-[rgb(113,113,122)] text-sm font-medium mb-1">Местоположение</div>
|
||||||
@@ -127,6 +197,7 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
<div className="text-white text-sm">{getStatusText(detector.status)}</div>
|
<div className="text-white text-sm">{getStatusText(detector.status)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Строка 3: Временная метка и серийный номер */}
|
||||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
<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="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-[rgb(113,113,122)] text-sm font-medium mb-1">Временная метка</div>
|
||||||
@@ -142,14 +213,15 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Компактный режим с якорной позицией (всплывающее окно)
|
||||||
|
// Используется для отображения информации при наведении на детектор в списке
|
||||||
if (compact && anchor) {
|
if (compact && anchor) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute z-40" style={{ left: anchor.left, top: anchor.top }}>
|
<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-[240px] max-w-[300px]">
|
<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 items-start justify-between gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-semibold truncate">Датч.{detector.name}</div>
|
<div className="font-semibold truncate">{detector.name}</div>
|
||||||
<div className="opacity-80">{getStatusText(detector.status)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="text-gray-300 hover:text-white transition-colors">
|
<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">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -158,13 +230,13 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
<button onClick={handleReportsClick} className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] 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">
|
<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" />
|
<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>
|
</svg>
|
||||||
Отчет
|
Отчет
|
||||||
</button>
|
</button>
|
||||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
<button onClick={handleHistoryClick} className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] 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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -177,21 +249,25 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Полный режим боковой панели (основной режим)
|
||||||
|
// Отображается как правая панель с полной информацией о детекторе
|
||||||
return (
|
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="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="h-full overflow-auto p-5">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
{/* Заголовок с названием детектора */}
|
||||||
<h3 className="text-white text-lg font-medium">
|
<h3 className="text-white text-lg font-medium">
|
||||||
Датч.{detector.name}
|
{detector.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
{/* Кнопки действий: Отчет и История */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
<button onClick={handleReportsClick} className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] 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">
|
<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" />
|
<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>
|
</svg>
|
||||||
Отчет
|
Отчет
|
||||||
</button>
|
</button>
|
||||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
<button onClick={handleHistoryClick} className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] 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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -200,8 +276,10 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Секция с детальной информацией о детекторе */}
|
||||||
<DetailsSection />
|
<DetailsSection />
|
||||||
|
|
||||||
|
{/* Кнопка закрытия панели */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||||
|
|||||||
@@ -35,11 +35,12 @@ interface DetectorType {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
const FloorNavigation: React.FC<FloorNavigationProps> = ({ objectId, detectorsData, onDetectorMenuClick, onClose, is3DReady = true }) => {
|
const FloorNavigation: React.FC<FloorNavigationProps> = (props) => {
|
||||||
|
const { objectId, detectorsData, onDetectorMenuClick, onClose, is3DReady = true } = props
|
||||||
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set())
|
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set())
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
|
||||||
// конвертация детекторов в array и фильтруем по objectId и тексту запроса
|
// Преобразование detectors в массив и фильтрация по objectId и поисковому запросу
|
||||||
const detectorsArray = Object.values(detectorsData.detectors) as DetectorType[]
|
const detectorsArray = Object.values(detectorsData.detectors) as DetectorType[]
|
||||||
let filteredDetectors = objectId
|
let filteredDetectors = objectId
|
||||||
? detectorsArray.filter(detector => detector.object === objectId)
|
? detectorsArray.filter(detector => detector.object === objectId)
|
||||||
|
|||||||
@@ -1,6 +1,33 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useEffect, useCallback } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import useNavigationStore from '@/app/store/navigationStore';
|
import useNavigationStore from '@/app/store/navigationStore';
|
||||||
|
import type { Zone } from '@/app/types';
|
||||||
|
|
||||||
|
// Безопасный резолвер src изображения, чтобы избежать ошибок Invalid URL в next/image
|
||||||
|
const resolveImageSrc = (src?: string | null): string => {
|
||||||
|
if (!src || typeof src !== 'string') return '/images/test_image.png';
|
||||||
|
let s = src.trim();
|
||||||
|
if (!s) return '/images/test_image.png';
|
||||||
|
s = s.replace(/\\/g, '/');
|
||||||
|
const lower = s.toLowerCase();
|
||||||
|
// Явный плейсхолдер test_image.png маппим на наш статический ресурс
|
||||||
|
if (lower === 'test_image.png' || lower.endsWith('/test_image.png') || lower.includes('/public/images/test_image.png')) {
|
||||||
|
return '/images/test_image.png';
|
||||||
|
}
|
||||||
|
// Если путь содержит public/images (даже абсолютный путь ФС), переводим в относительный путь сайта
|
||||||
|
if (/\/public\/images\//i.test(s)) {
|
||||||
|
const parts = s.split(/\/public\/images\//i);
|
||||||
|
const rel = parts[1] || '';
|
||||||
|
return `/images/${rel}`;
|
||||||
|
}
|
||||||
|
// Абсолютные URL и пути, относительные к сайту
|
||||||
|
if (s.startsWith('http://') || s.startsWith('https://')) return s;
|
||||||
|
if (s.startsWith('/')) return s;
|
||||||
|
// Нормализуем относительные имена ресурсов до путей сайта под /images
|
||||||
|
// Убираем ведущий 'public/', если он присутствует
|
||||||
|
s = s.replace(/^public\//i, '');
|
||||||
|
return s.startsWith('images/') ? `/${s}` : `/images/${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
interface MonitoringProps {
|
interface MonitoringProps {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
@@ -8,9 +35,7 @@ interface MonitoringProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
||||||
const [models, setModels] = useState<{ title: string; path: string }[]>([]);
|
const { currentObject, currentZones, zonesLoading, zonesError, loadZones } = useNavigationStore();
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
|
||||||
const PREFERRED_MODEL = useNavigationStore((state) => state.PREFERRED_MODEL);
|
|
||||||
|
|
||||||
const handleSelectModel = useCallback((modelPath: string) => {
|
const handleSelectModel = useCallback((modelPath: string) => {
|
||||||
console.log(`[NavigationPage] Model selected: ${modelPath}`);
|
console.log(`[NavigationPage] Model selected: ${modelPath}`);
|
||||||
@@ -18,39 +43,17 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|||||||
}, [onSelectModel]);
|
}, [onSelectModel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchModels = async () => {
|
const objId = currentObject?.id;
|
||||||
try {
|
if (!objId) return;
|
||||||
setLoadError(null);
|
loadZones(objId);
|
||||||
const res = await fetch('/api/big-models/list');
|
}, [currentObject?.id, loadZones]);
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || 'Failed to fetch models list');
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
const items: { name: string; path: string }[] = Array.isArray(data?.models) ? data.models : [];
|
|
||||||
|
|
||||||
const preferredModelName = PREFERRED_MODEL.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
|
const sortedZones: Zone[] = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
||||||
|
const oa = typeof a.order === 'number' ? a.order : 0;
|
||||||
const formatted = items
|
const ob = typeof b.order === 'number' ? b.order : 0;
|
||||||
.map((it) => ({ title: it.name, path: it.path }))
|
if (oa !== ob) return oa - ob;
|
||||||
.sort((a, b) => {
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
const aName = a.path.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
|
});
|
||||||
const bName = b.path.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
|
|
||||||
if (aName === preferredModelName) return -1;
|
|
||||||
if (bName === preferredModelName) return 1;
|
|
||||||
return a.title.localeCompare(b.title);
|
|
||||||
});
|
|
||||||
|
|
||||||
setModels(formatted);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Monitoring] Error loading models list:', error);
|
|
||||||
setLoadError(error instanceof Error ? error.message : String(error));
|
|
||||||
setModels([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchModels();
|
|
||||||
}, [PREFERRED_MODEL]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -68,85 +71,86 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* UI зон */}
|
||||||
{loadError && (
|
{zonesError && (
|
||||||
<div className="rounded-lg bg-red-600/20 border border-red-600/40 text-red-200 text-xs px-3 py-2">
|
<div className="rounded-lg bg-red-600/20 border border-red-600/40 text-red-200 text-xs px-3 py-2">
|
||||||
Ошибка загрузки списка моделей: {loadError}
|
Ошибка загрузки зон: {zonesError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{zonesLoading && (
|
||||||
{models.length > 0 && (
|
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
|
||||||
|
Загрузка зон...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sortedZones.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{/* Большая панорамная карточка для приоритетной модели */}
|
{sortedZones[0] && (
|
||||||
{models[0] && (
|
|
||||||
<button
|
<button
|
||||||
key={`${models[0].path}-panorama`}
|
key={`zone-${sortedZones[0].id}-panorama`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelectModel(models[0].path)}
|
onClick={() => sortedZones[0].model_path ? handleSelectModel(sortedZones[0].model_path) : null}
|
||||||
className="w-full bg-gray-300 rounded-lg h-[200px] flex items-center justify-center hover:bg-gray-400 transition-colors mb-4"
|
className="w-full bg-gray-300 rounded-lg h-[200px] flex items-center justify-center hover:bg-gray-400 transition-colors mb-4"
|
||||||
title={`Загрузить модель: ${models[0].title}`}
|
title={sortedZones[0].model_path ? `Открыть 3D модель зоны: ${sortedZones[0].name}` : 'Модель зоны отсутствует'}
|
||||||
|
disabled={!sortedZones[0].model_path}
|
||||||
>
|
>
|
||||||
<div className="w-full h-full bg-gray-200 rounded flex flex-col items-center justify-center relative">
|
<div className="w-full h-full bg-gray-200 rounded flex flex-col items-center justify-center relative">
|
||||||
|
{/* Всегда рендерим с разрешённой заглушкой */}
|
||||||
<Image
|
<Image
|
||||||
src="/images/test_image.png"
|
src={resolveImageSrc(sortedZones[0].image_path)}
|
||||||
alt={models[0].title}
|
alt={sortedZones[0].name || 'Зона'}
|
||||||
width={200}
|
width={200}
|
||||||
height={200}
|
height={200}
|
||||||
className="max-w-full max-h-full object-contain opacity-50"
|
className="max-w-full max-h-full object-contain opacity-50"
|
||||||
style={{ height: 'auto' }}
|
style={{ height: 'auto' }}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
target.style.display = 'none';
|
target.src = '/images/test_image.png';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-2 left-2 right-2 text-sm text-gray-700 bg-white/80 rounded px-3 py-1 truncate">
|
<div className="absolute bottom-2 left-2 right-2 text-sm text-gray-700 bg-white/80 rounded px-3 py-1 truncate">
|
||||||
{models[0].title}
|
{sortedZones[0].name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{sortedZones.length > 1 && (
|
||||||
{/* Сетка маленьких карточек для остальных моделей */}
|
|
||||||
{models.length > 1 && (
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{models.slice(1).map((model, idx) => (
|
{sortedZones.slice(1).map((zone: Zone, idx: number) => (
|
||||||
<button
|
<button
|
||||||
key={`${model.path}-${idx}`}
|
key={`zone-${zone.id}-${idx}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelectModel(model.path)}
|
onClick={() => zone.model_path ? handleSelectModel(zone.model_path) : null}
|
||||||
className="relative flex-1 bg-gray-300 rounded-lg h-[120px] flex items-center justify-center hover:bg-gray-400 transition-colors"
|
className="relative flex-1 bg-gray-300 rounded-lg h-[120px] flex items-center justify-center hover:bg-gray-400 transition-colors"
|
||||||
title={`Загрузить модель: ${model.title}`}
|
title={zone.model_path ? `Открыть 3D модель зоны: ${zone.name}` : 'Модель зоны отсутствует'}
|
||||||
>
|
disabled={!zone.model_path}
|
||||||
|
>
|
||||||
<div className="w-full h-full bg-gray-200 rounded flex flex-col items-center justify-center relative">
|
<div className="w-full h-full bg-gray-200 rounded flex flex-col items-center justify-center relative">
|
||||||
<Image
|
<Image
|
||||||
src="/images/test_image.png"
|
src={resolveImageSrc(zone.image_path)}
|
||||||
alt={model.title}
|
alt={zone.name || 'Зона'}
|
||||||
width={120}
|
width={120}
|
||||||
height={120}
|
height={120}
|
||||||
className="max-w-full max-h-full object-contain opacity-50"
|
className="max-w-full max-h-full object-contain opacity-50"
|
||||||
style={{ height: 'auto' }}
|
style={{ height: 'auto' }}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
target.style.display = 'none';
|
target.src = '/images/test_image.png';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-1 left-1 right-1 text-[10px] text-gray-700 bg-white/70 rounded px-2 py-0.5 truncate">
|
<div className="absolute bottom-1 left-1 right-1 text-[10px] text-gray-700 bg-white/70 rounded px-2 py-0.5 truncate">
|
||||||
{model.title}
|
{zone.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{sortedZones.length === 0 && !zonesError && !zonesLoading && (
|
||||||
{models.length === 0 && !loadError && (
|
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
|
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
|
||||||
Список моделей пуст. Добавьте файлы в assets/big-models или проверьте API /api/big-models/list.
|
Зоны не найдены для выбранного объекта. Проверьте параметр objectId в API /api/get-zones.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -67,6 +67,15 @@ const NotificationDetectorInfo: React.FC<NotificationDetectorInfoProps> = ({ det
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getPriorityText = (priority: string) => {
|
||||||
|
switch (priority.toLowerCase()) {
|
||||||
|
case 'high': return 'высокий'
|
||||||
|
case 'medium': return 'средний'
|
||||||
|
case 'low': return 'низкий'
|
||||||
|
default: return priority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const latestNotification = detectorInfo.notifications && detectorInfo.notifications.length > 0
|
const latestNotification = detectorInfo.notifications && detectorInfo.notifications.length > 0
|
||||||
? detectorInfo.notifications.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0]
|
? detectorInfo.notifications.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0]
|
||||||
: null
|
: null
|
||||||
@@ -87,7 +96,7 @@ const NotificationDetectorInfo: React.FC<NotificationDetectorInfoProps> = ({ det
|
|||||||
<div className="h-full overflow-auto">
|
<div className="h-full overflow-auto">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-white text-lg font-medium">
|
<h3 className="text-white text-lg font-medium">
|
||||||
Датч.{detectorInfo.name}
|
{detectorInfo.name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||||
@@ -102,10 +111,18 @@ const NotificationDetectorInfo: React.FC<NotificationDetectorInfoProps> = ({ det
|
|||||||
</svg>
|
</svg>
|
||||||
История
|
История
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Табличка с детекторами */}
|
{/* Табличка */}
|
||||||
<div className="space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden">
|
<div className="space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||||
@@ -146,7 +163,7 @@ const NotificationDetectorInfo: React.FC<NotificationDetectorInfoProps> = ({ det
|
|||||||
<div className="flex-1 p-4">
|
<div className="flex-1 p-4">
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Приоритет</div>
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Приоритет</div>
|
||||||
<div className={`text-sm font-medium ${getPriorityColor(latestNotification.priority)}`}>
|
<div className={`text-sm font-medium ${getPriorityColor(latestNotification.priority)}`}>
|
||||||
{latestNotification.priority}
|
{getPriorityText(latestNotification.priority)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,14 +176,6 @@ const NotificationDetectorInfo: React.FC<NotificationDetectorInfoProps> = ({ det
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface ObjectData {
|
|||||||
object_id: string
|
object_id: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
image: string
|
image: string | null
|
||||||
location: string
|
location: string
|
||||||
floors?: number
|
floors?: number
|
||||||
area?: string
|
area?: string
|
||||||
@@ -45,6 +45,41 @@ const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected =
|
|||||||
// Логика редактирования объекта
|
// Логика редактирования объекта
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Возврат к тестовому изображению, если 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 (
|
return (
|
||||||
<article
|
<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-[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] ${
|
||||||
@@ -85,13 +120,13 @@ const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected =
|
|||||||
<Image
|
<Image
|
||||||
className="absolute w-full h-full top-0 left-0 object-cover"
|
className="absolute w-full h-full top-0 left-0 object-cover"
|
||||||
alt={object.title}
|
alt={object.title}
|
||||||
src={object.image}
|
src={imgSrc}
|
||||||
fill
|
fill
|
||||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// Заглушка при ошибке загрузки изображения
|
// Заглушка при ошибке загрузки изображения
|
||||||
const target = e.target as HTMLImageElement
|
const target = e.target as HTMLImageElement
|
||||||
target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDUyIiBoZWlnaHQ9IjMwMiIgdmlld0JveD0iMCAwIDQ1MiAzMDIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSI0NTIiIGhlaWdodD0iMzAyIiBmaWxsPSIjRjFGMUYxIi8+CjxwYXRoIGQ9Ik0yMjYgMTUxTDI0NiAxMzFMMjY2IDE1MUwyNDYgMTcxTDIyNiAxNTFaIiBmaWxsPSIjOTk5OTk5Ii8+Cjx0ZXh0IHg9IjIyNiIgeT0iMTkwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5OTk5IiBmb250LXNpemU9IjE0Ij7QntCx0YrQtdC60YI8L3RleHQ+Cjwvc3ZnPgo='
|
target.src = '/images/test_image.png'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,13 +43,12 @@ interface DetectorsDataType {
|
|||||||
interface ReportsListProps {
|
interface ReportsListProps {
|
||||||
objectId?: string;
|
objectId?: string;
|
||||||
detectorsData: DetectorsDataType;
|
detectorsData: DetectorsDataType;
|
||||||
|
initialSearchTerm?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReportsList: React.FC<ReportsListProps> = ({ detectorsData }) => {
|
const ReportsList: React.FC<ReportsListProps> = ({ detectorsData, initialSearchTerm = '' }) => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState(initialSearchTerm);
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
const [priorityFilter] = useState('all');
|
|
||||||
const [acknowledgedFilter] = useState('all');
|
|
||||||
|
|
||||||
const allNotifications = useMemo(() => {
|
const allNotifications = useMemo(() => {
|
||||||
const notifications: NotificationType[] = [];
|
const notifications: NotificationType[] = [];
|
||||||
@@ -71,19 +70,11 @@ const ReportsList: React.FC<ReportsListProps> = ({ detectorsData }) => {
|
|||||||
|
|
||||||
const filteredDetectors = useMemo(() => {
|
const filteredDetectors = useMemo(() => {
|
||||||
return allNotifications.filter(notification => {
|
return allNotifications.filter(notification => {
|
||||||
const matchesSearch = notification.detector_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = searchTerm === '' || notification.detector_id.toString() === searchTerm;
|
||||||
notification.location.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
notification.message.toLowerCase().includes(searchTerm.toLowerCase());
|
|
||||||
|
|
||||||
const matchesStatus = statusFilter === 'all' || notification.type === statusFilter;
|
return matchesSearch;
|
||||||
const matchesPriority = priorityFilter === 'all' || notification.priority === priorityFilter;
|
|
||||||
const matchesAcknowledged = acknowledgedFilter === 'all' ||
|
|
||||||
(acknowledgedFilter === 'acknowledged' && notification.acknowledged) ||
|
|
||||||
(acknowledgedFilter === 'unacknowledged' && !notification.acknowledged);
|
|
||||||
|
|
||||||
return matchesSearch && matchesStatus && matchesPriority && matchesAcknowledged;
|
|
||||||
});
|
});
|
||||||
}, [allNotifications, searchTerm, statusFilter, priorityFilter, acknowledgedFilter]);
|
}, [allNotifications, searchTerm]);
|
||||||
|
|
||||||
const getStatusColor = (type: string) => {
|
const getStatusColor = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -168,7 +159,7 @@ const ReportsList: React.FC<ReportsListProps> = ({ detectorsData }) => {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Поиск детекторов..."
|
placeholder="Поиск по ID детектора..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
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"
|
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"
|
||||||
|
|||||||
BIN
frontend/public/images/test_image_2.png
Normal file
BIN
frontend/public/images/test_image_2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@@ -37,7 +37,7 @@ export class NavigationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
navigateToRoute(route: MainRoutes) {
|
navigateToRoute(route: MainRoutes) {
|
||||||
// Убираем суб-меню перед переходом на другую страницу
|
// Убираем подменю перед переходом на другую страницу
|
||||||
if (route !== MainRoutes.NAVIGATION) {
|
if (route !== MainRoutes.NAVIGATION) {
|
||||||
this.navigationStore.setCurrentSubmenu(null)
|
this.navigationStore.setCurrentSubmenu(null)
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,10 @@ export class NavigationService {
|
|||||||
|
|
||||||
selectObjectAndGoToDashboard(objectId: string, objectTitle: string) {
|
selectObjectAndGoToDashboard(objectId: string, objectTitle: string) {
|
||||||
this.navigationStore.setCurrentObject(objectId, objectTitle)
|
this.navigationStore.setCurrentObject(objectId, objectTitle)
|
||||||
this.navigateToRoute(MainRoutes.DASHBOARD)
|
// Проверяем, что подменю закрыто перед навигацией
|
||||||
|
this.navigationStore.setCurrentSubmenu(null)
|
||||||
|
const url = `${MainRoutes.DASHBOARD}?objectId=${encodeURIComponent(objectId)}&objectTitle=${encodeURIComponent(objectTitle)}`
|
||||||
|
this.router.push(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user