Prechádzať zdrojové kódy

🐛 fix(设备监控): 修正监控看板相邻变化告警逻辑

- 移除告警上下限,改为检查时间窗口内相邻数据变化量判断
- 支持不固定采样间隔及增加、减少两种告警
- 修正告警提示关闭后状态同步及新告警重新触发
- 优化告警配置表自适应列宽并补充测试
Zimo 3 dní pred
rodič
commit
bb22c22bdc

+ 1 - 1
.env.local

@@ -3,7 +3,7 @@ NODE_ENV=development
 
 VITE_DEV=true
 
-# 请求路径  http://192.168.188.86:48080  https://iot.deepoil.cc:5443  http://172.26.0.56:48080  http://192.168.188.198:48080
+# 请求路径  http://localhost:58080  https://iot.deepoil.cc:5443  http://172.26.0.56:48080  http://192.168.188.198:48080
 VITE_BASE_URL='https://iot.deepoil.cc:5443'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持 S3 服务

+ 36 - 0
src/utils/thresholdAlarmNoticeTracker.ts

@@ -0,0 +1,36 @@
+export function createThresholdAlarmNoticeTracker() {
+  const currentNotices = new Map<string, { noticeKey: string; fingerprint: string }>()
+  const acknowledgedFingerprints = new Map<string, string>()
+
+  return {
+    shouldCreate(alarmKey: string, fingerprint: string) {
+      return (
+        currentNotices.get(alarmKey)?.fingerprint !== fingerprint &&
+        acknowledgedFingerprints.get(alarmKey) !== fingerprint
+      )
+    },
+    isAcknowledged(alarmKey: string, fingerprint: string) {
+      return acknowledgedFingerprints.get(alarmKey) === fingerprint
+    },
+    record(alarmKey: string, noticeKey: string, fingerprint: string) {
+      acknowledgedFingerprints.delete(alarmKey)
+      currentNotices.set(alarmKey, { noticeKey, fingerprint })
+    },
+    close(alarmKey: string, noticeKey: string) {
+      if (currentNotices.get(alarmKey)?.noticeKey !== noticeKey) return false
+
+      const currentNotice = currentNotices.get(alarmKey)
+      currentNotices.delete(alarmKey)
+      acknowledgedFingerprints.set(alarmKey, currentNotice!.fingerprint)
+      return true
+    },
+    pass(alarmKey: string) {
+      currentNotices.delete(alarmKey)
+      acknowledgedFingerprints.delete(alarmKey)
+    },
+    clear() {
+      currentNotices.clear()
+      acknowledgedFingerprints.clear()
+    }
+  }
+}

+ 31 - 21
src/utils/thresholdAlarmWindow.ts

@@ -3,37 +3,47 @@ export interface AlarmPoint {
   value: number
 }
 
