100 lines
6.5 KiB
TypeScript
100 lines
6.5 KiB
TypeScript
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';
|
||
}
|