This commit is contained in:
2026-02-02 11:00:40 +03:00
parent 87a1a628d3
commit 2d0f236fa4
22 changed files with 1119 additions and 461 deletions

View File

@@ -30,6 +30,9 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
})
}, [alerts, searchTerm])
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
const getStatusColor = (type: string) => {
switch (type) {
case 'critical':
@@ -64,29 +67,29 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
{/* Таблица алертов */}
<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>
<h2 style={interSemiboldStyle} className="text-xl text-white">История тревог</h2>
<span style={interRegularStyle} 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>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Статус</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Местоположение</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Приоритет</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Подтверждено</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm 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>
<td style={interRegularStyle} className="py-4 text-sm text-white">
<div>{item.detector_name || 'Детектор'}</div>
{item.detector_id ? (
<div className="text-sm text-gray-400">ID: {item.detector_id}</div>
<div className="text-gray-400">ID: {item.detector_id}</div>
) : null}
</td>
<td className="py-4">
@@ -95,16 +98,16 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
className="w-3 h-3 rounded-full"
style={{ backgroundColor: getStatusColor(item.type) }}
></div>
<span className="text-sm text-gray-300">
<span style={interRegularStyle} 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 style={interRegularStyle} className="py-4 text-sm text-white">
{item.message}
</td>
<td className="py-4">
<div className="text-sm text-white">{item.location || '-'}</div>
<td style={interRegularStyle} className="py-4 text-sm text-white">
{item.location || '-'}
</td>
<td className="py-4">
<span
@@ -122,20 +125,21 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
</span>
</td>
<td className="py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
<span style={interRegularStyle} className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs ${
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]"
style={interRegularStyle}
className="ml-2 inline-flex items-center px-2 py-1 rounded text-xs 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 style={interRegularStyle} className="py-4 text-sm text-gray-300">
{new Date(item.timestamp).toLocaleString('ru-RU')}
</td>
</tr>
))}

View File

@@ -90,7 +90,8 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
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"
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 text-sm font-medium"
style={{ fontFamily: 'Inter, sans-serif' }}
/>
<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" />

View File

@@ -15,44 +15,182 @@ interface AreaChartProps {
const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
const width = 635
const height = 200
const paddingBottom = 20
const baselineY = height - paddingBottom
const maxPlotHeight = height - 40
const height = 280
const margin = { top: 20, right: 30, bottom: 50, left: 60 }
const plotWidth = width - margin.left - margin.right
const plotHeight = height - margin.top - margin.bottom
const baselineY = margin.top + plotHeight
const safeData = (Array.isArray(data) && data.length > 0)
? data
: Array.from({ length: 7 }, () => ({ value: 0 }))
const maxVal = Math.max(...safeData.map(d => d.value || 0), 1)
const stepX = safeData.length > 1 ? width / (safeData.length - 1) : width
const stepX = safeData.length > 1 ? plotWidth / (safeData.length - 1) : plotWidth
const points = safeData.map((d, i) => {
const x = i * stepX
const y = baselineY - (Math.min(d.value || 0, maxVal) / maxVal) * maxPlotHeight
const x = margin.left + i * stepX
const y = baselineY - (Math.min(d.value || 0, maxVal) / maxVal) * plotHeight
return { x, y }
})
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
const areaPath = `${linePath} L${width},${baselineY} L0,${baselineY} Z`
const areaPath = `${linePath} L${width - margin.right},${baselineY} L${margin.left},${baselineY} Z`
// Генерируем Y-оси метки
const ySteps = 4
const yLabels = Array.from({ length: ySteps + 1 }, (_, i) => {
const value = (maxVal / ySteps) * (ySteps - i)
const y = margin.top + (i * plotHeight) / ySteps
return { value: value.toFixed(1), y }
})
// Генерируем X-оси метки (показываем каждую 2-ю или 3-ю точку)
const xLabelStep = Math.ceil(safeData.length / 5)
const xLabels = safeData
.map((d, i) => {
const x = margin.left + i * stepX
const label = d.label || d.timestamp || `${i + 1}`
return { label, x, index: i }
})
.filter((_, i) => i % xLabelStep === 0 || i === safeData.length - 1)
return (
<div className={`w-full h-full ${className}`}>
<svg className="w-full h-full" viewBox={`0 0 ${width} ${height}`}>
<defs>
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgb(42, 157, 144)" stopOpacity="0.3" />
<stop offset="100%" stopColor="rgb(42, 157, 144)" stopOpacity="0" />
<stop offset="0%" stopColor="rgb(37, 99, 235)" stopOpacity="0.3" />
<stop offset="100%" stopColor="rgb(37, 99, 235)" stopOpacity="0" />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#areaGradient)" />
<path d={linePath} stroke="rgb(42, 157, 144)" strokeWidth="2" fill="none" />
</defs>
{/* Сетка Y */}
{yLabels.map((label, i) => (
<line
key={`grid-y-${i}`}
x1={margin.left}
y1={label.y}
x2={width - margin.right}
y2={label.y}
stroke="rgba(148, 163, 184, 0.2)"
strokeWidth="1"
strokeDasharray="4,4"
/>
))}
{/* Ось X */}
<line
x1={margin.left}
y1={baselineY}
x2={width - margin.right}
y2={baselineY}
stroke="rgb(148, 163, 184)"
strokeWidth="2"
/>
{/* Ось Y */}
<line
x1={margin.left}
y1={margin.top}
x2={margin.left}
y2={baselineY}
stroke="rgb(148, 163, 184)"
strokeWidth="2"
/>
{/* Y-оси метки и подписи */}
{yLabels.map((label, i) => (
<g key={`y-label-${i}`}>
<line
x1={margin.left - 5}
y1={label.y}
x2={margin.left}
y2={label.y}
stroke="rgb(148, 163, 184)"
strokeWidth="1"
/>
<text
x={margin.left - 10}
y={label.y + 4}
textAnchor="end"
fontSize="12"
fill="rgb(148, 163, 184)"
fontFamily="Arial, sans-serif"
>
{label.value}
</text>
</g>
))}
{/* X-оси метки и подписи */}
{xLabels.map((label, i) => (
<g key={`x-label-${i}`}>
<line
x1={label.x}
y1={baselineY}
x2={label.x}
y2={baselineY + 5}
stroke="rgb(148, 163, 184)"
strokeWidth="1"
/>
<text
x={label.x}
y={baselineY + 20}
textAnchor="middle"
fontSize="11"
fill="rgb(148, 163, 184)"
fontFamily="Arial, sans-serif"
>
{typeof label.label === 'string' ? label.label.substring(0, 10) : `${label.index + 1}`}
</text>
</g>
))}
{/* Подпись оси Y */}
<text
x={20}
y={margin.top + plotHeight / 2}
textAnchor="middle"
fontSize="13"
fill="rgb(148, 163, 184)"
fontFamily="Arial, sans-serif"
transform={`rotate(-90, 20, ${margin.top + plotHeight / 2})`}
>
Значение
</text>
{/* Подпись оси X */}
<text
x={margin.left + plotWidth / 2}
y={height - 10}
textAnchor="middle"
fontSize="13"
fill="rgb(148, 163, 184)"
fontFamily="Arial, sans-serif"
>
Время
</text>
{/* График */}
<path d={areaPath} fill="url(#areaGradient)" />
<path d={linePath} stroke="rgb(37, 99, 235)" strokeWidth="2.5" fill="none" />
{/* Точки данных */}
{points.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r="3" fill="rgb(42, 157, 144)" />
<circle
key={i}
cx={p.x}
cy={p.y}
r="4"
fill="rgb(37, 99, 235)"
stroke="rgb(15, 23, 42)"
strokeWidth="2"
/>
))}
</svg>
</div>
)
}
export default AreaChart
export default AreaChart

