ui creation

This commit is contained in:
Первов Артем
2026-06-17 09:46:52 +03:00
commit 2c4aef1185
41 changed files with 6251 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
import { LineChart } from 'echarts/charts';
import {
DataZoomComponent,
GridComponent,
LegendComponent,
TooltipComponent,
} from 'echarts/components';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import ReactEChartsCore from 'echarts-for-react/esm/core.js';
import type { HistoryResponse } from '../api/cloud';
echarts.use([LineChart, GridComponent, LegendComponent, TooltipComponent, DataZoomComponent, CanvasRenderer]);
const SERIES_COLORS = [
'#5B8FF9',
'#5AD8A6',
'#F6BD16',
'#E8684A',
'#6DC8EC',
'#FF9D4D',
'#73D13D',
'#A7B3FF',
];
type HistoryChartProps = {
data?: HistoryResponse;
loading: boolean;
from?: string;
to?: string;
};
/** Подбирает подпись оси времени под текущий масштаб графика. */
function formatAxisDate(value: number, spanMs?: number): string {
const date = new Date(value);
if (!spanMs || spanMs <= 24 * 60 * 60 * 1000) {
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(date);
}
if (spanMs <= 7 * 24 * 60 * 60 * 1000) {
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit' }).format(date);
}
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' }).format(date);
}
/** Рисует исторические ряды показателей через ECharts с динамической временной шкалой. */
export function HistoryChart({ data, loading, from, to }: HistoryChartProps) {
const xMin = from ? new Date(from).getTime() : undefined;
const xMax = to ? new Date(to).getTime() : undefined;
const xSpan = Number.isFinite(xMin) && Number.isFinite(xMax) ? Number(xMax) - Number(xMin) : undefined;
const option = {
color: SERIES_COLORS,
animation: false,
backgroundColor: 'transparent',
textStyle: {
color: '#cbd5e1',
fontFamily: 'Inter, Segoe UI, sans-serif',
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(10, 13, 18, 0.96)',
borderColor: 'rgba(212, 165, 116, 0.32)',
textStyle: { color: '#f8fafc' },
valueFormatter: (value: unknown) => (typeof value === 'number' ? value.toFixed(3) : String(value)),
},
legend: {
type: 'scroll',
top: 0,
textStyle: { color: '#cbd5e1' },
},
grid: {
top: 54,
left: 48,
right: 24,
bottom: 72,
},
xAxis: {
type: 'time',
min: Number.isFinite(xMin) ? xMin : undefined,
max: Number.isFinite(xMax) ? xMax : undefined,
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.35)' } },
axisLabel: {
color: '#94a3b8',
formatter: (value: number) => formatAxisDate(value, xSpan),
},
splitLine: { show: false },
},
yAxis: {
type: 'value',
scale: true,
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.35)' } },
axisLabel: { color: '#94a3b8' },
splitLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.1)' } },
},
dataZoom: [
{
type: 'inside',
throttle: 80,
},
{
type: 'slider',
bottom: 18,
height: 28,
borderColor: 'rgba(148, 163, 184, 0.24)',
fillerColor: 'rgba(91, 143, 249, 0.18)',
handleStyle: {
color: '#d4a574',
borderColor: '#e8c9a0',
},
textStyle: { color: '#94a3b8' },
},
],
series:
data?.series.map((series, index) => ({
name: series.tag,
type: 'line',
showSymbol: false,
smooth: false,
sampling: 'lttb',
lineStyle: {
width: 1.8,
color: SERIES_COLORS[index % SERIES_COLORS.length],
},
emphasis: {
focus: 'series',
},
data: series.points.map((point) => [point.t, point.v]),
})) ?? [],
};
if (loading) {
return <div className="chart-placeholder">Загрузка графика...</div>;
}
if (!data?.series.length) {
return <div className="chart-placeholder">Нет данных для выбранного диапазона</div>;
}
return <ReactEChartsCore echarts={echarts} option={option} className="history-chart" notMerge lazyUpdate />;
}

View File

@@ -0,0 +1,49 @@
import { Activity, Check, Clock3, Plus } from 'lucide-react';
import type { CurrentItem } from '../api/cloud';
import { formatNumber } from '../utils/format';
import { getMetricStatus, type MetricStatusInfo } from '../utils/metricStatus';
type MetricCardProps = {
item: CurrentItem;
selected: boolean;
statusInfo?: MetricStatusInfo;
onToggle: (tag: string) => void;
};
/** Показывает детальную карточку текущего показателя и управляет добавлением тега на график. */
export function MetricCard({ item, selected, statusInfo = getMetricStatus(item), onToggle }: MetricCardProps) {
const { ageSeconds, label, status } = statusInfo;
const ageLabel = ageSeconds < 60 ? `${ageSeconds}с` : `${Math.floor(ageSeconds / 60)}м ${ageSeconds % 60}с`;
return (
<button
className={`metric-card metric-card--${status} ${selected ? 'metric-card--selected' : ''}`}
type="button"
onClick={() => onToggle(item.tag)}
title={selected ? 'Убрать с графика' : 'Добавить на график'}
>
<div className="metric-card__header">
<span className="metric-card__signal">
<Activity size={15} />
</span>
<span className="metric-card__tag">{item.tag}</span>
<span className="metric-card__corner">
<span className={`metric-card__state is-${status}`}>{label}</span>
<span className="metric-card__age" title="Возраст последнего измерения">
<Clock3 size={13} />
{ageLabel}
</span>
</span>
</div>
<div className="metric-card__value">{formatNumber(item.value)}</div>
<div className="metric-card__footer">
<span className="metric-card__action">
{selected ? <Check size={14} /> : <Plus size={14} />}
{selected ? 'на графике' : 'на график'}
</span>
</div>
</button>
);
}

View File

@@ -0,0 +1,9 @@
type StatusPillProps = {
state: 'ok' | 'warn' | 'error' | 'muted';
label: string;
};
/** Отображает компактный статусный бейдж с цветовой семантикой. */
export function StatusPill({ state, label }: StatusPillProps) {
return <span className={`status-pill status-pill--${state}`}>{label}</span>;
}

View File

@@ -0,0 +1,40 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { buildToirLightUrl, getToirLightPostMessageTarget } from '../integrations/toir';
type ToirLightIframeProps = {
path: string;
title: string;
className?: string;
onLoad?: () => void;
};
/** Встраивает TOиР light в iframe и синхронизирует с ним темную тему приложения. */
export function ToirLightIframe({ path, title, className, onLoad }: ToirLightIframeProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const src = useMemo(() => buildToirLightUrl(path, 'dark'), [path]);
const targetOrigin = useMemo(() => getToirLightPostMessageTarget(), []);
/** Отправляет тему в iframe после загрузки и при пересоздании URL. */
const sendTheme = useCallback(() => {
iframeRef.current?.contentWindow?.postMessage({ type: 'greact-theme', theme: 'dark' }, targetOrigin);
}, [targetOrigin]);
useEffect(() => {
sendTheme();
}, [sendTheme, src]);
return (
<iframe
ref={iframeRef}
className={className}
src={src}
title={title}
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
onLoad={() => {
sendTheme();
onLoad?.();
}}
/>
);
}