Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/domain/alert.ts
T

100 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AlertEvent, AlertRule, AlertSeverity, AlertStatus } from '../../api/types';
import { formatZhNumber } from './formatters';
const alertTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: 'Asia/Shanghai'
});
const LEGACY_LOCAL_SQL_TIMESTAMP = /\.\d{6}Z$/;
export const severityLabels: Record<AlertSeverity, string> = { critical: '紧急', major: '重要', minor: '一般' };
export const statusLabels: Record<AlertStatus, string> = { unprocessed: '未处理', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
export const actionLabels: Record<string, string> = { repair: '修正告警信息', trigger: '触发', acknowledge: '已确认', close: '已关闭', ignore: '已忽略', recover: '已恢复', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
export const metricLabels: Record<string, string> = { speed_kmh: '速度', soc_percent: 'SOC', alarm_active: '协议告警位', freshness_sec: '离线时长', data_delay_sec: '数据延迟' };
export const operatorLabels: Record<string, string> = { gt: '>', gte: '≥', lt: '<', lte: '≤', eq: '=', neq: '≠', between: '区间内', outside: '区间外', changed: '状态变化' };
export const triggerTypeLabels: Record<string, string> = { metric: '数值触发', geofence: '电子围栏', stationary: '长时间静止', offline: '长时间离线' };
type AlertRuleCondition = Pick<AlertRule, 'triggerType' | 'fenceName' | 'fenceRadiusM' | 'metric' | 'operator' | 'threshold' | 'thresholdHigh' | 'valueType' | 'booleanThreshold' | 'durationSec'>;
export function formatAlertDuration(value: number) {
if (!value) return '立即';
if (value % 86_400 === 0) return `${value / 86_400} 天`;
if (value % 3_600 === 0) return `${value / 3_600} 小时`;
if (value % 60 === 0) return `${value / 60} 分钟`;
return `${value} 秒`;
}
function ruleThresholdValue(metric: string, value: number, unit = '') {
if (!Number.isFinite(value)) return '请设置阈值';
if (metric === 'freshness_sec' || metric === 'data_delay_sec') return formatAlertDuration(value);
return `${formatZhNumber(Number(value.toFixed(2)), 2)}${unit ? ` ${unit}` : ''}`;
}
export function formatAlertTime(value: string) {
if (!value) return '—';
// Older alert APIs appended Z to MySQL DATETIME values that were already in
// Asia/Shanghai. Their six-digit SQL fractional seconds make the legacy
// shape distinguishable from genuine UTC timestamps.
const normalized = LEGACY_LOCAL_SQL_TIMESTAMP.test(value) ? value.replace(/Z$/, '+08:00') : value;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return value.replace('T', ' ').slice(0, 19);
return alertTimeFormatter.format(date).replace(/\//g, '-');
}
export function alertValue(event: Pick<AlertEvent, 'triggerValue' | 'unit'>) {
return `${formatZhNumber(Number(event.triggerValue.toFixed(2)), 2)} ${event.unit}`.trim();
}
export function thresholdText(event: Pick<AlertEvent, 'triggerType' | 'operator' | 'threshold' | 'thresholdHigh' | 'unit' | 'durationSec'>) {
if (event.triggerType === 'geofence') return `${{ enter: '进入围栏', exit: '离开围栏', inside: '位于围栏内', outside: '位于围栏外' }[event.operator] ?? '电子围栏条件'}${event.durationSec ? `,持续 ${formatAlertDuration(event.durationSec)}` : ''}`;
const duration = event.durationSec > 0 ? `,持续 ${event.durationSec} 秒` : '';
if (event.operator === 'between' || event.operator === 'outside') return `${operatorLabels[event.operator]} ${event.threshold}${event.thresholdHigh} ${event.unit}${duration}`.trim();
if (event.operator === 'changed') return `状态发生变化${duration}`;
return `${operatorLabels[event.operator] ?? event.operator} ${formatZhNumber(Number(event.threshold.toFixed(2)), 2)} ${event.unit}${duration}`.trim();
}
export function alertDeltaText(event: Pick<AlertEvent, 'operator' | 'triggerValue' | 'threshold' | 'unit'>) {
if (!Number.isFinite(event.triggerValue) || !Number.isFinite(event.threshold)) return '规则已命中';
const delta = event.operator === 'gt' || event.operator === 'gte'
? event.triggerValue - event.threshold
: event.operator === 'lt' || event.operator === 'lte'
? event.threshold - event.triggerValue
: Number.NaN;
if (!Number.isFinite(delta)) return '规则已命中';
return `+${formatZhNumber(Number(Math.max(0, delta).toFixed(2)), 2)} ${event.unit}`.trim();
}
export function ruleCondition(
rule: AlertRuleCondition,
labels: Record<string, string> = metricLabels,
units: Record<string, string> = {},
includeDuration = true
) {
if (rule.triggerType === 'geofence') {
const mode = { enter: '进入', exit: '离开', inside: '位于围栏内', outside: '位于围栏外' }[rule.operator] ?? '满足围栏条件';
return `${mode}${rule.fenceName || '未命名围栏'}” · 半径 ${formatZhNumber(rule.fenceRadiusM ?? rule.threshold, 0)} m${includeDuration && rule.durationSec ? ` · 持续 ${formatAlertDuration(rule.durationSec)}` : ''}`;
}
if (rule.triggerType === 'offline') return `离线超过 ${formatAlertDuration(rule.threshold)}`;
if (rule.triggerType === 'stationary') return `速度 ${operatorLabels[rule.operator] ?? rule.operator} ${ruleThresholdValue(rule.metric, rule.threshold, units[rule.metric] ?? 'km/h')}${includeDuration && rule.durationSec ? ` · 持续 ${formatAlertDuration(rule.durationSec)}` : ''}`;
const unit = units[rule.metric] ?? '';
const threshold = rule.operator === 'changed'
? '状态变化'
: rule.operator === 'between' || rule.operator === 'outside'
? `${operatorLabels[rule.operator]} ${ruleThresholdValue(rule.metric, rule.threshold, unit)}${ruleThresholdValue(rule.metric, rule.thresholdHigh, unit)}`
: rule.valueType === 'boolean'
? (rule.booleanThreshold ? '是' : '否')
: `${operatorLabels[rule.operator] ?? rule.operator} ${ruleThresholdValue(rule.metric, rule.threshold, unit)}`;
return `${labels[rule.metric] ?? rule.metric} ${threshold}${includeDuration && rule.durationSec ? ` · 持续 ${formatAlertDuration(rule.durationSec)}` : ''}`;
}
export function canAct(status: AlertStatus, action: 'acknowledge' | 'close' | 'ignore') {
if (action === 'acknowledge') return status === 'unprocessed';
if (action === 'close') return status === 'unprocessed' || status === 'processing' || status === 'recovered';
return status === 'unprocessed' || status === 'processing';
}