1. Изменен внешний вид карточек зон мониторинга и поведение при выборе модели - загружаются нужные модели при выборе карточки

2. Добавлено автоматическое масштабирование и позиционирование камеры - модель сразу открывается и показывается вся на экране
This commit is contained in:
2026-02-03 12:43:04 +03:00
parent 135921eb14
commit baa3d1baa4
6 changed files with 630 additions and 35 deletions

View File

@@ -2,6 +2,7 @@
import React, { useEffect, useCallback, useState } from 'react' import React, { useEffect, useCallback, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import Image from 'next/image'
import Sidebar from '../../../components/ui/Sidebar' import Sidebar from '../../../components/ui/Sidebar'
import AnimatedBackground from '../../../components/ui/AnimatedBackground' import AnimatedBackground from '../../../components/ui/AnimatedBackground'
import useNavigationStore from '../../store/navigationStore' import useNavigationStore from '../../store/navigationStore'
@@ -546,15 +547,16 @@ const NavigationPage: React.FC = () => {
</> </>
) : !selectedModelPath ? ( ) : !selectedModelPath ? (
<div className="h-full flex items-center justify-center bg-[#0e111a]"> <div className="h-full flex items-center justify-center bg-[#0e111a]">
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md"> <div className="text-center p-8 flex flex-col items-center">
<div className="text-gray-400 text-lg font-semibold mb-4"> <Image
3D модель не загружена src="/icons/logo.png"
</div> alt="AerBIM HT Monitor"
<div className="text-gray-300 mb-4"> width={300}
Модель не готова к отображению height={41}
</div> className="mb-6"
<div className="text-sm text-gray-400"> />
Выберите модель из навигации по этажам <div className="text-gray-300 text-lg">
Выберите модель для отображения
</div> </div>
</div> </div>
</div> </div>

View File

@@ -480,6 +480,55 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
max: { x: boundingBox.max.x, y: boundingBox.max.y, z: boundingBox.max.z }, max: { x: boundingBox.max.x, y: boundingBox.max.y, z: boundingBox.max.z },
}, },
}) })
// Автоматическое кадрирование камеры для отображения всей модели
const camera = scene.activeCamera as ArcRotateCamera
if (camera) {
const center = boundingBox.min.add(boundingBox.max).scale(0.5)
const size = boundingBox.max.subtract(boundingBox.min)
const maxDimension = Math.max(size.x, size.y, size.z)
// Устанавливаем оптимальное расстояние камеры
const targetRadius = maxDimension * 2.5 // Множитель для комфортного отступа
// Плавная анимация камеры к центру модели
scene.stopAnimation(camera)
const ease = new CubicEase()
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
const frameRate = 60
const durationMs = 800 // 0.8 секунды
const totalFrames = Math.round((durationMs / 1000) * frameRate)
// Анимация позиции камеры
Animation.CreateAndStartAnimation(
'frameCameraTarget',
camera,
'target',
frameRate,
totalFrames,
camera.target.clone(),
center.clone(),
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
// Анимация зума
Animation.CreateAndStartAnimation(
'frameCameraRadius',
camera,
'radius',
frameRate,
totalFrames,
camera.radius,
targetRadius,
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
console.log('[ModelViewer] Camera framed to model:', { center, targetRadius, maxDimension })
}
} }
setLoadingProgress(100) setLoadingProgress(100)

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useCallback } from 'react'; import React, { useEffect, useCallback, 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'; import type { Zone } from '@/app/types';
@@ -38,7 +38,8 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
const { currentObject, currentZones, zonesLoading, zonesError, loadZones } = useNavigationStore(); const { currentObject, currentZones, zonesLoading, zonesError, loadZones } = useNavigationStore();
const handleSelectModel = useCallback((modelPath: string) => { const handleSelectModel = useCallback((modelPath: string) => {
console.log(`[NavigationPage] Model selected: ${modelPath}`); console.log(`[Monitoring] Model selected: ${modelPath}`);
console.log(`[Monitoring] onSelectModel callback:`, onSelectModel);
onSelectModel?.(modelPath); onSelectModel?.(modelPath);
}, [onSelectModel]); }, [onSelectModel]);
@@ -46,30 +47,21 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
useEffect(() => { useEffect(() => {
const objId = currentObject?.id; const objId = currentObject?.id;
if (!objId) return; if (!objId) return;
console.log(`[Monitoring] Loading zones for object ID: ${objId}`);
loadZones(objId); loadZones(objId);
}, [currentObject?.id, loadZones]); }, [currentObject?.id, loadZones]);
// Автоматический выбор первой зоны при загрузке // Автоматический выбор первой зоны ОТКЛЮЧЕН - пользователь должен выбрать модель вручную
useEffect(() => {
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 || '');
});
if (sortedZones.length > 0 && sortedZones[0].model_path && !zonesLoading) {
handleSelectModel(sortedZones[0].model_path);
}
}, [currentZones, zonesLoading, handleSelectModel]);
const sortedZones: Zone[] = React.useMemo(() => { const sortedZones: Zone[] = React.useMemo(() => {
return (currentZones || []).slice().sort((a: Zone, b: Zone) => { const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
const oa = typeof a.order === 'number' ? a.order : 0; const oa = typeof a.order === 'number' ? a.order : 0;
const ob = typeof b.order === 'number' ? b.order : 0; const ob = typeof b.order === 'number' ? b.order : 0;
if (oa !== ob) return oa - ob; if (oa !== ob) return oa - ob;
return (a.name || '').localeCompare(b.name || ''); 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]); }, [currentZones]);
return ( return (
@@ -106,25 +98,29 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
key={`zone-${sortedZones[0].id}-panorama`} key={`zone-${sortedZones[0].id}-panorama`}
type="button" type="button"
onClick={() => sortedZones[0].model_path ? handleSelectModel(sortedZones[0].model_path) : null} 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="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}` : 'Модель зоны отсутствует'} title={sortedZones[0].model_path ? `Открыть 3D модель зоны: ${sortedZones[0].name}` : 'Модель зоны отсутствует'}
disabled={!sortedZones[0].model_path} 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="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 <Image
src={resolveImageSrc(sortedZones[0].image_path)} src={resolveImageSrc(sortedZones[0].image_path)}
alt={sortedZones[0].name || 'Зона'} 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-60 group-hover:opacity-80 transition-opacity duration-300"
style={{ height: 'auto' }} style={{ height: 'auto' }}
onError={(e) => { onError={(e) => {
const target = e.target as HTMLImageElement; const target = e.target as HTMLImageElement;
target.src = '/images/test_image.png'; 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-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} {sortedZones[0].name}
</div> </div>
</div> </div>
@@ -137,24 +133,29 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
key={`zone-${zone.id}-${idx}`} key={`zone-${zone.id}-${idx}`}
type="button" type="button"
onClick={() => zone.model_path ? handleSelectModel(zone.model_path) : null} 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="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}` : 'Модель зоны отсутствует'} title={zone.model_path ? `Открыть 3D модель зоны: ${zone.name}` : 'Модель зоны отсутствует'}
disabled={!zone.model_path} disabled={!zone.model_path}
> >
<div className="w-full h-full bg-gray-200 rounded flex flex-col items-center justify-center relative"> {/* Градиентный фон при наведении */}
<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 <Image
src={resolveImageSrc(zone.image_path)} src={resolveImageSrc(zone.image_path)}
alt={zone.name || 'Зона'} 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-60 group-hover:opacity-80 transition-opacity duration-300"
style={{ height: 'auto' }} style={{ height: 'auto' }}
onError={(e) => { onError={(e) => {
const target = e.target as HTMLImageElement; const target = e.target as HTMLImageElement;
target.src = '/images/test_image.png'; 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-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} {zone.name}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,179 @@
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;
onSelectModel?: (modelPath: string) => void;
}
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
const { currentObject, currentZones, zonesLoading, zonesError, loadZones } = useNavigationStore();
const handleSelectModel = useCallback((modelPath: string) => {
console.log(`[NavigationPage] Model selected: ${modelPath}`);
onSelectModel?.(modelPath);
}, [onSelectModel]);
// Загрузка зон при изменении объекта
useEffect(() => {
const objId = currentObject?.id;
if (!objId) return;
loadZones(objId);
}, [currentObject?.id, loadZones]);
// Автоматический выбор первой зоны при загрузке
useEffect(() => {
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 || '');
});
if (sortedZones.length > 0 && sortedZones[0].model_path && !zonesLoading) {
handleSelectModel(sortedZones[0].model_path);
}
}, [currentZones, zonesLoading, handleSelectModel]);
const sortedZones: Zone[] = React.useMemo(() => {
return (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 || '');
});
}, [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="w-full bg-gray-300 rounded-lg h-[200px] flex items-center justify-center hover:bg-gray-400 transition-colors mb-4"
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={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.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">
{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="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={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.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">
{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;

View File

@@ -0,0 +1,193 @@
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 } = useNavigationStore();
const [userSelectedModel, setUserSelectedModel] = useState(false);
const handleSelectModel = useCallback((modelPath: string, isUserClick = false) => {
console.log(`[Monitoring] Model selected: ${modelPath}, isUserClick: ${isUserClick}`);
console.log(`[Monitoring] onSelectModel callback:`, onSelectModel);
if (isUserClick) {
setUserSelectedModel(true);
}
onSelectModel?.(modelPath);
}, [onSelectModel]);
// Загрузка зон при изменении объекта
useEffect(() => {
const objId = currentObject?.id;
if (!objId) return;
console.log(`[Monitoring] Loading zones for object ID: ${objId}`);
loadZones(objId);
}, [currentObject?.id, loadZones]);
// Автоматический выбор первой зоны при загрузке (только если пользователь не выбрал вручную)
useEffect(() => {
if (userSelectedModel) {
console.log('[Monitoring] User already selected model, skipping auto-selection');
return;
}
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 || '');
});
if (sortedZones.length > 0 && sortedZones[0].model_path && !zonesLoading) {
console.log('[Monitoring] Auto-selecting first zone model');
handleSelectModel(sortedZones[0].model_path, false);
}
}, [currentZones, zonesLoading, handleSelectModel, userSelectedModel]);
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, true) : 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={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={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.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">
{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, true) : 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={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.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">
{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;

View File

@@ -0,0 +1,171 @@
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 } = useNavigationStore();
const handleSelectModel = useCallback((modelPath: string) => {
console.log(`[Monitoring] Model selected: ${modelPath}`);
console.log(`[Monitoring] onSelectModel callback:`, onSelectModel);
onSelectModel?.(modelPath);
}, [onSelectModel]);
// Загрузка зон при изменении объекта
useEffect(() => {
const objId = currentObject?.id;
if (!objId) return;
console.log(`[Monitoring] Loading zones for object ID: ${objId}`);
loadZones(objId);
}, [currentObject?.id, loadZones]);
// Автоматический выбор первой зоны ОТКЛЮЧЕН - пользователь должен выбрать модель вручную
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="w-full bg-gray-300 rounded-lg h-[200px] flex items-center justify-center hover:bg-gray-400 transition-colors mb-4"
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={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.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">
{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="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={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.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">
{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;