New api and zone management; highligh occlusion and highlighAll functionality; improved search in reports and alerts history + autofill; refactored alert panel
This commit is contained in:
@@ -1,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>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user