Files
aerbim-ht-monitor/frontend/components/navigation/Monitoring.tsx

201 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useCallback, useState } from 'react';
import Image from 'next/image';
import useNavigationStore from '@/app/store/navigationStore';
import type { Zone } from '@/app/types';
// Безопасный резолвер src изображения, чтобы избежать ошибок Invalid URL в next/image
const resolveImageSrc = (src?: string | null): string => {
if (!src || typeof src !== 'string') return '/images/test_image.png';
let s = src.trim();
if (!s) return '/images/test_image.png';
s = s.replace(/\\/g, '/');
const lower = s.toLowerCase();
// Явный плейсхолдер test_image.png маппим на наш статический ресурс
if (lower === 'test_image.png' || lower.endsWith('/test_image.png') || lower.includes('/public/images/test_image.png')) {
return '/images/test_image.png';
}
// Если путь содержит public/images (даже абсолютный путь ФС), переводим в относительный путь сайта
if (/\/public\/images\//i.test(s)) {
const parts = s.split(/\/public\/images\//i);
const rel = parts[1] || '';
return `/images/${rel}`;
}
// Абсолютные URL и пути, относительные к сайту
if (s.startsWith('http://') || s.startsWith('https://')) return s;
if (s.startsWith('/')) return s;
// Нормализуем относительные имена ресурсов до путей сайта под /images
// Убираем ведущий 'public/', если он присутствует
s = s.replace(/^public\//i, '');
return s.startsWith('images/') ? `/${s}` : `/images/${s}`;
}
interface MonitoringProps {
onClose?: () => void;
onSelectModel?: (modelPath: string) => void;
}
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
const { currentObject, currentZones, zonesLoading, zonesError, loadZones, currentModelPath } = useNavigationStore();
const [autoSelectedRef, setAutoSelectedRef] = React.useState(false);
const handleSelectModel = useCallback((modelPath: string) => {
console.log(`[Monitoring] Model selected: ${modelPath}`);
console.log(`[Monitoring] onSelectModel callback:`, onSelectModel);
onSelectModel?.(modelPath);
// Автоматически закрываем панель после выбора модели
if (onClose) {
setTimeout(() => {
console.log('[Monitoring] Auto-closing after model selection');
onClose();
}, 100);
}
}, [onSelectModel, onClose]);
// Загрузка зон при изменении объекта
useEffect(() => {
const objId = currentObject?.id;
if (!objId) return;
console.log(`[Monitoring] Loading zones for object ID: ${objId}`);
loadZones(objId);
}, [currentObject?.id, loadZones]);
// Автоматический выбор модели, если currentModelPath установлен (переход из таблицы)
useEffect(() => {
if (!currentModelPath || autoSelectedRef || !onSelectModel) return;
console.log('[Monitoring] Auto-selecting model from currentModelPath:', currentModelPath);
setAutoSelectedRef(true);
onSelectModel(currentModelPath);
}, [currentModelPath, autoSelectedRef, onSelectModel]);
// Сброс флага при изменении объекта
useEffect(() => {
setAutoSelectedRef(false);
}, [currentObject?.id])
const sortedZones: Zone[] = React.useMemo(() => {
const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
const oa = typeof a.order === 'number' ? a.order : 0;
const ob = typeof b.order === 'number' ? b.order : 0;
if (oa !== ob) return oa - ob;
return (a.name || '').localeCompare(b.name || '');
});
console.log(`[Monitoring] Sorted zones:`, sorted.map(z => ({ id: z.id, name: z.name, model_path: z.model_path })));
return sorted;
}, [currentZones]);
return (
<div className="w-full">
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-white text-2xl font-semibold">Зоны мониторинга</h2>
{onClose && (
<button
onClick={onClose}
className="text-white hover:text-gray-300 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* UI зон */}
{zonesError && (
<div className="rounded-lg bg-red-600/20 border border-red-600/40 text-red-200 text-xs px-3 py-2">
Ошибка загрузки зон: {zonesError}
</div>
)}
{zonesLoading && (
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
Загрузка зон...
</div>
)}
{sortedZones.length > 0 && (
<>
{sortedZones[0] && (
<button
key={`zone-${sortedZones[0].id}-panorama`}
type="button"
onClick={() => sortedZones[0].model_path ? handleSelectModel(sortedZones[0].model_path) : null}
className="group w-full bg-gradient-to-br from-cyan-600/20 via-blue-600/20 to-purple-600/20 rounded-xl h-[220px] flex items-center justify-center hover:from-cyan-500/30 hover:via-blue-500/30 hover:to-purple-500/30 transition-all duration-300 mb-4 border border-cyan-400/30 hover:border-cyan-400/60 shadow-lg shadow-cyan-500/10 hover:shadow-cyan-500/30 overflow-hidden relative backdrop-blur-sm"
title={sortedZones[0].model_path ? `Открыть 3D модель зоны: ${sortedZones[0].name}` : 'Модель зоны отсутствует'}
disabled={!sortedZones[0].model_path}
>
{/* Градиентный фон при наведении */}
<div className="absolute inset-0 bg-gradient-to-br from-cyan-400/10 via-blue-400/10 to-purple-400/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
{/* Анимированный градиент по краям */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-cyan-400/5 to-transparent animate-pulse"></div>
<div className="w-full h-full rounded-lg flex flex-col items-center justify-center relative">
<Image
src={resolveImageSrc(sortedZones[0].image_path)}
alt={sortedZones[0].name || 'Зона'}
width={200}
height={200}
className="max-w-full max-h-full object-contain opacity-60 group-hover:opacity-80 transition-opacity duration-300"
style={{ height: 'auto' }}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = '/images/test_image.png';
}}
/>
<div className="absolute bottom-3 left-3 right-3 text-sm font-medium text-white bg-gradient-to-r from-cyan-600/80 via-blue-600/80 to-purple-600/80 backdrop-blur-md rounded-lg px-4 py-2 truncate border border-cyan-400/40 shadow-lg shadow-cyan-500/20">
{sortedZones[0].name}
</div>
</div>
</button>
)}
{sortedZones.length > 1 && (
<div className="grid grid-cols-2 gap-3">
{sortedZones.slice(1).map((zone: Zone, idx: number) => (
<button
key={`zone-${zone.id}-${idx}`}
type="button"
onClick={() => zone.model_path ? handleSelectModel(zone.model_path) : null}
className="group relative flex-1 bg-gradient-to-br from-emerald-600/20 via-teal-600/20 to-cyan-600/20 rounded-xl h-[140px] flex items-center justify-center hover:from-emerald-500/30 hover:via-teal-500/30 hover:to-cyan-500/30 transition-all duration-300 border border-emerald-400/30 hover:border-emerald-400/60 shadow-lg shadow-emerald-500/10 hover:shadow-emerald-500/30 overflow-hidden backdrop-blur-sm"
title={zone.model_path ? `Открыть 3D модель зоны: ${zone.name}` : 'Модель зоны отсутствует'}
disabled={!zone.model_path}
>
{/* Градиентный фон при наведении */}
<div className="absolute inset-0 bg-gradient-to-br from-emerald-400/10 via-teal-400/10 to-cyan-400/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
{/* Анимированный градиент по краям */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-emerald-400/5 to-transparent animate-pulse"></div>
<div className="w-full h-full rounded-lg flex flex-col items-center justify-center relative">
<Image
src={resolveImageSrc(zone.image_path)}
alt={zone.name || 'Зона'}
width={120}
height={120}
className="max-w-full max-h-full object-contain opacity-60 group-hover:opacity-80 transition-opacity duration-300"
style={{ height: 'auto' }}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = '/images/test_image.png';
}}
/>
<div className="absolute bottom-2 left-2 right-2 text-[11px] font-medium text-white bg-gradient-to-r from-emerald-600/80 via-teal-600/80 to-cyan-600/80 backdrop-blur-md rounded-md px-2 py-1 truncate border border-emerald-400/40 shadow-md shadow-emerald-500/20">
{zone.name}
</div>
</div>
</button>
))}
</div>
)}
</>
)}
{sortedZones.length === 0 && !zonesError && !zonesLoading && (
<div className="col-span-2">
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
Зоны не найдены для выбранного объекта. Проверьте параметр objectId в API /api/get-zones.
</div>
</div>
)}
</div>
</div>
);
};
export default Monitoring;