View File

@@ -14,26 +14,159 @@ interface BarChartProps {
}
const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
const width = 635
const height = 280
const margin = { top: 20, right: 30, bottom: 50, left: 60 }
const plotWidth = width - margin.left - margin.right
const plotHeight = height - margin.top - margin.bottom
const baselineY = margin.top + plotHeight
const barData = (Array.isArray(data) && data.length > 0)
? data.map(d => ({ value: d.value, color: d.color || 'rgb(42, 157, 144)' }))
: Array.from({ length: 12 }, () => ({ value: 0, color: 'rgb(42, 157, 144)' }))
? data.map(d => ({ value: d.value, label: d.label || '', color: d.color || 'rgb(37, 99, 235)' }))
: Array.from({ length: 12 }, (_, i) => ({ value: 0, label: `${i + 1}`, color: 'rgb(37, 99, 235)' }))
const maxVal = Math.max(...barData.map(b => b.value || 0), 1)
// Генерируем Y-оси метки
const ySteps = 4
const yLabels = Array.from({ length: ySteps + 1 }, (_, i) => {
const value = (maxVal / ySteps) * (ySteps - i)
const y = margin.top + (i * plotHeight) / ySteps
return { value: value.toFixed(1), y }
})
// Генерируем X-оси метки (показываем каждую 2-ю или 3-ю)
const xLabelStep = Math.ceil(barData.length / 8)
const xLabels = barData
.map((d, i) => {
const barWidth = Math.max(30, plotWidth / barData.length - 8)
const barSpacing = (plotWidth - barWidth * barData.length) / (barData.length - 1 || 1)
const x = margin.left + i * (barWidth + barSpacing) + barWidth / 2
return { label: d.label || `${i + 1}`, x, index: i }
})
.filter((_, i) => i % xLabelStep === 0 || i === barData.length - 1)
return (
<div className={`w-full h-full ${className}`}>
<svg className="w-full h-full" viewBox="0 0 635 200">
<g>
{barData.map((bar, index) => {
const barWidth = 40
const barSpacing = 12
const x = index * (barWidth + barSpacing) + 20
const barHeight = (bar.value / maxVal) * 160
const y = 180 - barHeight
return (
<svg className="w-full h-full" viewBox={`0 0 ${width} ${height}`}>
{/* Сетка Y */}
{yLabels.map((label, i) => (
<line
key={`grid-y-${i}`}
x1={margin.left}
y1={label.y}
x2={width - margin.right}
y2={label.y}
stroke="rgba(148, 163, 184, 0.2)"
strokeWidth="1"
strokeDasharray="4,4"
/>
))}
{/* Ось X */}
<line
x1={margin.left}
y1={baselineY}
x2={width - margin.right}
y2={baselineY}
stroke="rgb(148, 163, 184)"
strokeWidth="2"
/>
{/* Ось Y */}
<line
x1={margin.left}
y1={margin.top}
x2={margin.left}
y2={baselineY}
stroke="rgb(148, 163, 184)"
strokeWidth="2"
/>
{/* Y-оси метки и подписи */}
{yLabels.map((label, i) => (
<g key={`y-label-${i}`}>
<line
x1={margin.left - 5}
y1={label.y}
x2={margin.left}
y2={label.y}
stroke="rgb(148, 163, 184)"
strokeWidth="1"
/>
<text
x={margin.left - 10}
y={label.y + 4}
textAnchor="end"
fontSize="12"
fill="rgb(148, 163, 184)"
fontFamily="Arial, sans-serif"
>
{label.value}
</text>
</g>
))}
{/* X-оси метки и подписи */}
{xLabels.map((label, i) => (
<g key={`x-label-${i}`}>
<line
x1={label.x}
y1={baselineY}
x2={label.x}
y2={baselineY + 5}
stroke="rgb(148, 163, 184)"
strokeWidth="1"
/>
<text
x={label.x}
y={baselineY + 20}
textAnchor="middle"
fontSize="11"
fill="rgb(148, 163, 184)"
fontFamily="Arial, sans-serif"
>
{typeof label.label === 'string' ? label.label.substring(0, 8) : `${label.index + 1}`}
</text>
</g>
))}
{/* Подпись оси Y */}
<text
x={20}
y={margin.top + plotHeight / 2}
textAnchor="middle"
fontSize="13"
fill="rgb(148, 163, 184)"
fontFamily="Arial, sans-serif"
transform={`rotate(-90, 20, ${margin.top + plotHeight / 2})`}
>
Значение
</text>
{/* Подпись оси X */}
<text
x={margin.left + plotWidth / 2}
y={height - 10}
textAnchor="middle"
fontSize="13"
fill="rgb(148, 163, 184)"
fontFamily="Arial, sans-serif"
>
Период
</text>
{/* Столбцы */}
{barData.map((bar, index) => {
const barWidth = Math.max(30, plotWidth / barData.length - 8)
const barSpacing = (plotWidth - barWidth * barData.length) / (barData.length - 1 || 1)
const x = margin.left + index * (barWidth + barSpacing)
const barHeight = (bar.value / maxVal) * plotHeight
const y = baselineY - barHeight
return (
<g key={`bar-${index}`}>
<rect
key={index}
x={x}
y={y}
width={barWidth}
@@ -41,13 +174,27 @@ const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
fill={bar.color}
rx="4"
ry="4"
opacity="0.9"
/>
)
})}
</g>
{/* Тень для глубины */}
<rect
x={x}
y={y}
width={barWidth}
height={barHeight}
fill="none"
stroke={bar.color}
strokeWidth="1"
rx="4"
ry="4"
opacity="0.3"
/>
</g>
)
})}
</svg>
</div>
)
}
export default BarChart
export default BarChart

