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:
iv_vuytsik
2025-12-25 03:10:21 +03:00
parent 31030f2997
commit ce7e39debf
36 changed files with 1562 additions and 472 deletions

View 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

View File

@@ -31,18 +31,16 @@ interface RawDetector {
}>
}
type FilterType = 'all' | 'critical' | 'warning' | 'normal'
interface DetectorListProps {
objectId?: string
selectedDetectors: number[]
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 [selectedFilter, setSelectedFilter] = useState<FilterType>('all')
const [searchTerm, setSearchTerm] = useState<string>('')
const [searchTerm, setSearchTerm] = useState<string>(initialSearchTerm)
useEffect(() => {
const loadDetectors = async () => {
@@ -72,14 +70,8 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
loadDetectors()
}, [objectId])
const filteredDetectors = detectors.filter(detector => {
const matchesSearch = detector.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
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'
const filteredDetectors = detectors.filter(detector => {
const matchesSearch = searchTerm === '' || detector.detector_id.toString() === searchTerm
return matchesSearch
})
@@ -88,53 +80,13 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<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 className="flex items-center gap-3">
<div className="relative">
<input
type="text"
placeholder="Поиск детекторов..."
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"

View File

@@ -60,7 +60,7 @@ const Dashboard: React.FC = () => {
loadDashboard()
}, [objectTitle, selectedChartPeriod, selectedSensorType])
// Separate effect for table data based on table period
// Отдельный эффект для загрузки таблицы по выбранному периоду
useEffect(() => {
const loadTableData = async () => {
try {
@@ -195,7 +195,7 @@ const Dashboard: React.FC = () => {
<option value="24">День</option>
<option value="72">3 дня</option>
<option value="168">Неделя</option>
<option value="720">Месяц</option>
<option value="720">Месяц</option>
</select>
<svg className="w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
@@ -207,15 +207,13 @@ const Dashboard: React.FC = () => {
{/* Карты-графики */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px]">
<ChartCard
title="Показатель"
// subtitle removed
title="Показатель"
>
<AreaChart data={chartData} />
</ChartCard>
<ChartCard
title="Статистика"
// subtitle removed
title="Статистика"
>
<BarChart data={chartData?.map((d: any) => ({ value: d.value }))} />
</ChartCard>
@@ -236,7 +234,7 @@ const Dashboard: React.FC = () => {
<option value="24">День</option>
<option value="72">3 дня</option>
<option value="168">Неделя</option>
<option value="720">Месяц</option>
<option value="720">Месяц</option>
</select>
<svg className="w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />

View File

@@ -39,8 +39,12 @@ export interface ModelViewerProps {
}
}) => void
onError?: (error: string) => void
activeMenu?: string | null
focusSensorId?: string | null
renderOverlay?: (params: { anchor: { left: number; top: number } | null; info?: { name?: string; sensorId?: string } | null }) => React.ReactNode
isSensorSelectionEnabled?: boolean
onSensorPick?: (sensorId: string | null) => void
highlightAllSensors?: boolean
}
const ModelViewer: React.FC<ModelViewerProps> = ({
@@ -50,6 +54,9 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
onError,
focusSensorId,
renderOverlay,
isSensorSelectionEnabled,
onSensorPick,
highlightAllSensors,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const engineRef = useRef<Nullable<Engine>>(null)
@@ -61,6 +68,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
const isDisposedRef = useRef(false)
const importedMeshesRef = useRef<AbstractMesh[]>([])
const highlightLayerRef = useRef<HighlightLayer | null>(null)
const highlightedMeshesRef = useRef<AbstractMesh[]>([])
const chosenMeshRef = useRef<AbstractMesh | null>(null)
const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null)
const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null)
@@ -214,7 +222,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
if (!canvasRef.current || isInitializedRef.current) return
const canvas = canvasRef.current
const engine = new Engine(canvas, true)
const engine = new Engine(canvas, true, { stencil: true })
engineRef.current = engine
engine.runRenderLoop(() => {
@@ -419,29 +427,40 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
}, [modelPath, onError, onModelLoaded])
useEffect(() => {
console.log('[ModelViewer] highlightAllSensors effect triggered:', { highlightAllSensors, modelReady, sceneReady: !!sceneRef.current })
if (!sceneRef.current || isDisposedRef.current || !modelReady) return
// Если включено выделение всех сенсоров - выделяем их все
if (highlightAllSensors) {
console.log('[ModelViewer] Calling highlightAllSensorMeshes()')
highlightAllSensorMeshes()
return
}
const sensorId = (focusSensorId ?? '').trim()
if (!sensorId) {
console.log('[ModelViewer] Focus cleared (no Sensor_ID provided)')
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
highlightedMeshesRef.current = []
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
return
}
}
const allMeshes = importedMeshesRef.current || []
if (allMeshes.length === 0) {
console.warn('[ModelViewer] No meshes available for sensor matching')
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
highlightedMeshesRef.current = []
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
return
}
}
const sensorMeshes = allMeshes.filter((m: any) => {
try {
@@ -516,15 +535,26 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
const hl = highlightLayerRef.current
if (hl) {
// Переключаем группу рендеринга для предыдущего выделенного меша
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
highlightedMeshesRef.current = []
hl.removeAllMeshes()
if (chosen instanceof Mesh) {
chosen.renderingGroupId = 1
highlightedMeshesRef.current.push(chosen)
hl.addMesh(chosen, new Color3(1, 1, 0))
} else if (chosen instanceof InstancedMesh) {
// Сохраняем исходный меш для инстанса
chosen.sourceMesh.renderingGroupId = 1
highlightedMeshesRef.current.push(chosen.sourceMesh)
hl.addMesh(chosen.sourceMesh, new Color3(1, 1, 0))
} else {
const children = typeof (chosen as any)?.getChildMeshes === 'function' ? (chosen as any).getChildMeshes() : []
for (const cm of children) {
if (cm instanceof Mesh) {
cm.renderingGroupId = 1
highlightedMeshesRef.current.push(cm)
hl.addMesh(cm, new Color3(1, 1, 0))
}
}
@@ -534,18 +564,144 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
setOverlayData({ name: chosen.name, sensorId })
} catch (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()
chosenMeshRef.current = null
setOverlayPos(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) => {

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import Image from 'next/image';
import useNavigationStore from '@/app/store/navigationStore';
import type { Zone } from '@/app/types';
interface ToolbarButton {
icon: string;
@@ -32,7 +33,7 @@ const SceneToolbar: React.FC<SceneToolbarProps> = ({
navMenuActive = false,
}) => {
const [isZoomOpen, setIsZoomOpen] = useState(false);
const { PREFERRED_MODEL, showMonitoring, openMonitoring, closeMonitoring } = useNavigationStore();
const { showMonitoring, openMonitoring, closeMonitoring, currentZones, loadZones, currentObject } = useNavigationStore();
const handleToggleNavMenu = () => {
if (showMonitoring) {
@@ -43,25 +44,46 @@ const SceneToolbar: React.FC<SceneToolbarProps> = ({
};
const handleHomeClick = async () => {
if (onSelectModel) {
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 (!onSelectModel) return;
if (preferredModel) {
onSelectModel(preferredModel.path);
} else {
console.error('Preferred model not found in the list');
try {
let zones: Zone[] = Array.isArray(currentZones) ? currentZones : [];
// Если зоны ещё не загружены, откройте Monitoring и загрузите зоны для текущего объекта
if ((!zones || zones.length === 0) && currentObject?.id) {
if (!showMonitoring) {
openMonitoring();
}
} catch (error) {
console.error('Error fetching models list:', error);
await loadZones(currentObject.id);
zones = useNavigationStore.getState().currentZones || [];
}
if (!Array.isArray(zones) || zones.length === 0) {
console.warn('No zones available to select a model from.');
return;
}
const sorted = zones.slice().sort((a: Zone, b: Zone) => {
const oa = typeof a.order === 'number' ? a.order : 0;
const ob = typeof b.order === 'number' ? b.order : 0;
if (oa !== ob) return oa - ob;
return (a.name || '').localeCompare(b.name || '');
});
const top = sorted[0];
let chosenPath: string | null = top?.model_path && String(top.model_path).trim() ? top.model_path! : null;
if (!chosenPath) {
const nextWithModel = sorted.find((z) => z.model_path && String(z.model_path).trim());
chosenPath = nextWithModel?.model_path ?? null;
}
if (chosenPath) {
onSelectModel(chosenPath);
} else {
console.warn('No zone has a valid model_path to open.');
}
} catch (error) {
console.error('Error selecting top zone model:', error);
}
};

View File

@@ -1,7 +1,10 @@
'use client'
import React from 'react'
import { useRouter } from 'next/navigation'
import useNavigationStore from '@/app/store/navigationStore'
import AreaChart from '../dashboard/AreaChart'
interface AlertType {
id: number
detector_id: number
@@ -26,6 +29,9 @@ interface AlertMenuProps {
}
const AlertMenu: React.FC<AlertMenuProps> = ({ alert, isOpen, onClose, getStatusText, compact = false, anchor = null }) => {
const router = useRouter()
const { setSelectedDetector, currentObject } = useNavigationStore()
if (!isOpen) return null
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) => {
if (typeof priority !== 'string') return 'Неизвестно';
switch (priority.toLowerCase()) {
case 'high': 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) {
return (
<div className="absolute z-40" style={{ left: anchor.left, top: anchor.top }}>
<div className="rounded-[10px] bg-black/80 text-white text-xs px-3 py-2 shadow-xl min-w-[240px] max-w-[300px]">
<div className="flex items-start justify-between gap-3">
<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-4 mb-3">
<div className="flex-1">
<div className="font-semibold truncate">Датч.{alert.detector_name}</div>
<div className="opacity-80">{getStatusText(alert.status)}</div>
<div className="font-semibold truncate text-base">{alert.detector_name}</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>
<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">
@@ -73,47 +198,40 @@ const AlertMenu: React.FC<AlertMenuProps> = ({ alert, isOpen, onClose, getStatus
</button>
</div>
<div className="mt-2 space-y-1">
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Приоритет</div>
<div className={`text-xs font-medium ${getPriorityColor(alert.priority)}`}>
{getPriorityText(alert.priority)}
</div>
</div>
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Время</div>
<div className="text-white text-xs truncate">{formatDate(alert.timestamp)}</div>
{/* Тело: 3 строки / 2 колонки */}
<div className="space-y-2 mb-6">
{/* Строка 1: Статус */}
<div className="grid grid-cols-2 gap-3">
<div className="text-[rgb(113,113,122)] text-xs">Статус</div>
<div className="flex items-center gap-1 justify-end">
<div className={`w-2 h-2 rounded-full ${getStatusColorCircle(alert.status)}`}></div>
<div className="text-white text-xs">{getStatusText(alert.status)}</div>
</div>
</div>
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Сообщение</div>
<div className="text-white text-xs">{alert.message}</div>
{/* Строка 2: Причина тревоги */}
<div className="grid grid-cols-2 gap-3">
<div className="text-[rgb(113,113,122)] text-xs">Причина тревоги</div>
<div className="text-white text-xs truncate">{alert.message}</div>
</div>
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Местоположение</div>
<div className="text-white text-xs">{alert.location}</div>
{/* Строка 3: Временная метка */}
<div className="grid grid-cols-2 gap-3">
<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 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">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Отчет
</button>
<button 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">
<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 className="space-y-6">
<div className="text-[rgb(113,113,122)] text-xs text-center">{formatDate(alert.timestamp)}</div>
<div className="grid grid-cols-1 gap-6 min-h-[70px]">
<AreaChart data={chartData} />
</div>
</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="flex items-center justify-between mb-4">
<h3 className="text-white text-lg font-medium">
Датч.{alert.detector_name}
{alert.detector_name}
</h3>
<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">
<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 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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
История
</button>
<button
onClick={onClose}
className="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 className="grid grid-cols-2 gap-16 mb-12 flex-grow">
{/* Колонка 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 className="space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden">
<div className="flex">
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
<div className="text-white text-sm">{alert.detector_name}</div>
</div>
<div className="flex-1 p-4">
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Приоритет</div>
<div className={`text-sm font-medium ${getPriorityColor(alert.priority)}`}>
{getPriorityText(alert.priority)}
</div>
</div>
{/* Низ: Две строки - первая содержит дату/время, вторая строка ниже - наш график */}
<div className="space-y-8 flex-shrink-0">
{/* Строка 1: Дата/время */}
<div className="p-8 bg-[rgb(22,24,36)] rounded-lg">
<div className="text-[rgb(113,113,122)] text-base font-medium mb-4">Дата/время</div>
<div className="text-white text-base">{formatDate(alert.timestamp)}</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">{alert.location}</div>
{/* Charts */}
<div className="grid grid-cols-1 gap-6 min-h-[300px]">
<div className="p-2 min-h-[30px]">
<AreaChart data={chartData} />
</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>

View File

@@ -1,7 +1,9 @@
'use client'
import React from 'react'
import { useRouter } from 'next/navigation'
import useNavigationStore from '@/app/store/navigationStore'
interface DetectorType {
detector_id: number
name: string
@@ -13,7 +15,7 @@ interface DetectorType {
detector_type: string
location: string
floor: number
notifications?: Array<{
notifications: Array<{
id: number
type: string
message: string
@@ -22,7 +24,7 @@ interface DetectorType {
priority: string
}>
}
interface DetectorMenuProps {
detector: DetectorType
isOpen: boolean
@@ -32,10 +34,14 @@ interface DetectorMenuProps {
anchor?: { left: number; top: number } | null
}
// Главный компонент меню детектора
// Показывает детальную информацию о датчике с возможностью навигации к отчетам и истории
const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => {
const router = useRouter()
const { setSelectedDetector, currentObject } = useNavigationStore()
if (!isOpen) return null
// Получаем самую свежую временную метку из уведомлений
// Определение последней временной метки из уведомлений детектора
const latestTimestamp = (() => {
const list = detector.notifications ?? []
if (!Array.isArray(list) || list.length === 0) return null
@@ -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' })
: 'Нет данных'
// Определение типа детектора и его отображаемого названия
const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase()
const deriveCodeFromType = (): string => {
const t = (detector.type || '').toLowerCase()
@@ -58,16 +65,73 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
return ''
}
const effectiveDetectorTypeCode = rawDetectorTypeCode || deriveCodeFromType()
// Карта соответствия кодов типов детекторов их русским названиям
const detectorTypeLabelMap: Record<string, string> = {
GA: 'Инклинометр',
PE: 'Тензометр',
GLE: 'Гидроуровень',
}
const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—'
// Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором
const handleReportsClick = () => {
const currentUrl = new URL(window.location.href)
const objectId = currentUrl.searchParams.get('objectId') || currentObject.id
const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title
const detectorData = {
...detector,
notifications: detector.notifications || []
}
setSelectedDetector(detectorData)
let reportsUrl = '/reports'
const params = new URLSearchParams()
if (objectId) params.set('objectId', objectId)
if (objectTitle) params.set('objectTitle', objectTitle)
if (params.toString()) {
reportsUrl += `?${params.toString()}`
}
router.push(reportsUrl)
}
// Обработчик клика по кнопке "История" - навигация на страницу истории тревог с выбранным детектором
const handleHistoryClick = () => {
const currentUrl = new URL(window.location.href)
const objectId = currentUrl.searchParams.get('objectId') || currentObject.id
const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title
const detectorData = {
...detector,
notifications: detector.notifications || []
}
setSelectedDetector(detectorData)
let alertsUrl = '/alerts'
const params = new URLSearchParams()
if (objectId) params.set('objectId', objectId)
if (objectTitle) params.set('objectTitle', objectTitle)
if (params.toString()) {
alertsUrl += `?${params.toString()}`
}
router.push(alertsUrl)
}
// Компонент секции деталей детектора
// Отображает информацию о датчике в компактном или полном формате
const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => (
<div className={compact ? 'mt-2 space-y-1' : 'space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden'}>
{compact ? (
// Компактный режим: 4 строки по 2 колонки с основной информацией
<>
{/* Строка 1: Маркировка и тип детектора */}
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Маркировка по проекту</div>
@@ -78,6 +142,7 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
<div className="text-white text-xs truncate">{displayDetectorTypeLabel}</div>
</div>
</div>
{/* Строка 2: Местоположение и статус */}
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Местоположение</div>
@@ -88,6 +153,7 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
<div className="text-white text-xs truncate">{getStatusText(detector.status)}</div>
</div>
</div>
{/* Строка 3: Временная метка и этаж */}
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Временная метка</div>
@@ -98,6 +164,7 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
<div className="text-white text-xs truncate">{detector.floor}</div>
</div>
</div>
{/* Строка 4: Серийный номер */}
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Серийный номер</div>
@@ -106,7 +173,9 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
</div>
</>
) : (
// Полный режим: 3 строки по 2 колонки с рамками между элементами
<>
{/* Строка 1: Маркировка по проекту и тип детектора */}
<div className="flex">
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
@@ -117,6 +186,7 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
<div className="text-white text-sm">{displayDetectorTypeLabel}</div>
</div>
</div>
{/* Строка 2: Местоположение и статус */}
<div className="flex border-t border-[rgb(30,31,36)]">
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Местоположение</div>
@@ -127,6 +197,7 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
<div className="text-white text-sm">{getStatusText(detector.status)}</div>
</div>
</div>
{/* Строка 3: Временная метка и серийный номер */}
<div className="flex border-t border-[rgb(30,31,36)]">
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Временная метка</div>
@@ -142,14 +213,15 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
</div>
)
// Компактный режим с якорной позицией (всплывающее окно)
// Используется для отображения информации при наведении на детектор в списке
if (compact && anchor) {
return (
<div className="absolute z-40" style={{ left: anchor.left, top: anchor.top }}>
<div className="rounded-[10px] bg-black/80 text-white text-xs px-3 py-2 shadow-xl min-w-[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-1">
<div className="font-semibold truncate">Датч.{detector.name}</div>
<div className="opacity-80">{getStatusText(detector.status)}</div>
<div className="font-semibold truncate">{detector.name}</div>
</div>
<button onClick={onClose} className="text-gray-300 hover:text-white transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -158,13 +230,13 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
</button>
</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">
<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">
<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 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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@@ -177,21 +249,25 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
)
}
// Полный режим боковой панели (основной режим)
// Отображается как правая панель с полной информацией о детекторе
return (
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 з-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
<div className="h-full overflow-auto p-5">
<div className="flex items-center justify-between mb-4">
{/* Заголовок с названием детектора */}
<h3 className="text-white text-lg font-medium">
Датч.{detector.name}
{detector.name}
</h3>
{/* Кнопки действий: Отчет и История */}
<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">
<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 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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@@ -200,8 +276,10 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
</div>
</div>
{/* Секция с детальной информацией о детекторе */}
<DetailsSection />
{/* Кнопка закрытия панели */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
@@ -214,5 +292,5 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
</div>
)
}
export default DetectorMenu

View File

@@ -1,7 +1,7 @@
'use client'
import React, { useState } from 'react'
interface DetectorsDataType {
detectors: Record<string, DetectorType>
}
@@ -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 [searchTerm, setSearchTerm] = useState('')
// конвертация детекторов в array и фильтруем по objectId и тексту запроса
// Преобразование detectors в массив и фильтрация по objectId и поисковому запросу
const detectorsArray = Object.values(detectorsData.detectors) as DetectorType[]
let filteredDetectors = objectId
? detectorsArray.filter(detector => detector.object === objectId)

View File

@@ -1,6 +1,33 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useEffect, useCallback } from 'react';
import Image from 'next/image';
import useNavigationStore from '@/app/store/navigationStore';
import type { Zone } from '@/app/types';
// Безопасный резолвер src изображения, чтобы избежать ошибок Invalid URL в next/image
const resolveImageSrc = (src?: string | null): string => {
if (!src || typeof src !== 'string') return '/images/test_image.png';
let s = src.trim();
if (!s) return '/images/test_image.png';
s = s.replace(/\\/g, '/');
const lower = s.toLowerCase();
// Явный плейсхолдер test_image.png маппим на наш статический ресурс
if (lower === 'test_image.png' || lower.endsWith('/test_image.png') || lower.includes('/public/images/test_image.png')) {
return '/images/test_image.png';
}
// Если путь содержит public/images (даже абсолютный путь ФС), переводим в относительный путь сайта
if (/\/public\/images\//i.test(s)) {
const parts = s.split(/\/public\/images\//i);
const rel = parts[1] || '';
return `/images/${rel}`;
}
// Абсолютные URL и пути, относительные к сайту
if (s.startsWith('http://') || s.startsWith('https://')) return s;
if (s.startsWith('/')) return s;
// Нормализуем относительные имена ресурсов до путей сайта под /images
// Убираем ведущий 'public/', если он присутствует
s = s.replace(/^public\//i, '');
return s.startsWith('images/') ? `/${s}` : `/images/${s}`;
}
interface MonitoringProps {
onClose?: () => void;
@@ -8,49 +35,25 @@ interface MonitoringProps {
}
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
const [models, setModels] = useState<{ title: string; path: string }[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const PREFERRED_MODEL = useNavigationStore((state) => state.PREFERRED_MODEL);
const { currentObject, currentZones, zonesLoading, zonesError, loadZones } = useNavigationStore();
const handleSelectModel = useCallback((modelPath: string) => {
console.log(`[NavigationPage] Model selected: ${modelPath}`);
onSelectModel?.(modelPath);
}, [onSelectModel]);
useEffect(() => {
const fetchModels = async () => {
try {
setLoadError(null);
const res = await fetch('/api/big-models/list');
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 objId = currentObject?.id;
if (!objId) return;
loadZones(objId);
}, [currentObject?.id, loadZones]);
const preferredModelName = PREFERRED_MODEL.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
const formatted = items
.map((it) => ({ title: it.name, path: it.path }))
.sort((a, b) => {
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]);
const sortedZones: Zone[] = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
const oa = typeof a.order === 'number' ? a.order : 0;
const ob = typeof b.order === 'number' ? b.order : 0;
if (oa !== ob) return oa - ob;
return (a.name || '').localeCompare(b.name || '');
});
return (
<div className="w-full">
@@ -68,85 +71,86 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
</button>
)}
</div>
{loadError && (
{/* UI зон */}
{zonesError && (
<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>
)}
{models.length > 0 && (
{zonesLoading && (
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
Загрузка зон...
</div>
)}
{sortedZones.length > 0 && (
<>
{/* Большая панорамная карточка для приоритетной модели */}
{models[0] && (
{sortedZones[0] && (
<button
key={`${models[0].path}-panorama`}
key={`zone-${sortedZones[0].id}-panorama`}
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"
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">
{/* Всегда рендерим с разрешённой заглушкой */}
<Image
src="/images/test_image.png"
alt={models[0].title}
src={resolveImageSrc(sortedZones[0].image_path)}
alt={sortedZones[0].name || 'Зона'}
width={200}
height={200}
className="max-w-full max-h-full object-contain opacity-50"
style={{ height: 'auto' }}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.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">
{models[0].title}
{sortedZones[0].name}
</div>
</div>
</button>
)}
{/* Сетка маленьких карточек для остальных моделей */}
{models.length > 1 && (
{sortedZones.length > 1 && (
<div className="grid grid-cols-2 gap-3">
{models.slice(1).map((model, idx) => (
{sortedZones.slice(1).map((zone: Zone, idx: number) => (
<button
key={`${model.path}-${idx}`}
key={`zone-${zone.id}-${idx}`}
type="button"
onClick={() => handleSelectModel(model.path)}
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}`}
>
onClick={() => zone.model_path ? handleSelectModel(zone.model_path) : null}
className="relative flex-1 bg-gray-300 rounded-lg h-[120px] flex items-center justify-center hover:bg-gray-400 transition-colors"
title={zone.model_path ? `Открыть 3D модель зоны: ${zone.name}` : 'Модель зоны отсутствует'}
disabled={!zone.model_path}
>
<div className="w-full h-full bg-gray-200 rounded flex flex-col items-center justify-center relative">
<Image
src="/images/test_image.png"
alt={model.title}
src={resolveImageSrc(zone.image_path)}
alt={zone.name || 'Зона'}
width={120}
height={120}
className="max-w-full max-h-full object-contain opacity-50"
style={{ height: 'auto' }}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.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">
{model.title}
{zone.name}
</div>
</div>
</button>
))}
</div>
)}
</>
)}
{models.length === 0 && !loadError && (
{sortedZones.length === 0 && !zonesError && !zonesLoading && (
<div className="col-span-2">
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
Список моделей пуст. Добавьте файлы в assets/big-models или проверьте API /api/big-models/list.
Зоны не найдены для выбранного объекта. Проверьте параметр objectId в API /api/get-zones.
</div>
</div>
)}

View File

@@ -66,6 +66,15 @@ const NotificationDetectorInfo: React.FC<NotificationDetectorInfoProps> = ({ det
default: return 'text-gray-400'
}
}
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
? detectorInfo.notifications.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0]
@@ -87,7 +96,7 @@ const NotificationDetectorInfo: React.FC<NotificationDetectorInfoProps> = ({ det
<div className="h-full overflow-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-white text-lg font-medium">
Датч.{detectorInfo.name}
{detectorInfo.name}
</h3>
<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">
@@ -102,10 +111,18 @@ const NotificationDetectorInfo: React.FC<NotificationDetectorInfoProps> = ({ det
</svg>
История
</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 className="space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden">
<div className="flex">
<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="text-[rgb(113,113,122)] text-sm font-medium mb-1">Приоритет</div>
<div className={`text-sm font-medium ${getPriorityColor(latestNotification.priority)}`}>
{latestNotification.priority}
{getPriorityText(latestNotification.priority)}
</div>
</div>
</div>
@@ -159,14 +176,6 @@ const NotificationDetectorInfo: React.FC<NotificationDetectorInfoProps> = ({ det
)}
</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>
)

View File

@@ -7,7 +7,7 @@ interface ObjectData {
object_id: string
title: string
description: string
image: string
image: string | null
location: string
floors?: number
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 (
<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] ${
@@ -85,13 +120,13 @@ const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected =
<Image
className="absolute w-full h-full top-0 left-0 object-cover"
alt={object.title}
src={object.image}
src={imgSrc}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
onError={(e) => {
// Заглушка при ошибке загрузки изображения
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>

View File

@@ -43,13 +43,12 @@ interface DetectorsDataType {
interface ReportsListProps {
objectId?: string;
detectorsData: DetectorsDataType;
initialSearchTerm?: string;
}
const ReportsList: React.FC<ReportsListProps> = ({ detectorsData }) => {
const [searchTerm, setSearchTerm] = useState('');
const ReportsList: React.FC<ReportsListProps> = ({ detectorsData, initialSearchTerm = '' }) => {
const [searchTerm, setSearchTerm] = useState(initialSearchTerm);
const [statusFilter, setStatusFilter] = useState('all');
const [priorityFilter] = useState('all');
const [acknowledgedFilter] = useState('all');
const allNotifications = useMemo(() => {
const notifications: NotificationType[] = [];
@@ -70,20 +69,12 @@ const ReportsList: React.FC<ReportsListProps> = ({ detectorsData }) => {
}, [detectorsData]);
const filteredDetectors = useMemo(() => {
return allNotifications.filter(notification => {
const matchesSearch = notification.detector_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
notification.location.toLowerCase().includes(searchTerm.toLowerCase()) ||
notification.message.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || notification.type === statusFilter;
const matchesPriority = priorityFilter === 'all' || notification.priority === priorityFilter;
const matchesAcknowledged = acknowledgedFilter === 'all' ||
(acknowledgedFilter === 'acknowledged' && notification.acknowledged) ||
(acknowledgedFilter === 'unacknowledged' && !notification.acknowledged);
return matchesSearch && matchesStatus && matchesPriority && matchesAcknowledged;
return allNotifications.filter(notification => {
const matchesSearch = searchTerm === '' || notification.detector_id.toString() === searchTerm;
return matchesSearch;
});
}, [allNotifications, searchTerm, statusFilter, priorityFilter, acknowledgedFilter]);
}, [allNotifications, searchTerm]);
const getStatusColor = (type: string) => {
switch (type) {
@@ -168,7 +159,7 @@ const ReportsList: React.FC<ReportsListProps> = ({ detectorsData }) => {
<div className="relative">
<input
type="text"
placeholder="Поиск детекторов..."
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"