-export interface WindowThresholdAlarm {
-  value: number
-  threshold: number
-  baseThreshold: number
-  direction: 'high' | 'low'
+export interface WindowChangeAlarm {
+  change: number
+  direction: 'increase' | 'decrease'
+  fromValue: number
+  fromTs: number
+  toTs: number
+  toValue: number
 }
 
-export function getWindowThresholdAlarm(
+export function getWindowChangeAlarm(
   points: AlarmPoint[],
   anchorTs: number,
   windowSeconds: number,
-  min?: number,
-  max?: number,
-  range = 0
-): WindowThresholdAlarm | undefined {
+  range: number
+): WindowChangeAlarm | undefined {
+  if (windowSeconds <= 0 || range < 0) return undefined
+
   const startTs = anchorTs - windowSeconds * 1000
+  const windowPoints = points
+    .filter((point) => point.ts > startTs && point.ts <= anchorTs)
+    .sort((left, right) => left.ts - right.ts)
 
-  for (let index = points.length - 1; index >= 0; index--) {
-    const point = points[index]
-    if (point.ts > anchorTs) continue
-    if (windowSeconds > 0 && point.ts <= startTs) break
-    if (windowSeconds === 0 && point.ts !== anchorTs) break
+  let alarm: WindowChangeAlarm | undefined
 
-    if (max !== undefined && point.value > max + range) {
-      return { value: point.value, threshold: max + range, baseThreshold: max, direction: 'high' }
-    }
+  for (let index = 1; index < windowPoints.length; index += 1) {
+    const previousPoint = windowPoints[index - 1]
+    const currentPoint = windowPoints[index]
+    const delta = currentPoint.value - previousPoint.value
+    const change = Math.abs(delta)
+
+    if (change <= range) continue
 
-    if (min !== undefined && point.value < min - range) {
-      return { value: point.value, threshold: min - range, baseThreshold: min, direction: 'low' }
+    alarm = {
+      change,
+      direction: delta > 0 ? 'increase' : 'decrease',
+      fromValue: previousPoint.value,
+      fromTs: previousPoint.ts,
+      toTs: currentPoint.ts,
+      toValue: currentPoint.value
     }
   }
 
-  return undefined
+  return alarm
 }

+ 120 - 96
src/views/oli-connection/monitoring-board/chart.vue

@@ -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% {

+ 50 - 0
tests/thresholdAlarmNoticeTracker.test.mjs

@@ -0,0 +1,50 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import { createThresholdAlarmNoticeTracker } from '../src/utils/thresholdAlarmNoticeTracker.ts'
+
+test('关闭当前活动告警提示后,本轮持续超限不再重复告警', () => {
+  const tracker = createThresholdAlarmNoticeTracker()
+
+  tracker.record('oil-speed', 'notice-1', 'min-1|max-1')
+  assert.equal(tracker.shouldCreate('oil-speed', 'min-1|max-1'), false)
+
+  assert.equal(tracker.close('oil-speed', 'notice-1'), true)
+  assert.equal(tracker.isAcknowledged('oil-speed', 'min-1|max-1'), true)
+  assert.equal(tracker.shouldCreate('oil-speed', 'min-1|max-1'), false)
+  assert.equal(tracker.shouldCreate('oil-speed', 'min-2|max-2'), true)
+})
+
+test('关闭历史提示不会让当前活动告警重复提示', () => {
+  const tracker = createThresholdAlarmNoticeTracker()
+
+  tracker.record('oil-speed', 'notice-current', 'min-1|max-1')
+  assert.equal(tracker.close('oil-speed', 'notice-history'), false)
+
+  assert.equal(tracker.isAcknowledged('oil-speed', 'min-1|max-1'), false)
+  assert.equal(tracker.shouldCreate('oil-speed', 'min-1|max-1'), false)
+})
+
+test('检查通过后允许下一轮告警创建新提示', () => {
+  const tracker = createThresholdAlarmNoticeTracker()
+
+  tracker.record('oil-speed', 'notice-1', 'min-1|max-1')
+  tracker.close('oil-speed', 'notice-1')
+  assert.equal(tracker.shouldCreate('oil-speed', 'min-1|max-1'), false)
+
+  tracker.pass('oil-speed')
+
+  assert.equal(tracker.isAcknowledged('oil-speed', 'min-1|max-1'), false)
+  assert.equal(tracker.shouldCreate('oil-speed', 'min-1|max-1'), true)
+})
+
+test('不同指标的当前提示状态互不影响', () => {
+  const tracker = createThresholdAlarmNoticeTracker()
+
+  tracker.record('oil-speed', 'oil-notice', 'oil-1')
+  tracker.record('well-pressure', 'pressure-notice', 'pressure-1')
+  tracker.close('oil-speed', 'oil-notice')
+
+  assert.equal(tracker.isAcknowledged('oil-speed', 'oil-1'), true)
+  assert.equal(tracker.shouldCreate('oil-speed', 'oil-1'), false)
+  assert.equal(tracker.shouldCreate('well-pressure', 'pressure-1'), false)
+})

+ 95 - 42
tests/thresholdAlarmWindow.test.mjs

@@ -1,80 +1,133 @@
 import assert from 'node:assert/strict'
 import { test } from 'node:test'
-import { getWindowThresholdAlarm } from '../src/utils/thresholdAlarmWindow.ts'
+import { getWindowChangeAlarm } from '../src/utils/thresholdAlarmWindow.ts'
 
 const second = (value) => value * 1000
 
-test('第 25 秒检查第 16~25 秒,窗口内任一点超限就告警', () => {
-  const points = Array.from({ length: 10 }, (_, index) => ({
-    ts: second(index + 16),
-    value: index === 0 ? 1101 : 800
-  }))
-
-  assert.deepEqual(getWindowThresholdAlarm(points, second(25), 10, 500, 1000, 100), {
-    value: 1101,
-    threshold: 1100,
-    baseThreshold: 1000,
-    direction: 'high'
+test('最近 10 秒内相邻数据上涨幅度大于超限范围时告警', () => {
+  const points = [
+    { ts: second(47), value: 800 },
+    { ts: second(48), value: 901 },
+    { ts: second(55), value: 901 }
+  ]
+
+  assert.deepEqual(getWindowChangeAlarm(points, second(55), 10, 100), {
+    change: 101,
+    direction: 'increase',
+    fromValue: 800,
+    fromTs: second(47),
+    toTs: second(48),
+    toValue: 901
   })
 })
 
-test('第 15 秒的越限点不属于第 25 秒的 10 秒窗口', () => {
+test('最近 10 秒内相邻数据下降幅度大于超限范围时同样告警', () => {
   const points = [
-    { ts: second(15), value: 1101 },
-    { ts: second(25), value: 800 }
+    { ts: second(47), value: 901 },
+    { ts: second(48), value: 800 },
+    { ts: second(55), value: 800 }
   ]
 
-  assert.equal(getWindowThresholdAlarm(points, second(25), 10, 500, 1000, 100), undefined)
+  assert.deepEqual(getWindowChangeAlarm(points, second(55), 10, 100), {
+    change: 101,
+    direction: 'decrease',
+    fromValue: 901,
+    fromTs: second(47),
+    toTs: second(48),
+    toValue: 800
+  })
 })
 
-test('窗口随当前时间右移,旧越限点退出后告警解除', () => {
+test('相邻数据间隔不固定时仍按相邻变化量判断', () => {
   const points = [
-    { ts: second(16), value: 1101 },
-    { ts: second(25), value: 800 },
-    { ts: second(26), value: 800 }
+    { ts: second(46), value: 800 },
+    { ts: second(47), value: 850 },
+    { ts: second(49), value: 951 },
+    { ts: second(53), value: 970 },
+    { ts: second(55), value: 970 }
   ]
 
-  assert.equal(getWindowThresholdAlarm(points, second(26), 10, 500, 1000, 100), undefined)
+  assert.deepEqual(getWindowChangeAlarm(points, second(55), 10, 100), {
+    change: 101,
+    direction: 'increase',
+    fromValue: 850,
+    fromTs: second(47),
+    toTs: second(49),
+    toValue: 951
+  })
 })
 
-test('下限按阈值减去范围检查,优先展示最近一次越限点', () => {
+test('相邻数据未超限时,即使窗口累计变化超限也不告警', () => {
   const points = [
-    { ts: second(16), value: 1101 },
-    { ts: second(22), value: 399 },
-    { ts: second(25), value: 800 }
+    { ts: second(46), value: 850 },
+    { ts: second(47), value: 900 },
+    { ts: second(48), value: 950 },
+    { ts: second(55), value: 950 }
   ]
 
-  assert.deepEqual(getWindowThresholdAlarm(points, second(25), 10, 500, 1000, 100), {
-    value: 399,
-    threshold: 400,
-    baseThreshold: 500,
-    direction: 'low'
+  assert.equal(getWindowChangeAlarm(points, second(55), 10, 100), undefined)
+})
+
+test('窗口内有多次超限时返回最新一组相邻变化', () => {
+  const points = [
+    { ts: second(47), value: 800 },
+    { ts: second(48), value: 901 },
+    { ts: second(50), value: 800 },
+    { ts: second(51), value: 650 },
+    { ts: second(55), value: 650 }
+  ]
+
+  assert.deepEqual(getWindowChangeAlarm(points, second(55), 10, 100), {
+    change: 150,
+    direction: 'decrease',
+    fromValue: 800,
+    fromTs: second(50),
+    toTs: second(51),
+    toValue: 650
   })
 })
 
-test('范围边界相等时不触发', () => {
+test('窗口外的数据不参与幅度计算', () => {
   const points = [
-    { ts: second(24), value: 1100 },
-    { ts: second(25), value: 400 }
+    { ts: second(45), value: 1000 },
+    { ts: second(46), value: 800 },
+    { ts: second(55), value: 800 }
   ]
 
-  assert.equal(getWindowThresholdAlarm(points, second(25), 10, 500, 1000, 100), undefined)
+  assert.equal(getWindowChangeAlarm(points, second(55), 10, 100), undefined)
 })
 
-test('乱序数据中晚于当前时间的点不参与检查', () => {
+test('窗口随最新数据右移,窗口外的相邻超限退出后告警解除', () => {
   const points = [
-    { ts: second(25), value: 800 },
-    { ts: second(26), value: 1101 }
+    { ts: second(46), value: 800 },
+    { ts: second(47), value: 901 },
+    { ts: second(56), value: 900 }
   ]
 
-  assert.equal(getWindowThresholdAlarm(points, second(25), 10, 500, 1000, 100), undefined)
+  assert.equal(getWindowChangeAlarm(points, second(56), 10, 100), undefined)
 })
 
-test('未设置窗口时只检查当前点,保留原有立即告警语义', () => {
+test('范围边界相等时不触发', () => {
   const points = [
-    { ts: second(24), value: 1001 },
-    { ts: second(25), value: 800 }
+    { ts: second(54), value: 800 },
+    { ts: second(55), value: 900 }
   ]
 
-  assert.equal(getWindowThresholdAlarm(points, second(25), 0, 500, 1000), undefined)
+  assert.equal(getWindowChangeAlarm(points, second(55), 10, 100), undefined)
+})
+
+test('乱序数据中晚于当前时间的点不参与检查', () => {
+  const points = [
+    { ts: second(54), value: 800 },
+    { ts: second(55), value: 900 },
+    { ts: second(56), value: 1000 }
+  ]
+
+  assert.equal(getWindowChangeAlarm(points, second(55), 10, 100), undefined)
+})
+
+test('窗口内只有一个数据点时不触发', () => {
+  const points = [{ ts: second(55), value: 800 }]
+
+  assert.equal(getWindowChangeAlarm(points, second(55), 10, 0), undefined)
 })