|
|
@@ -4,7 +4,8 @@ import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
|
|
|
import { useTableComponents } from '@/components/ZmTable/useTableComponents'
|
|
|
import { useAlarmSound } from '@/utils/useAlarmSound'
|
|
|
import { useMqtt } from '@/utils/useMqtt'
|
|
|
-import { getWindowThresholdAlarm } from '@/utils/thresholdAlarmWindow'
|
|
|
+import { getWindowChangeAlarm } from '@/utils/thresholdAlarmWindow'
|
|
|
+import { createThresholdAlarmNoticeTracker } from '@/utils/thresholdAlarmNoticeTracker'
|
|
|
import {
|
|
|
Dimensions,
|
|
|
formatChartValue,
|
|
|
@@ -88,39 +89,56 @@ const message = useMessage()
|
|
|
const { setAlarmActive } = useAlarmSound()
|
|
|
|
|
|
interface ThresholdAlarm {
|
|
|
+ alarmAt: number
|
|
|
+ alarmKey: string
|
|
|
+ fingerprint: string
|
|
|
key: string
|
|
|
name: string
|
|
|
- value: number
|
|
|
- threshold: number
|
|
|
- baseThreshold: number
|
|
|
+ change: number
|
|
|
+ direction: 'increase' | 'decrease'
|
|
|
+ fromValue: number
|
|
|
+ minValue: number
|
|
|
+ maxValue: number
|
|
|
+ toValue: number
|
|
|
duration: number
|
|
|
range: number
|
|
|
- direction: 'high' | 'low'
|
|
|
suffix?: string
|
|
|
}
|
|
|
|
|
|
const thresholdAlarms = shallowRef<Record<string, ThresholdAlarm>>({})
|
|
|
const activeThresholdAlarms = computed(() => Object.values(thresholdAlarms.value))
|
|
|
+const thresholdAlarmNotices = shallowRef<Record<string, ThresholdAlarm>>({})
|
|
|
+const activeThresholdAlarmNotices = computed(() => Object.values(thresholdAlarmNotices.value))
|
|
|
+const thresholdAlarmNoticeTracker = createThresholdAlarmNoticeTracker()
|
|
|
+let thresholdAlarmNoticeSequence = 0
|
|
|
|
|
|
function getThresholdAlarmText(alarm: ThresholdAlarm) {
|
|
|
- const directionText = alarm.direction === 'high' ? '高于上限' : '低于下限'
|
|
|
const suffix = alarm.suffix || ''
|
|
|
+ const directionText = alarm.direction === 'increase' ? '增加' : '减少'
|
|
|
|
|
|
- const rangeText =
|
|
|
- alarm.range > 0
|
|
|
- ? `(阈值 ${formatChartValue(alarm.baseThreshold)}${suffix},超限范围 ${formatChartValue(alarm.range)}${suffix})`
|
|
|
- : ''
|
|
|
- const durationText = alarm.duration > 0 ? `(最近 ${formatChartValue(alarm.duration)} 秒内)` : ''
|
|
|
- const valueText = alarm.duration > 0 ? '出现' : '当前'
|
|
|
-
|
|
|
- return `${alarm.name}:${durationText}${valueText} ${formatChartValue(alarm.value)}${suffix},${directionText} ${formatChartValue(alarm.threshold)}${suffix}${rangeText}`
|
|
|
+ return `${dayjs(alarm.alarmAt).format('YYYY-MM-DD HH:mm:ss')} 告警,${alarm.name}:最近 ${formatChartValue(alarm.duration)} 秒内从 ${formatChartValue(alarm.fromValue)}${suffix}${directionText}到 ${formatChartValue(alarm.toValue)}${suffix},${directionText}了 ${formatChartValue(alarm.change)}${suffix},超过超限范围 ${formatChartValue(alarm.range)}${suffix}`
|
|
|
}
|
|
|
|
|
|
-function clearThresholdAlarms() {
|
|
|
+function clearActiveThresholdAlarms() {
|
|
|
thresholdAlarms.value = {}
|
|
|
+ thresholdAlarmNoticeTracker.clear()
|
|
|
setAlarmActive(false)
|
|
|
}
|
|
|
|
|
|
+function closeThresholdAlarmNotice(key: string) {
|
|
|
+ const alarm = thresholdAlarmNotices.value[key]
|
|
|
+ const nextNotices = { ...thresholdAlarmNotices.value }
|
|
|
+ delete nextNotices[key]
|
|
|
+ thresholdAlarmNotices.value = nextNotices
|
|
|
+
|
|
|
+ if (!alarm || !thresholdAlarmNoticeTracker.close(alarm.alarmKey, key)) return
|
|
|
+
|
|
|
+ const nextAlarms = { ...thresholdAlarms.value }
|
|
|
+ delete nextAlarms[alarm.alarmKey]
|
|
|
+ thresholdAlarms.value = nextAlarms
|
|
|
+ setAlarmActive(activeThresholdAlarms.value.length > 0)
|
|
|
+}
|
|
|
+
|
|
|
const currentOtherName = ref(props.otherName)
|
|
|
|
|
|
const displayDeviceName = computed(() => currentOtherName.value || props.deviceName)
|
|
|
@@ -253,7 +271,7 @@ watch(isConnected, (newVal) => {
|
|
|
subscribe(props.mqttUrl)
|
|
|
// subscribe('/636/YF649/property/post')
|
|
|
} else {
|
|
|
- clearThresholdAlarms()
|
|
|
+ clearActiveThresholdAlarms()
|
|
|
}
|
|
|
})
|
|
|
|
|
|
@@ -317,7 +335,7 @@ watch(
|
|
|
if (!active) {
|
|
|
destroy()
|
|
|
cancelRealtimeChartUpdate()
|
|
|
- clearThresholdAlarms()
|
|
|
+ clearActiveThresholdAlarms()
|
|
|
return
|
|
|
}
|
|
|
|
|
|
@@ -532,26 +550,44 @@ function updateDimensionValues(valueMap: Map<string, number>) {
|
|
|
|
|
|
function updateThresholdAlarm(pointMap: Map<string, ChartPoint>) {
|
|
|
const nextAlarms = { ...thresholdAlarms.value }
|
|
|
+ const nextNotices = { ...thresholdAlarmNotices.value }
|
|
|
|
|
|
dimensions.value.forEach((item) => {
|
|
|
const point = pointMap.get(item.identifier)
|
|
|
if (!point) return
|
|
|
|
|
|
- const min = normalizeRangeValue(item.minValue)
|
|
|
- const max = normalizeRangeValue(item.maxValue)
|
|
|
- const duration = Math.max(0, normalizeRangeValue(item.periodSecond) ?? 0)
|
|
|
- const range = Math.max(0, normalizeRangeValue(item.changeValue) ?? 0)
|
|
|
const alarmKey = `${item.identifier}|${item.name}`
|
|
|
+ const duration = normalizeRangeValue(item.periodSecond)
|
|
|
+ const range = normalizeRangeValue(item.changeValue)
|
|
|
+
|
|
|
+ if (duration === undefined || duration <= 0 || range === undefined || range < 0) {
|
|
|
+ delete nextAlarms[alarmKey]
|
|
|
+ thresholdAlarmNoticeTracker.pass(alarmKey)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
const series = chartData.value[item.name] || [point]
|
|
|
const anchorTs = series[series.length - 1]?.ts ?? point.ts
|
|
|
- const alarm = getWindowThresholdAlarm(series, anchorTs, duration, min, max, range)
|
|
|
+ const alarm = getWindowChangeAlarm(series, anchorTs, duration, range)
|
|
|
|
|
|
if (!alarm) {
|
|
|
delete nextAlarms[alarmKey]
|
|
|
+ thresholdAlarmNoticeTracker.pass(alarmKey)
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- nextAlarms[alarmKey] = {
|
|
|
+ const fingerprint = `${alarm.fromTs}:${alarm.fromValue}|${alarm.toTs}:${alarm.toValue}`
|
|
|
+
|
|
|
+ if (thresholdAlarmNoticeTracker.isAcknowledged(alarmKey, fingerprint)) {
|
|
|
+ delete nextAlarms[alarmKey]
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const previousAlarm = thresholdAlarms.value[alarmKey]
|
|
|
+ const activeAlarm: ThresholdAlarm = {
|
|
|
+ alarmAt: previousAlarm?.fingerprint === fingerprint ? previousAlarm.alarmAt : anchorTs,
|
|
|
+ alarmKey,
|
|
|
+ fingerprint,
|
|
|
key: alarmKey,
|
|
|
name: item.name,
|
|
|
...alarm,
|
|
|
@@ -559,9 +595,22 @@ function updateThresholdAlarm(pointMap: Map<string, ChartPoint>) {
|
|
|
range,
|
|
|
suffix: item.suffix
|
|
|
}
|
|
|
+
|
|
|
+ nextAlarms[alarmKey] = activeAlarm
|
|
|
+
|
|
|
+ if (thresholdAlarmNoticeTracker.shouldCreate(alarmKey, fingerprint)) {
|
|
|
+ const noticeKey = `${alarmKey}|${anchorTs}|${++thresholdAlarmNoticeSequence}`
|
|
|
+ nextNotices[noticeKey] = {
|
|
|
+ ...activeAlarm,
|
|
|
+ alarmAt: anchorTs,
|
|
|
+ key: noticeKey
|
|
|
+ }
|
|
|
+ thresholdAlarmNoticeTracker.record(alarmKey, noticeKey, fingerprint)
|
|
|
+ }
|
|
|
})
|
|
|
|
|
|
thresholdAlarms.value = nextAlarms
|
|
|
+ thresholdAlarmNotices.value = nextNotices
|
|
|
setAlarmActive(activeThresholdAlarms.value.length > 0)
|
|
|
}
|
|
|
|
|
|
@@ -1016,8 +1065,6 @@ function buildRangeSettingDrafts() {
|
|
|
name: item.name,
|
|
|
identifier: item.identifier,
|
|
|
color: item.color,
|
|
|
- minValue: normalizeRangeValue(item.minValue),
|
|
|
- maxValue: normalizeRangeValue(item.maxValue),
|
|
|
periodSecond: normalizeRangeValue(item.periodSecond),
|
|
|
changeValue: normalizeRangeValue(item.changeValue),
|
|
|
axisMin: normalizeRangeValue(item.axisMin),
|
|
|
@@ -1056,23 +1103,17 @@ async function saveDeviceName() {
|
|
|
|
|
|
async function saveRangeSetting(
|
|
|
item: Dimensions,
|
|
|
- minValue?: number,
|
|
|
- maxValue?: number,
|
|
|
periodSecondValue?: number,
|
|
|
changeValueValue?: number,
|
|
|
axisMinValue?: number,
|
|
|
axisMaxValue?: number
|
|
|
) {
|
|
|
- const min = normalizeRangeValue(minValue)
|
|
|
- const max = normalizeRangeValue(maxValue)
|
|
|
const periodSecond = normalizeRangeValue(periodSecondValue)
|
|
|
const changeValue = normalizeRangeValue(changeValueValue)
|
|
|
const axisMin = normalizeRangeValue(axisMinValue)
|
|
|
const axisMax = normalizeRangeValue(axisMaxValue)
|
|
|
|
|
|
if (
|
|
|
- min === undefined &&
|
|
|
- max === undefined &&
|
|
|
periodSecond === undefined &&
|
|
|
changeValue === undefined &&
|
|
|
axisMin === undefined &&
|
|
|
@@ -1092,26 +1133,10 @@ async function saveRangeSetting(
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- if ((min === undefined) !== (max === undefined)) {
|
|
|
- throw new Error(`${item.name} 的告警上限和告警下限需要同时填写`)
|
|
|
- }
|
|
|
-
|
|
|
- if (min !== undefined && max !== undefined && min > max) {
|
|
|
- throw new Error(`${item.name} 的告警下限不能大于告警上限`)
|
|
|
- }
|
|
|
-
|
|
|
- if (min !== undefined && max !== undefined && min === max) {
|
|
|
- throw new Error(`${item.name} 的告警上限和告警下限不能相等`)
|
|
|
- }
|
|
|
-
|
|
|
if ((periodSecond === undefined) !== (changeValue === undefined)) {
|
|
|
throw new Error(`${item.name} 的检查时间和超限范围需要同时填写`)
|
|
|
}
|
|
|
|
|
|
- if ((periodSecond !== undefined || changeValue !== undefined) && min === undefined) {
|
|
|
- throw new Error(`${item.name} 需要先设置告警上限和告警下限`)
|
|
|
- }
|
|
|
-
|
|
|
if (periodSecond !== undefined && (!Number.isInteger(periodSecond) || periodSecond <= 0)) {
|
|
|
throw new Error(`${item.name} 的检查时间必须为大于 0 的整数秒`)
|
|
|
}
|
|
|
@@ -1129,8 +1154,8 @@ async function saveRangeSetting(
|
|
|
}
|
|
|
|
|
|
const body = {
|
|
|
- minValue: min ?? null,
|
|
|
- maxValue: max ?? null,
|
|
|
+ minValue: null,
|
|
|
+ maxValue: null,
|
|
|
periodSecond: periodSecond ?? null,
|
|
|
changeValue: changeValue ?? null,
|
|
|
axisMin: axisMin ?? null,
|
|
|
@@ -1145,8 +1170,8 @@ async function saveRangeSetting(
|
|
|
const res = await IotDeviceApi.saveMaxMin(body)
|
|
|
|
|
|
if (res.id) item.id = res.id
|
|
|
- item.minValue = min
|
|
|
- item.maxValue = max
|
|
|
+ item.minValue = undefined
|
|
|
+ item.maxValue = undefined
|
|
|
item.periodSecond = periodSecond
|
|
|
item.changeValue = changeValue
|
|
|
item.axisMin = axisMin
|
|
|
@@ -1164,8 +1189,6 @@ async function handleSettingsSave() {
|
|
|
|
|
|
await saveRangeSetting(
|
|
|
item,
|
|
|
- draft.minValue,
|
|
|
- draft.maxValue,
|
|
|
draft.periodSecond,
|
|
|
draft.changeValue,
|
|
|
draft.axisMin,
|
|
|
@@ -1173,7 +1196,7 @@ async function handleSettingsSave() {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
- clearThresholdAlarms()
|
|
|
+ clearActiveThresholdAlarms()
|
|
|
updateSeriesByNames(dimensions.value.map((item) => item.name))
|
|
|
settingsDialogVisible.value = false
|
|
|
message.success('设置成功')
|
|
|
@@ -1187,8 +1210,6 @@ async function handleSettingsSave() {
|
|
|
function handleRangeDialogReset() {
|
|
|
rangeSettingDrafts.value = rangeSettingDrafts.value.map((item) => ({
|
|
|
...item,
|
|
|
- minValue: undefined,
|
|
|
- maxValue: undefined,
|
|
|
periodSecond: undefined,
|
|
|
changeValue: undefined,
|
|
|
axisMin: undefined,
|
|
|
@@ -1197,8 +1218,6 @@ function handleRangeDialogReset() {
|
|
|
}
|
|
|
|
|
|
function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
- row.minValue = undefined
|
|
|
- row.maxValue = undefined
|
|
|
row.periodSecond = undefined
|
|
|
row.changeValue = undefined
|
|
|
row.axisMin = undefined
|
|
|
@@ -1211,9 +1230,9 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
<div class="flex items-center">
|
|
|
<div class="title-icon"></div>
|
|
|
<div>{{ `${props.deviceCode}-${displayDeviceName}` }}</div>
|
|
|
- <span v-if="activeThresholdAlarms.length" class="chart-header__alarm-count">
|
|
|
+ <span v-if="activeThresholdAlarmNotices.length" class="chart-header__alarm-count">
|
|
|
<i class="i-material-symbols:warning-rounded"></i>
|
|
|
- {{ activeThresholdAlarms.length }} 项超限
|
|
|
+ {{ activeThresholdAlarmNotices.length }} 条告警
|
|
|
</span>
|
|
|
</div>
|
|
|
<div class="chart-header__actions">
|
|
|
@@ -1252,16 +1271,23 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
|
|
|
<div class="chart-stage">
|
|
|
<div
|
|
|
- v-if="activeThresholdAlarms.length"
|
|
|
+ v-if="activeThresholdAlarmNotices.length"
|
|
|
class="chart-alarm-panel"
|
|
|
role="alert"
|
|
|
aria-live="polite">
|
|
|
<span
|
|
|
- v-for="alarm in activeThresholdAlarms"
|
|
|
+ v-for="alarm in activeThresholdAlarmNotices"
|
|
|
:key="alarm.key"
|
|
|
class="chart-alarm-panel__item">
|
|
|
<i class="i-material-symbols:warning-rounded"></i>
|
|
|
<span>{{ getThresholdAlarmText(alarm) }}</span>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="chart-alarm-panel__close"
|
|
|
+ :aria-label="`关闭 ${alarm.name} 告警提示`"
|
|
|
+ @click.stop="closeThresholdAlarmNotice(alarm.key)">
|
|
|
+ <i class="i-material-symbols:close-rounded"></i>
|
|
|
+ </button>
|
|
|
</span>
|
|
|
</div>
|
|
|
<div ref="chartRef" class="chart-canvas"></div>
|
|
|
@@ -1287,14 +1313,9 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
</el-form>
|
|
|
|
|
|
<div class="board-settings-section">
|
|
|
- <div class="board-settings-section__title">告警阈值、检查时间、超限范围与 Y 轴范围</div>
|
|
|
- <ZmTable
|
|
|
- :data="rangeSettingDrafts"
|
|
|
- :loading="false"
|
|
|
- :max-height="420"
|
|
|
- :show-border="true"
|
|
|
- :fit="false">
|
|
|
- <ZmTableColumn label="曲线" width="210" fixed="left" align="left">
|
|
|
+ <div class="board-settings-section__title">检查时间、超限范围与 Y 轴范围</div>
|
|
|
+ <ZmTable :data="rangeSettingDrafts" :loading="false" :max-height="420" :show-border="true">
|
|
|
+ <ZmTableColumn label="曲线" align="left">
|
|
|
<template #default="{ row }">
|
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
|
<span
|
|
|
@@ -1304,25 +1325,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
</div>
|
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="告警下限" width="115">
|
|
|
- <template #default="{ row }">
|
|
|
- <el-input-number
|
|
|
- v-model="row.minValue"
|
|
|
- class="!w-full"
|
|
|
- :controls="false"
|
|
|
- placeholder="未设置" />
|
|
|
- </template>
|
|
|
- </ZmTableColumn>
|
|
|
- <ZmTableColumn label="告警上限" width="115">
|
|
|
- <template #default="{ row }">
|
|
|
- <el-input-number
|
|
|
- v-model="row.maxValue"
|
|
|
- class="!w-full"
|
|
|
- :controls="false"
|
|
|
- placeholder="未设置" />
|
|
|
- </template>
|
|
|
- </ZmTableColumn>
|
|
|
- <ZmTableColumn label="检查时间(秒)" width="125">
|
|
|
+ <ZmTableColumn label="检查时间(秒)">
|
|
|
<template #default="{ row }">
|
|
|
<el-input-number
|
|
|
v-model="row.periodSecond"
|
|
|
@@ -1330,10 +1333,10 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
:controls="false"
|
|
|
:min="1"
|
|
|
:precision="0"
|
|
|
- placeholder="仅当前值" />
|
|
|
+ placeholder="未设置" />
|
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="超限范围" width="115">
|
|
|
+ <ZmTableColumn label="超限范围">
|
|
|
<template #default="{ row }">
|
|
|
<el-input-number
|
|
|
v-model="row.changeValue"
|
|
|
@@ -1343,7 +1346,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
placeholder="0" />
|
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="Y 轴下限" width="110">
|
|
|
+ <ZmTableColumn label="Y 轴下限">
|
|
|
<template #default="{ row }">
|
|
|
<el-input-number
|
|
|
v-model="row.axisMin"
|
|
|
@@ -1352,7 +1355,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
:placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
|
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="Y 轴上限" width="110">
|
|
|
+ <ZmTableColumn label="Y 轴上限">
|
|
|
<template #default="{ row }">
|
|
|
<el-input-number
|
|
|
v-model="row.axisMax"
|
|
|
@@ -1361,7 +1364,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
:placeholder="formatChartValue(getSeriesRange(row.name).labelMax)" />
|
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="当前 Y 轴范围" width="120">
|
|
|
+ <ZmTableColumn label="当前 Y 轴范围">
|
|
|
<template #default="{ row }">
|
|
|
<span class="board-settings-range-text">
|
|
|
{{ formatChartValue(getSeriesRange(row.name).labelMin) }}
|
|
|
@@ -1370,7 +1373,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
</span>
|
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="操作" width="66">
|
|
|
+ <ZmTableColumn label="操作">
|
|
|
<template #default="{ row }">
|
|
|
<el-button size="small" link type="primary" @click="clearRangeDraft(row)">
|
|
|
清空
|
|
|
@@ -1657,17 +1660,38 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
display: inline-flex;
|
|
|
gap: 5px;
|
|
|
align-items: center;
|
|
|
+ max-width: 100%;
|
|
|
padding: 3px 7px;
|
|
|
font-size: 12px;
|
|
|
font-weight: 700;
|
|
|
line-height: 1.4;
|
|
|
color: #fee2e2;
|
|
|
- white-space: nowrap;
|
|
|
+ white-space: normal;
|
|
|
background: rgb(220 38 38 / 24%);
|
|
|
border: 1px solid rgb(248 113 113 / 40%);
|
|
|
border-radius: 999px;
|
|
|
}
|
|
|
|
|
|
+.chart-alarm-panel__close {
|
|
|
+ display: inline-flex;
|
|
|
+ flex-shrink: 0;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 18px;
|
|
|
+ height: 18px;
|
|
|
+ padding: 0;
|
|
|
+ color: #fecaca;
|
|
|
+ cursor: pointer;
|
|
|
+ background: transparent;
|
|
|
+ border: 0;
|
|
|
+ border-radius: 999px;
|
|
|
+}
|
|
|
+
|
|
|
+.chart-alarm-panel__close:hover {
|
|
|
+ color: #fff;
|
|
|
+ background: rgb(248 113 113 / 28%);
|
|
|
+}
|
|
|
+
|
|
|
@keyframes alarm-pulse {
|
|
|
0%,
|
|
|
100% {
|