View File

@@ -15,13 +15,16 @@ const ChartCard: React.FC<ChartCardProps> = ({
children,
className = ''
}) => {
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
return (
<div className={`bg-[#161824] rounded-[20px] p-6 ${className}`}>
<div className="flex items-start justify-between mb-6">
<div>
<h3 className="text-white text-base font-semibold mb-1">{title}</h3>
<h3 style={interSemiboldStyle} className="text-white text-sm mb-1">{title}</h3>
{subtitle && (
<p className="text-[#71717a] text-sm">{subtitle}</p>
<p style={interRegularStyle} className="text-[#71717a] text-xs">{subtitle}</p>
)}
</div>
<div className="w-4 h-4">
@@ -38,4 +41,4 @@ const ChartCard: React.FC<ChartCardProps> = ({
)
}
export default ChartCard
export default ChartCard

View File

@@ -1,12 +1,14 @@
'use client'
import React, { useEffect, useState } from 'react'
import React, { useEffect, useState, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import Sidebar from '../ui/Sidebar'
import AnimatedBackground from '../ui/AnimatedBackground'
import useNavigationStore from '../../app/store/navigationStore'
import ChartCard from './ChartCard'
import AreaChart from './AreaChart'
import BarChart from './BarChart'
import { aggregateChartDataByDays } from '../../lib/chartDataAggregator'
const Dashboard: React.FC = () => {
const router = useRouter()
@@ -14,7 +16,7 @@ const Dashboard: React.FC = () => {
const objectTitle = currentObject?.title
const [dashboardAlerts, setDashboardAlerts] = useState<any[]>([])
const [chartData, setChartData] = useState<{ timestamp: string; value: number }[]>([])
const [rawChartData, setRawChartData] = useState<{ timestamp: string; value: number }[]>([])
const [sensorTypes] = useState<Array<{code: string, name: string}>>([
{ code: '', name: 'Все датчики' },
{ code: 'GA', name: 'Инклинометр' },
@@ -52,7 +54,7 @@ const Dashboard: React.FC = () => {
setDashboardAlerts(tableData as any[])
const cd = Array.isArray(payload?.data?.chart_data) ? payload.data.chart_data : []
setChartData(cd as any[])
setRawChartData(cd as any[])
} catch (e) {
console.error('Failed to load dashboard:', e)
}
@@ -129,14 +131,25 @@ const Dashboard: React.FC = () => {
const handleTablePeriodChange = (period: string) => {
setSelectedTablePeriod(period)
}
// Агрегируем данные графика в зависимости от периода
const chartData = useMemo(() => {
return aggregateChartDataByDays(rawChartData, selectedChartPeriod)
}, [rawChartData, selectedChartPeriod])
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
return (
<div className="flex h-screen bg-[#0e111a]">
<Sidebar
activeItem={1} // Dashboard
/>
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
<AnimatedBackground />
<div className="relative z-20">
<Sidebar
activeItem={1} // Dashboard
/>
</div>
<div className="flex-1 flex flex-col">
<div className="relative z-10 flex-1 flex flex-col">
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
<div className="flex items-center gap-4">
<button
@@ -158,7 +171,7 @@ const Dashboard: React.FC = () => {
<div className="flex-1 p-6 overflow-auto">
<div className="mb-6">
<h1 className="text-white text-2xl font-semibold mb-6">{objectTitle || 'Объект'}</h1>
<h1 style={interSemiboldStyle} className="text-white text-2xl mb-6">{objectTitle || 'Объект'}</h1>
<div className="flex items-center gap-3 mb-6">
<div className="relative">
@@ -215,7 +228,7 @@ const Dashboard: React.FC = () => {
<ChartCard
title="Статистика"
>
<BarChart data={chartData?.map((d: any) => ({ value: d.value }))} />
<BarChart data={chartData?.map((d: any) => ({ value: d.value, label: d.label }))} />
</ChartCard>
</div>
</div>
@@ -224,7 +237,7 @@ const Dashboard: React.FC = () => {
<div>
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-white text-2xl font-semibold">Тренды</h2>
<h2 style={interSemiboldStyle} className="text-white text-2xl">Тренды</h2>
<div className="relative">
<select
value={selectedTablePeriod}
@@ -248,63 +261,64 @@ const Dashboard: React.FC = () => {
<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 style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Серьезность</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Дата</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Решен</th>
</tr>
</thead>
<tbody>
{filteredAlerts.map((alert: any) => (
<tr key={alert.id} className="border-b border-gray-800">
<td className="py-3 text-white text-sm">{alert.name}</td>
<td className="py-3 text-gray-300 text-sm">{alert.message}</td>
<td style={interRegularStyle} className="py-3 text-white text-sm">{alert.name}</td>
<td style={interRegularStyle} className="py-3 text-gray-300 text-sm">{alert.message}</td>
<td className="py-3">
<span className={`text-sm ${alert.severity === 'critical' ? 'text-red-500' : alert.severity === 'warning' ? 'text-orange-500' : 'text-green-500'}`}>
<span style={interRegularStyle} className={`text-sm ${alert.severity === 'critical' ? 'text-red-500' : alert.severity === 'warning' ? 'text-orange-500' : 'text-green-500'}`}>
{alert.severity === 'critical' ? 'Критическое' : alert.severity === 'warning' ? 'Предупреждение' : 'Норма'}
</span>
</td>
<td className="py-3 text-gray-400 text-sm">{new Date(alert.created_at).toLocaleString()}</td>
<td style={interRegularStyle} className="py-3 text-gray-400 text-sm">{new Date(alert.created_at).toLocaleString()}</td>
<td className="py-3">
{alert.resolved ? (
<span className="text-sm text-green-500">Да</span>
<span style={interRegularStyle} className="text-sm text-green-500">Да</span>
) : (
<span className="text-sm text-gray-500">Нет</span>
)}
<span style={interRegularStyle} className="text-sm text-gray-500">Нет</span>
)
}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Статы */}
<div className="mt-6 grid grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-white">{filteredAlerts.length}</div>
<div className="text-sm text-gray-400">Всего</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-500">{statusCounts.normal}</div>
<div className="text-sm text-gray-400">Норма</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-orange-500">{statusCounts.warning}</div>
<div className="text-sm text-gray-400">Предупреждения</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-red-500">{statusCounts.critical}</div>
<div className="text-sm text-gray-400">Критические</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Статистика */}
<div className="mt-6 grid grid-cols-4 gap-4">
<div className="text-center">
<div style={interSemiboldStyle} className="text-2xl text-white">{filteredAlerts.length}</div>
<div style={interRegularStyle} className="text-sm text-gray-400">Всего</div>
</div>
<div className="text-center">
<div style={interSemiboldStyle} className="text-2xl text-green-500">{statusCounts.normal}</div>
<div style={interRegularStyle} className="text-sm text-gray-400">Норма</div>
</div>
<div className="text-center">
<div style={interSemiboldStyle} className="text-2xl text-orange-500">{statusCounts.warning}</div>
<div style={interRegularStyle} className="text-sm text-gray-400">Предупреждения</div>
</div>
<div className="text-center">
<div style={interSemiboldStyle} className="text-2xl text-red-500">{statusCounts.critical}</div>
<div style={interRegularStyle} className="text-sm text-gray-400">Критические</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default Dashboard
export default Dashboard

View File

@@ -88,6 +88,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState<
{ sensorId: string; left: number; top: number; colorHex: string }[]
>([])
// NEW: State for tracking hovered sensor in overlay circles
const [hoveredSensorId, setHoveredSensorId] = useState<string | null>(null)
const handlePan = () => setPanActive(!panActive);
@@ -223,6 +225,82 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
);
}
};
// NEW: Function to handle overlay circle click
const handleOverlayCircleClick = (sensorId: string) => {
console.log('[ModelViewer] Overlay circle clicked:', sensorId)
// Find the mesh for this sensor
const allMeshes = importedMeshesRef.current || []
const sensorMeshes = collectSensorMeshes(allMeshes)
const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
if (!targetMesh) {
console.warn(`[ModelViewer] Mesh not found for sensor: ${sensorId}`)
return
}
const scene = sceneRef.current
const camera = scene?.activeCamera as ArcRotateCamera
if (!scene || !camera) return
// Calculate bounding box of the sensor mesh
const bbox = (typeof targetMesh.getHierarchyBoundingVectors === 'function')
? targetMesh.getHierarchyBoundingVectors()
: {
min: targetMesh.getBoundingInfo().boundingBox.minimumWorld,
max: targetMesh.getBoundingInfo().boundingBox.maximumWorld
}
const center = bbox.min.add(bbox.max).scale(0.5)
const size = bbox.max.subtract(bbox.min)
const maxDimension = Math.max(size.x, size.y, size.z)
// Calculate optimal camera distance
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
// Stop any current animations
scene.stopAnimation(camera)
// Setup easing
const ease = new CubicEase()
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
const frameRate = 60
const durationMs = 600 // 0.6 seconds for smooth animation
const totalFrames = Math.round((durationMs / 1000) * frameRate)
// Animate camera target position
Animation.CreateAndStartAnimation(
'camTarget',
camera,
'target',
frameRate,
totalFrames,
camera.target.clone(),
center.clone(),
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
// Animate camera radius (zoom)
Animation.CreateAndStartAnimation(
'camRadius',
camera,
'radius',
frameRate,
totalFrames,
camera.radius,
targetRadius,
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
// Call callback to display tooltip
onSensorPick?.(sensorId)
console.log('[ModelViewer] Camera animation started for sensor:', sensorId)
}
useEffect(() => {
isDisposedRef.current = false
@@ -343,160 +421,168 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
}, [onError])
useEffect(() => {
if (!isInitializedRef.current || isDisposedRef.current) {
return
}
if (!modelPath || modelPath.trim() === '') {
console.warn('[ModelViewer] No model path provided')
// Не вызываем onError для пустого пути - это нормальное состояние при инициализации
setIsLoading(false)
return
}
if (!modelPath || !sceneRef.current || !engineRef.current) return
const scene = sceneRef.current
setIsLoading(true)
setLoadingProgress(0)
setShowModel(false)
setModelReady(false)
const loadModel = async () => {
if (!sceneRef.current || isDisposedRef.current) {
return
}
const currentModelPath = modelPath;
console.log('[ModelViewer] Starting model load:', currentModelPath);
setIsLoading(true)
setLoadingProgress(0)
setShowModel(false)
setModelReady(false)
setPanActive(false)
const oldMeshes = sceneRef.current.meshes.slice();
const activeCameraId = sceneRef.current.activeCamera?.uniqueId;
console.log('[ModelViewer] Cleaning up old meshes. Total:', oldMeshes.length);
oldMeshes.forEach(m => {
if (m.uniqueId !== activeCameraId) {
m.dispose();
}
});
console.log('[ModelViewer] Loading GLTF model:', currentModelPath)
// UI элемент загрузчика (есть эффект замедленности)
const progressInterval = setInterval(() => {
setLoadingProgress(prev => {
if (prev >= 90) {
clearInterval(progressInterval)
return 90
}
return prev + Math.random() * 15
})
}, 100)
try {
console.log('[ModelViewer] Calling ImportMeshAsync with path:', currentModelPath);
console.log('[ModelViewer] Starting to load model:', modelPath)
// Проверим доступность файла через fetch
try {
const testResponse = await fetch(currentModelPath, { method: 'HEAD' });
console.log('[ModelViewer] File availability check:', {
url: currentModelPath,
status: testResponse.status,
statusText: testResponse.statusText,
ok: testResponse.ok
});
} catch (fetchError) {
console.error('[ModelViewer] File fetch error:', fetchError);
// UI элемент загрузчика (есть эффект замедленности)
const progressInterval = setInterval(() => {
setLoadingProgress(prev => {
if (prev >= 90) {
clearInterval(progressInterval)
return 90
}
return prev + Math.random() * 15
})
}, 100)
// Use the correct ImportMeshAsync signature: (url, scene, onProgress)
const result = await ImportMeshAsync(modelPath, scene, (evt) => {
if (evt.lengthComputable) {
const progress = (evt.loaded / evt.total) * 100
setLoadingProgress(progress)
console.log('[ModelViewer] Loading progress:', progress)
}
})
clearInterval(progressInterval)
if (isDisposedRef.current) {
console.log('[ModelViewer] Component disposed during load')
return
}
const result = await ImportMeshAsync(currentModelPath, sceneRef.current)
console.log('[ModelViewer] ImportMeshAsync completed successfully');
console.log('[ModelViewer] Import result:', {
console.log('[ModelViewer] Model loaded successfully:', {
meshesCount: result.meshes.length,
particleSystemsCount: result.particleSystems.length,
skeletonsCount: result.skeletons.length,
animationGroupsCount: result.animationGroups.length
});
if (isDisposedRef.current || modelPath !== currentModelPath) {
console.log('[ModelViewer] Model loading aborted - model changed during load')
clearInterval(progressInterval)
setIsLoading(false)
return;
}
})
importedMeshesRef.current = result.meshes
clearInterval(progressInterval)
setLoadingProgress(100)
console.log('[ModelViewer] GLTF Model loaded successfully!', result)
if (result.meshes.length > 0) {
const boundingBox = result.meshes[0].getHierarchyBoundingVectors()
const size = boundingBox.max.subtract(boundingBox.min)
const maxDimension = Math.max(size.x, size.y, size.z)
const camera = sceneRef.current!.activeCamera as ArcRotateCamera
camera.radius = maxDimension * 2
camera.target = result.meshes[0].position
importedMeshesRef.current = result.meshes
setModelReady(true)
onModelLoaded?.({
meshes: result.meshes,
boundingBox: {
min: boundingBox.min,
max: boundingBox.max
}
min: { x: boundingBox.min.x, y: boundingBox.min.y, z: boundingBox.min.z },
max: { x: boundingBox.max.x, y: boundingBox.max.y, z: boundingBox.max.z },
},
})
}
// Плавное появление модели
setTimeout(() => {
if (!isDisposedRef.current && modelPath === currentModelPath) {
setShowModel(true)
setIsLoading(false)
} else {
console.log('Model display aborted - model changed during animation')
}
}, 500)
} else {
console.warn('No meshes found in model')
onError?.('В модели не найдена геометрия')
setIsLoading(false)
}
} catch (error) {
clearInterval(progressInterval)
if (!isDisposedRef.current && modelPath === currentModelPath) {
console.error('Error loading GLTF model:', error)
const errorMessage = error instanceof Error ? error.message : String(error)
onError?.(`Ошибка загрузки модели: ${errorMessage}`)
} else {
console.log('Error occurred but loading was aborted - model changed')
}
setLoadingProgress(100)
setShowModel(true)
setModelReady(true)
setIsLoading(false)
} catch (error) {
if (isDisposedRef.current) return
const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка'
console.error('[ModelViewer] Error loading model:', errorMessage)
const message = `Ошибка при загрузке модели: ${errorMessage}`
onError?.(message)
setIsLoading(false)
setModelReady(false)
}
}
// Загрузка модлеи начинается после появления спиннера
requestIdleCallback(() => loadModel(), { timeout: 50 })
}, [modelPath, onError, onModelLoaded])
loadModel()
}, [modelPath, onModelLoaded, onError])
useEffect(() => {
if (!sceneRef.current || isDisposedRef.current || !modelReady) return
useEffect(() => {
if (!highlightAllSensors || focusSensorId || !modelReady) {
setAllSensorsOverlayCircles([])
return
}
if (highlightAllSensors) {
const allMeshes = importedMeshesRef.current || []
const sensorMeshes = collectSensorMeshes(allMeshes)
applyHighlightToMeshes(
highlightLayerRef.current,
highlightedMeshesRef,
sensorMeshes,
mesh => {
const sid = getSensorIdFromMesh(mesh)
const status = sid ? sensorStatusMap?.[sid] : undefined
return statusToColor3(status ?? null)
},
)
const scene = sceneRef.current
const engine = engineRef.current
if (!scene || !engine) {
setAllSensorsOverlayCircles([])
return
}
const allMeshes = importedMeshesRef.current || []
const sensorMeshes = collectSensorMeshes(allMeshes)
if (sensorMeshes.length === 0) {
setAllSensorsOverlayCircles([])
return
}
const engineTyped = engine as Engine
const updateCircles = () => {
const circles = computeSensorOverlayCircles({
scene,
engine: engineTyped,
meshes: sensorMeshes,
sensorStatusMap,
})
setAllSensorsOverlayCircles(circles)
}
updateCircles()
const observer = scene.onBeforeRenderObservable.add(updateCircles)
return () => {
scene.onBeforeRenderObservable.remove(observer)
setAllSensorsOverlayCircles([])
}
}, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap])
useEffect(() => {
if (!highlightAllSensors || focusSensorId || !modelReady) {
return
}
const scene = sceneRef.current
if (!scene) return
const allMeshes = importedMeshesRef.current || []
if (allMeshes.length === 0) {
return
}
const sensorMeshes = collectSensorMeshes(allMeshes)
console.log('[ModelViewer] Total meshes in model:', allMeshes.length)
console.log('[ModelViewer] Sensor meshes found:', sensorMeshes.length)
// Log first 5 sensor IDs found in meshes
const sensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean).slice(0, 5)
console.log('[ModelViewer] Sample sensor IDs from meshes:', sensorIds)
if (sensorMeshes.length === 0) {
console.warn('[ModelViewer] No sensor meshes found in 3D model!')
return
}
applyHighlightToMeshes(
highlightLayerRef.current,
highlightedMeshesRef,
sensorMeshes,
mesh => {
const sid = getSensorIdFromMesh(mesh)
const status = sid ? sensorStatusMap?.[sid] : undefined
return statusToColor3(status ?? null)
},
)
}, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap])
useEffect(() => {
if (!focusSensorId || !modelReady) {
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
highlightedMeshesRef.current = []
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
@@ -528,12 +614,14 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
}
const sensorMeshes = collectSensorMeshes(allMeshes)
const allSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m))
const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
console.log('[ModelViewer] Sensor focus', {
requested: sensorId,
totalImportedMeshes: allMeshes.length,
totalSensorMeshes: sensorMeshes.length,
allSensorIds: allSensorIds,
chosen: chosen ? { id: chosen.id, name: chosen.name, uniqueId: chosen.uniqueId, parent: chosen.parent?.name } : null,
source: 'result.meshes',
})
@@ -593,7 +681,6 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusSensorId, modelReady, highlightAllSensors])
// Включение выбора на основе взаимодействия с моделью только при готовности модели и включении выбора сенсоров
useEffect(() => {
const scene = sceneRef.current
if (!scene || !modelReady || !isSensorSelectionEnabled) return
@@ -621,7 +708,6 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
}
}, [modelReady, isSensorSelectionEnabled, onSensorPick])
// Расчет позиции оверлея
const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => {
if (!sceneRef.current || !mesh) return null
const scene = sceneRef.current
@@ -644,49 +730,12 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
}
}, [])
// Позиция оверлея изначально
useEffect(() => {
if (!chosenMeshRef.current || !overlayData) return
const pos = computeOverlayPosition(chosenMeshRef.current)
setOverlayPos(pos)
}, [overlayData, computeOverlayPosition])
useEffect(() => {
const scene = sceneRef.current
const engine = engineRef.current
if (!scene || !engine || !modelReady) {
setAllSensorsOverlayCircles([])
return
}
if (!highlightAllSensors || focusSensorId || !sensorStatusMap) {
setAllSensorsOverlayCircles([])
return
}
const allMeshes = importedMeshesRef.current || []
const sensorMeshes = collectSensorMeshes(allMeshes)
if (sensorMeshes.length === 0) {
setAllSensorsOverlayCircles([])
return
}
const engineTyped = engine as Engine
const updateCircles = () => {
const circles = computeSensorOverlayCircles({
scene,
engine: engineTyped,
meshes: sensorMeshes,
sensorStatusMap,
})
setAllSensorsOverlayCircles(circles)
}
updateCircles()
const observer = scene.onBeforeRenderObservable.add(updateCircles)
return () => {
scene.onBeforeRenderObservable.remove(observer)
setAllSensorsOverlayCircles([])
}
}, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap])
// Позиция оверлея при движущейся камере
useEffect(() => {
if (!sceneRef.current || !chosenMeshRef.current || !overlayData) return
const scene = sceneRef.current
@@ -767,13 +816,19 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
/>
</>
)}
{/* UPDATED: Interactive overlay circles with hover effects */}
{allSensorsOverlayCircles.map(circle => {
const size = 36
const radius = size / 2
const fill = hexWithAlpha(circle.colorHex, 0.2)
const isHovered = hoveredSensorId === circle.sensorId
return (
<div
key={`${circle.sensorId}-${Math.round(circle.left)}-${Math.round(circle.top)}`}
onClick={() => handleOverlayCircleClick(circle.sensorId)}
onMouseEnter={() => setHoveredSensorId(circle.sensorId)}
onMouseLeave={() => setHoveredSensorId(null)}
style={{
position: 'absolute',
left: circle.left - radius,
@@ -783,8 +838,16 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
borderRadius: '9999px',
border: `2px solid ${circle.colorHex}`,
backgroundColor: fill,
pointerEvents: 'none',
pointerEvents: 'auto',
cursor: 'pointer',
transition: 'all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)',
transform: isHovered ? 'scale(1.4)' : 'scale(1)',
boxShadow: isHovered
? `0 0 25px ${circle.colorHex}, inset 0 0 10px ${circle.colorHex}`
: `0 0 8px ${circle.colorHex}`,
zIndex: isHovered ? 50 : 10,
}}
title={`Датчик: ${circle.sensorId}`}
/>
)
})}

View File

@@ -181,13 +181,13 @@ const AlertMenu: React.FC<AlertMenuProps> = ({ alert, isOpen, onClose, getStatus
<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">
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] 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">
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] 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>

View File

@@ -230,13 +230,13 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
</button>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<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">
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] 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 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">
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] 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>
@@ -261,13 +261,13 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
</h3>
{/* Кнопки действий: Отчет и История */}
<div className="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">
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] 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 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">
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] 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>

