add translation for tags

This commit is contained in:
Первов Артем
2026-06-17 11:22:24 +03:00
parent 2c4aef1185
commit 6b6e0abe40
6 changed files with 118 additions and 15 deletions

View File

@@ -67,6 +67,21 @@ export type HistoryResponse = {
valueMode?: 'raw' | 'avg' | 'last' | 'first' | 'min' | 'max';
};
export type TagTranslationItem = {
edge: string;
tag: string;
locale: string;
displayName: string;
source: string | null;
updatedAt: string;
};
export type TagTranslationResponse = {
edge: string;
locale: string;
items: TagTranslationItem[];
};
/** Выполняет типизированный GET-запрос к cloud-v2 и централизованно обрабатывает ошибки HTTP. */
async function request<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
const url = new URL(path, API_URL);
@@ -133,3 +148,16 @@ export function getHistory(params: {
valueMode: params.valueMode,
});
}
/** Загружает русскоязычный справочник названий тегов для выбранного edge. */
export function getTagTranslations(params: {
edge: string;
locale?: string;
tags?: string[];
}): Promise<TagTranslationResponse> {
return request<TagTranslationResponse>('/tag-translations', {
edge: params.edge,
locale: params.locale ?? 'ru',
tags: params.tags?.join(','),
});
}

View File