View File

@@ -42,18 +42,35 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
onSelectModel?.(modelPath);
}, [onSelectModel]);
// Загрузка зон при изменении объекта
useEffect(() => {
const objId = currentObject?.id;
if (!objId) return;
loadZones(objId);
}, [currentObject?.id, loadZones]);
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 || '');
});
// Автоматический выбор первой зоны при загрузке
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">

View File

@@ -1,6 +1,8 @@
'use client'
import React from 'react'
import React from 'react'
import { useRouter } from 'next/navigation'
import useNavigationStore from '@/app/store/navigationStore'
import * as statusColors from '../../lib/statusColors'
interface DetectorInfoType {
@@ -29,6 +31,8 @@ interface NotificationDetectorInfoProps {
}
const NotificationDetectorInfo: React.FC<NotificationDetectorInfoProps> = ({ detectorData, onClose }) => {
const router = useRouter()
const { setSelectedDetector, currentObject } = useNavigationStore()
const detectorInfo = detectorData
if (!detectorInfo) {
@@ -76,6 +80,72 @@ const NotificationDetectorInfo: React.FC<NotificationDetectorInfoProps> = ({ det
default: return priority
}
}
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 detectorDataToSet = {
detector_id: detectorInfo.detector_id,
name: detectorInfo.name,
serial_number: '',
object: detectorInfo.object,
status: detectorInfo.status,
checked: detectorInfo.checked,
type: detectorInfo.type,
detector_type: detectorInfo.detector_type,
location: detectorInfo.location,
floor: detectorInfo.floor,
notifications: detectorInfo.notifications || []
}
setSelectedDetector(detectorDataToSet)
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 detectorDataToSet = {
detector_id: detectorInfo.detector_id,
name: detectorInfo.name,
serial_number: '',
object: detectorInfo.object,
status: detectorInfo.status,
checked: detectorInfo.checked,
type: detectorInfo.type,
detector_type: detectorInfo.detector_type,
location: detectorInfo.location,
floor: detectorInfo.floor,
notifications: detectorInfo.notifications || []
}
setSelectedDetector(detectorDataToSet)
let historyUrl = '/history'
const params = new URLSearchParams()
if (objectId) params.set('objectId', objectId)
if (objectTitle) params.set('objectTitle', objectTitle)
if (params.toString()) {
historyUrl += `?${params.toString()}`
}
router.push(historyUrl)
}
const latestNotification = detectorInfo.notifications && detectorInfo.notifications.length > 0
? detectorInfo.notifications.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0]
@@ -100,13 +170,13 @@ const NotificationDetectorInfo: React.FC<NotificationDetectorInfoProps> = ({ det
{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">
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] 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-[#3193f5] hover:bg-[#2563eb] 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>

View File

@@ -90,6 +90,9 @@ const ReportsList: React.FC<ReportsListProps> = ({ detectorsData, initialSearchT
}
};
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high':
@@ -186,21 +189,21 @@ const ReportsList: React.FC<ReportsListProps> = ({ detectorsData, initialSearchT
<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>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Статус</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Местоположение</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Приоритет</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Подтверждено</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Время</th>
</tr>
</thead>
<tbody>
{filteredDetectors.map((detector) => (
<tr key={detector.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">{detector.detector_name}</div>
<div className="text-sm text-gray-400">ID: {detector.detector_id}</div>
<td style={interRegularStyle} className="py-4 text-sm text-white">
<div>{detector.detector_name}</div>
<div className="text-gray-400">ID: {detector.detector_id}</div>
</td>
<td className="py-4">
<div className="flex items-center gap-2">
@@ -208,17 +211,17 @@ const ReportsList: React.FC<ReportsListProps> = ({ detectorsData, initialSearchT
className="w-3 h-3 rounded-full"
style={{ backgroundColor: getStatusColor(detector.type) }}
></div>
<span className="text-sm text-gray-300">
<span style={interRegularStyle} className="text-sm text-gray-300">
{detector.type === 'critical' ? 'Критический' :
detector.type === 'warning' ? 'Предупреждение' : 'Информация'}
</span>
</div>
</td>
<td className="py-4">
<div className="text-sm text-white">{detector.message}</div>
<td style={interRegularStyle} className="py-4 text-sm text-white">
{detector.message}
</td>
<td className="py-4">
<div className="text-sm text-white">{detector.location}</div>
<td style={interRegularStyle} className="py-4 text-sm text-white">
{detector.location}
</td>
<td className="py-4">
<span
@@ -230,7 +233,7 @@ const ReportsList: React.FC<ReportsListProps> = ({ detectorsData, initialSearchT
</span>
</td>
<td className="py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
<span style={interRegularStyle} className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs ${
detector.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'
@@ -238,10 +241,8 @@ const ReportsList: React.FC<ReportsListProps> = ({ detectorsData, initialSearchT
{detector.acknowledged ? 'Да' : 'Нет'}
</span>
</td>
<td className="py-4">
<div className="text-sm text-gray-300">
{new Date(detector.timestamp).toLocaleString('ru-RU')}
</div>
<td style={interRegularStyle} className="py-4 text-sm text-gray-300">
{new Date(detector.timestamp).toLocaleString('ru-RU')}
</td>
</tr>
))}

View File

@@ -12,21 +12,36 @@ const Button = ({
size = 'lg',
}: ButtonProps) => {
const sizeClasses = {
sm: 'h-10 text-sm',
md: 'h-12 text-base',
lg: 'h-14 text-xl',
sm: 'h-10 text-sm px-4',
md: 'h-12 text-base px-6',
lg: 'h-14 text-lg px-8',
}
return (
<button
onClick={onClick}
className={`cursor-pointer rounded-xl transition-all duration-500 hover:shadow-2xl ${sizeClasses[size]} ${className}`}
className={`
cursor-pointer
rounded-2xl
transition-all
duration-300
font-medium
shadow-lg
hover:shadow-2xl
hover:scale-105
active:scale-95
backdrop-blur-sm
${sizeClasses[size]}
${className}
`}
type={type}
>
{leftIcon && <span className="mr-2 flex items-center">{leftIcon}</span>}
{midIcon && <span className="flex items-center">{midIcon}</span>}
<span className="text-center font-normal">{text}</span>
{rightIcon && <span className="ml-2 flex items-center">{rightIcon}</span>}
<div className="flex items-center justify-center gap-2">
{leftIcon && <span className="flex items-center">{leftIcon}</span>}
{midIcon && <span className="flex items-center">{midIcon}</span>}
<span className="text-center">{text}</span>
{rightIcon && <span className="flex items-center">{rightIcon}</span>}
</div>
</button>
)
}

View File

@@ -11,8 +11,8 @@ interface LoadingSpinnerProps {
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
progress = 0,
size = 120,
strokeWidth = 8,
size = 140,
strokeWidth = 6,
className = ''
}) => {
const radius = (size - strokeWidth) / 2
@@ -22,47 +22,63 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
return (
<div className={`flex flex-col items-center justify-center ${className}`}>
<div className="relative" style={{ width: size, height: size }}>
<div className="relative" style={{ width: size, height: size }}>
{/* Фоновый круг с градиентом */}
<svg
className="transform -rotate-90"
className="transform -rotate-90 drop-shadow-lg"
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
>
<defs>
<linearGradient id="progressGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#2563eb" />
<stop offset="100%" stopColor="#0891b2" />
</linearGradient>
</defs>
{/* Фоновый круг */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="rgba(255, 255, 255, 0.1)"
stroke="rgba(255, 255, 255, 0.08)"
strokeWidth={strokeWidth}
fill="transparent"
/>
{/* Прогресс круг с градиентом */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="#389ee8"
stroke="url(#progressGradient)"
strokeWidth={strokeWidth}
fill="transparent"
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
className="transition-all duration-300 ease-out"
className="transition-all duration-500 ease-out filter drop-shadow-md"
/>
</svg>
{/* Процент в центре */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-white text-xl font-semibold">
{Math.round(progress)}%
</span>
<div className="text-center">
<span className="text-white text-3xl font-bold bg-gradient-to-r from-blue-400 to-cyan-300 bg-clip-text text-transparent">
{Math.round(progress)}%
</span>
</div>
</div>
</div>
<div className="mt-4 text-white text-base font-medium">
Loading Model...
{/* Текст загрузки */}
<div className="mt-6 text-center">
<p className="text-white text-lg font-semibold">Загрузка модели</p>
<p className="text-gray-400 text-sm mt-2">Пожалуйста, подождите</p>
</div>
</div>
)
}
export default LoadingSpinner
export default LoadingSpinner

View File

@@ -185,19 +185,18 @@ const Sidebar: React.FC<SidebarProps> = ({
openMonitoring,
openFloorNavigation,
openNotifications,
openListOfDetectors,
openSensors,
openListOfDetectors,
closeSensors,
closeListOfDetectors,
closeMonitoring,
closeFloorNavigation,
closeNotifications,
closeListOfDetectors,
closeSensors,
closeAllMenus,
showMonitoring,
showFloorNavigation,
showNotifications,
showListOfDetectors,
showSensors
showSensors,
showListOfDetectors
} = useNavigationStore()
useEffect(() => {
@@ -229,14 +228,10 @@ const Sidebar: React.FC<SidebarProps> = ({
case 3: // Monitoring
if (pathname !== '/navigation') {
router.push('/navigation')
setTimeout(() => {
closeAllMenus()
openMonitoring()
}, 100)
setTimeout(() => openMonitoring(), 100)
} else if (showMonitoring) {
closeMonitoring()
} else {
closeAllMenus()
openMonitoring()
}
handled = true
@@ -244,14 +239,10 @@ const Sidebar: React.FC<SidebarProps> = ({
case 4: // Floor Navigation
if (pathname !== '/navigation') {
router.push('/navigation')
setTimeout(() => {
closeAllMenus()
openFloorNavigation()
}, 100)
setTimeout(() => openFloorNavigation(), 100)
} else if (showFloorNavigation) {
closeFloorNavigation()
} else {
closeAllMenus()
openFloorNavigation()
}
handled = true
@@ -259,14 +250,10 @@ const Sidebar: React.FC<SidebarProps> = ({
case 5: // Notifications
if (pathname !== '/navigation') {
router.push('/navigation')
setTimeout(() => {
closeAllMenus()
openNotifications()
}, 100)
setTimeout(() => openNotifications(), 100)
} else if (showNotifications) {
closeNotifications()
} else {
closeAllMenus()
openNotifications()
}
handled = true
@@ -274,29 +261,21 @@ const Sidebar: React.FC<SidebarProps> = ({
case 6: // Sensors
if (pathname !== '/navigation') {
router.push('/navigation')
setTimeout(() => {
closeAllMenus()
openSensors()
}, 100)
setTimeout(() => openSensors(), 100)
} else if (showSensors) {
closeSensors()
} else {
closeAllMenus()
openSensors()
}
handled = true
break
case 7: // List of Detectors
case 7: // Detector List
if (pathname !== '/navigation') {
router.push('/navigation')
setTimeout(() => {
closeAllMenus()
openListOfDetectors()
}, 100)
setTimeout(() => openListOfDetectors(), 100)
} else if (showListOfDetectors) {
closeListOfDetectors()
} else {
closeAllMenus()
openListOfDetectors()
}
handled = true
@@ -352,8 +331,8 @@ const Sidebar: React.FC<SidebarProps> = ({
return (
<li key={item.id} className="flex-col flex items-center relative self-stretch w-full" role="listitem">
<button
className={`gap-2 pt-2 pr-2 pb-2 pl-2 rounded-md flex h-9 items-center relative self-stretch w-full transition-colors duration-200 hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset ${
isActive ? 'bg-gray-700' : ''
className={`gap-2 pt-2 pr-2 pb-2 pl-2 rounded-md flex h-9 items-center relative self-stretch w-full transition-all duration-200 hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset ${
isActive ? 'bg-gradient-to-r from-blue-600 to-cyan-500 shadow-lg shadow-blue-500/30' : ''
}`}
onClick={() => handleItemClick(item.id)}
aria-current={isActive ? 'page' : undefined}
@@ -379,8 +358,6 @@ const Sidebar: React.FC<SidebarProps> = ({
closeMonitoring()
closeFloorNavigation()
closeNotifications()
closeListOfDetectors()
closeSensors()
}
toggleNavigationSubMenu()
}}
@@ -395,8 +372,6 @@ const Sidebar: React.FC<SidebarProps> = ({
closeMonitoring()
closeFloorNavigation()
closeNotifications()
closeListOfDetectors()
closeSensors()
}
toggleNavigationSubMenu()
}
@@ -426,8 +401,8 @@ const Sidebar: React.FC<SidebarProps> = ({
return (
<li key={subItem.id} className="flex-col flex h-8 items-center relative self-stretch w-full" role="listitem">
<button
className={`gap-2 pt-1.5 pr-2 pb-1.5 pl-2 rounded-md flex h-8 items-center relative self-stretch w-full transition-colors duration-200 hover:bg-gray-600 focus:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset ${
isSubActive ? 'bg-gray-600' : ''
className={`gap-2 pt-1.5 pr-2 pb-1.5 pl-2 rounded-md flex h-8 items-center relative self-stretch w-full transition-all duration-200 hover:bg-gray-600 focus:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset ${
isSubActive ? 'bg-gradient-to-r from-blue-600 to-cyan-500 shadow-lg shadow-blue-500/30' : ''
}`}
onClick={() => handleItemClick(subItem.id)}
aria-current={isSubActive ? 'page' : undefined}
@@ -461,8 +436,6 @@ const Sidebar: React.FC<SidebarProps> = ({
closeMonitoring()
closeFloorNavigation()
closeNotifications()
closeListOfDetectors()
closeSensors()
}
// Убираем сайд-бар
toggleSidebar()
@@ -511,7 +484,7 @@ const Sidebar: React.FC<SidebarProps> = ({
</div>
<button
className="!relative !w-8 !h-8 p-1.5 rounded-lg bg-gray-800/60 border border-gray-600/40 shadow-lg hover:shadow-xl hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-200"
className="relative w-4 h-4 aspect-[1] p-1 rounded hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200"
aria-label="Logout"
title="Выйти"
type="button"