@@ -28,6 +28,7 @@ type HistoryChartProps = {
loading: boolean;
from?: string;
to?: string;
tagLabels?: Record<string, string>;
};
/** Подбирает подпись оси времени под текущий масштаб графика. */
@@ -46,7 +47,7 @@ function formatAxisDate(value: number, spanMs?: number): string {
}
/** Рисует исторические ряды показателей через ECharts с динамической временной шкалой. */
export function HistoryChart({ data, loading, from, to }: HistoryChartProps) {
export function HistoryChart({ data, loading, from, to, tagLabels = {} }: 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;
@@ -115,7 +116,7 @@ export function HistoryChart({ data, loading, from, to }: HistoryChartProps) {
],
series:
data?.series.map((series, index) => ({
name: series.tag,
name: tagLabels[series.tag] ?? series.tag,
type: 'line',
showSymbol: false,
smooth: false,

View File

@@ -5,28 +5,33 @@ import { getMetricStatus, type MetricStatusInfo } from '../utils/metricStatus';
type MetricCardProps = {
item: CurrentItem;
displayName?: string;
selected: boolean;
statusInfo?: MetricStatusInfo;
onToggle: (tag: string) => void;
};
/** Показывает детальную карточку текущего показателя и управляет добавлением тега на график. */
export function MetricCard({ item, selected, statusInfo = getMetricStatus(item), onToggle }: MetricCardProps) {
export function MetricCard({ item, displayName, selected, statusInfo = getMetricStatus(item), onToggle }: MetricCardProps) {
const { ageSeconds, label, status } = statusInfo;
const ageLabel = ageSeconds < 60 ? `${ageSeconds}с` : `${Math.floor(ageSeconds / 60)}м ${ageSeconds % 60}с`;
const title = displayName ?? item.tag;
return (
<button
className={`metric-card metric-card--${status} ${selected ? 'metric-card--selected' : ''}`}
type="button"
onClick={() => onToggle(item.tag)}
title={selected ? 'Убрать с графика' : 'Добавить на график'}
title={`${selected ? 'Убрать с графика' : 'Добавить на график'}: ${title}`}
>
<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__tag">
<span>{title}</span>
{displayName && displayName !== item.tag ? <small>{item.tag}</small> : null}
</span>
<span className="metric-card__corner">
<span className={`metric-card__state is-${status}`}>{label}</span>
<span className="metric-card__age" title="Возраст последнего измерения">

View File

@@ -16,7 +16,15 @@ import {
Wrench,
} from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { CurrentEvent, CurrentItem, CurrentResponse, getCurrent, getCurrentEventsUrl, getHistory } from '../api/cloud';
import {
CurrentEvent,
CurrentItem,
CurrentResponse,
getCurrent,
getCurrentEventsUrl,
getHistory,
getTagTranslations,
} from '../api/cloud';
import { useAuth } from '../auth/authContext';
import { HistoryChart } from '../components/HistoryChart';
import { MetricCard } from '../components/MetricCard';
@@ -73,6 +81,13 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) {
refetchInterval: currentEventsConnected ? false : 1_000,
});
const tagTranslations = useQuery({
queryKey: ['tag-translations', edgeId, 'ru'],
queryFn: () => getTagTranslations({ edge: edgeId, locale: 'ru' }),
enabled: Boolean(edgeId),
staleTime: 10 * 60 * 1000,
});
useEffect(() => {
if (!edgeId || typeof EventSource === 'undefined') {
setCurrentEventsConnected(false);
@@ -103,11 +118,20 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) {
};
}, [edgeId, queryClient]);
const tagLabels = useMemo(() => {
return Object.fromEntries((tagTranslations.data?.items ?? []).map((item) => [item.tag, item.displayName]));
}, [tagTranslations.data?.items]);
const getTagLabel = (tag: string) => tagLabels[tag] ?? tag;
const visibleItems = useMemo(() => {
const query = search.trim().toLowerCase();
const items = current.data?.items ?? [];
return query ? items.filter((item) => item.tag.toLowerCase().includes(query)) : items;
}, [current.data?.items, search]);
return query
? items.filter((item) => `${item.tag} ${tagLabels[item.tag] ?? ''}`.toLowerCase().includes(query))
: items;
}, [current.data?.items, search, tagLabels]);
const latestUpdatedAt = useMemo(() => {
const latest = (current.data?.items ?? []).reduce<number | null>((max, item) => {
@@ -272,6 +296,8 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) {
onSelectVisibleTags={selectVisibleTags}
onClearVisibleTags={clearVisibleTags}
onClearTags={() => setSelectedTags([])}
getTagLabel={getTagLabel}
tagLabels={tagLabels}
/>
) : null}
@@ -282,6 +308,7 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) {
isError={current.isError}
error={current.error}
selectedTags={selectedTags}
getTagLabel={getTagLabel}
onSearchChange={setSearch}
onToggleTag={toggleTag}
/>
@@ -381,6 +408,8 @@ function ArchiveView({
onSelectVisibleTags,
onClearVisibleTags,
onClearTags,
getTagLabel,
tagLabels,
}: {
items: Array<{ tag: string; value: number; time: string }>;
search: string;
@@ -399,6 +428,8 @@ function ArchiveView({
onSelectVisibleTags: () => void;
onClearVisibleTags: () => void;
onClearTags: () => void;
getTagLabel: (tag: string) => string;
tagLabels: Record<string, string>;
}) {
const [selectorOpen, setSelectorOpen] = useState(false);
const selectedPreview = selectedTags.slice(0, 10);
@@ -459,7 +490,7 @@ function ArchiveView({
</div>
<div className="selected-tags">
{selectedPreview.length ? selectedPreview.map((tag) => <span key={tag}>{tag}</span>) : <span>Не выбрано</span>}
{selectedPreview.length ? selectedPreview.map((tag) => <span key={tag}>{getTagLabel(tag)}</span>) : <span>Не выбрано</span>}
{hiddenSelectedCount > 0 ? <span>+{hiddenSelectedCount}</span> : null}
</div>
@@ -471,9 +502,13 @@ function ArchiveView({
key={item.tag}
type="button"
className={`tag-select-item ${selected ? 'tag-select-item--selected' : ''}`}
title={item.tag}
onClick={() => onToggleTag(item.tag)}
>
<span>{item.tag}</span>
<span className="tag-select-item__name">
<span>{getTagLabel(item.tag)}</span>
{getTagLabel(item.tag) !== item.tag ? <small>{item.tag}</small> : null}
</span>
<strong>{item.value.toLocaleString('ru-RU', { maximumFractionDigits: 3 })}</strong>
</button>
);
@@ -525,6 +560,7 @@ function ArchiveView({
loading={historyLoading}
from={toIsoFromInput(range.from)}
to={toIsoFromInput(range.to)}
tagLabels={tagLabels}
/>
) : (
<div className="chart-placeholder">Разверните список показателей и выберите серии для графика</div>
@@ -566,6 +602,7 @@ function IndicatorsView({
isError,
error,
selectedTags,
getTagLabel,
onSearchChange,
onToggleTag,
}: {
@@ -574,6 +611,7 @@ function IndicatorsView({
isError: boolean;
error: unknown;
selectedTags: string[];
getTagLabel: (tag: string) => string;
onSearchChange: (value: string) => void;
onToggleTag: (tag: string) => void;
}) {
@@ -656,11 +694,11 @@ function IndicatorsView({
key={`${item.edge}:${item.tag}`}
type="button"
className={`metric-tile metric-tile--${statusInfo.status} ${selectedTags.includes(item.tag) ? 'metric-tile--selected' : ''}`}
title={`${item.tag}: ${formatNumber(item.value)} · ${statusInfo.label}`}
title={`${getTagLabel(item.tag)} (${item.tag}): ${formatNumber(item.value)} · ${statusInfo.label}`}
onClick={() => onToggleTag(item.tag)}
>
<span className="metric-tile__status" />
<span className="metric-tile__tag">{item.tag}</span>
<span className="metric-tile__tag">{getTagLabel(item.tag)}</span>
<strong>{formatNumber(item.value)}</strong>
</button>
))}
@@ -671,6 +709,7 @@ function IndicatorsView({
<MetricCard
key={`${item.edge}:${item.tag}`}
item={item}
displayName={getTagLabel(item.tag)}
statusInfo={statusInfo}
selected={selectedTags.includes(item.tag)}
onToggle={onToggleTag}

View File

@@ -424,15 +424,29 @@
.metric-card__tag {
min-width: 0;
overflow: hidden;
color: #e5e7eb;
font-size: 0.86rem;
font-weight: 750;
letter-spacing: 0.025em;
}
.metric-card__tag span,
.metric-card__tag small {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-card__tag small {
margin-top: 3px;
color: var(--muted);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0;
}
.metric-card__state {
flex: 0 0 auto;
padding: 0 8px;

View File

@@ -343,13 +343,29 @@
text-align: left;
}
.tag-select-item span {
.tag-select-item__name {
min-width: 0;
}
.tag-select-item__name span,
.tag-select-item__name small {
display: block;
min-width: 0;
overflow: hidden;
font-size: 0.78rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-select-item__name span {
font-size: 0.78rem;
}
.tag-select-item__name small {
margin-top: 3px;
color: var(--muted);
font-size: 0.68rem;
}
.tag-select-item strong {
color: #e5e7eb;
font-size: 0.76rem;