yanghao 6 dni temu
rodzic
commit
ddbfa3b39d

+ 4 - 0
src/api/pms/iotrhdailyreport/index.ts

@@ -69,6 +69,10 @@ export const IotRhDailyReportApi = {
     return await request.get({ url: `/pms/iot-rh-daily-report/polylineStatistics`, params })
   },
 
+  getIotRhDailyReportSummaryMonthPolyline: async (params: any) => {
+    return await request.get({ url: `/pms/iot-rh-daily-report/monthPolylineStatistics`, params })
+  },
+
   nptStatistics: async (params: any) => {
     return await request.get({ url: `/pms/iot-rh-daily-report/nptStatistics`, params })
   },

+ 9 - 1
src/assets/icons/grid-16.svg

@@ -1 +1,9 @@
-<svg width="24.000000" height="24.000000" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><clipPath id="clip1_20937"><rect width="24.000000" height="24.000000" fill="white" fill-opacity="0"></rect></clipPath></defs><rect width="24.000000" height="24.000000" fill="currentColor" fill-opacity="0"></rect><rect width="24.000000" height="24.000000" fill-opacity="1.000000"></rect><g clip-path="url(#clip1_20937)"><path d="M1 3L22 3C22.5523 3 23 3.44771 23 4L23 20C23 20.5523 22.5523 21 22 21L1 21C0.447723 21 0 20.5523 0 20L0 4C0 3.44771 0.447723 3 1 3ZM22 4L1 4L1 20L22 20L22 4ZM2 5L6 5L6 8L2 8L2 5ZM7 5L11 5L11 8L7 8L7 5ZM12 5L16 5L16 8L12 8L12 5ZM17 5L21 5L21 8L17 8L17 5ZM2 9L6 9L6 11L2 11L2 9ZM7 9L11 9L11 11L7 11L7 9ZM12 9L16 9L16 11L12 11L12 9ZM17 9L21 9L21 11L17 11L17 9ZM2 12L6 12L6 15L2 15L2 12ZM7 12L11 12L11 15L7 15L7 12ZM12 12L16 12L16 15L12 15L12 12ZM17 12L21 12L21 15L17 15L17 12ZM2 16L6 16L6 19L2 19L2 16ZM7 16L11 16L11 19L7 19L7 16ZM12 16L16 16L16 19L12 19L12 16ZM17 16L21 16L21 19L17 19L17 16Z" clip-rule="evenodd" fill="currentColor" fill-opacity="0.701961" fill-rule="evenodd"></path></g></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
+  <rect x="2" y="3" width="20" height="18" rx="1" fill="none" stroke="currentColor" stroke-width="1.25" />
+  <line x1="7" y1="3" x2="7" y2="21" stroke="currentColor" stroke-width="1.25" />
+  <line x1="12" y1="3" x2="12" y2="21" stroke="currentColor" stroke-width="1.25" />
+  <line x1="17" y1="3" x2="17" y2="21" stroke="currentColor" stroke-width="1.25" />
+  <line x1="2" y1="7.5" x2="22" y2="7.5" stroke="currentColor" stroke-width="1.25" />
+  <line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="1.25" />
+  <line x1="2" y1="16.5" x2="22" y2="16.5" stroke="currentColor" stroke-width="1.25" />
+</svg>

+ 5 - 1
src/assets/icons/grid-four.svg

@@ -1 +1,5 @@
-<svg width="24.000000" height="24.000000" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><clipPath id="clip1_20933"><rect width="24.000000" height="24.000000" fill="white" fill-opacity="0"></rect></clipPath></defs><rect width="24.000000" height="24.000000" fill="currentColor" fill-opacity="0"></rect><rect width="24.000000" height="24.000000" fill-opacity="1.000000"></rect><g clip-path="url(#clip1_20933)"><path d="M22 3L1 3C0.447723 3 0 3.44771 0 4L0 20C0 20.5523 0.447723 21 1 21L22 21C22.5523 21 23 20.5523 23 20L23 4C23 3.44771 22.5523 3 22 3ZM1 4L22 4L22 20L1 20L1 4ZM2 5L11 5L11 11L2 11L2 5ZM12 5L21 5L21 11L12 11L12 5ZM2 12L11 12L11 19L2 19L2 12ZM21 12L12 12L12 19L21 19L21 12Z" clip-rule="evenodd" fill="currentColor" fill-opacity="0.701961" fill-rule="evenodd"></path></g></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
+  <rect x="2" y="3" width="20" height="18" rx="1" fill="none" stroke="currentColor" stroke-width="1.25" />
+  <line x1="12" y1="3" x2="12" y2="21" stroke="currentColor" stroke-width="1.25" />
+  <line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="1.25" />
+</svg>

+ 7 - 1
src/assets/icons/grid-nine.svg

@@ -1 +1,7 @@
-<svg width="24.000000" height="24.000000" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><clipPath id="clip1_20935"><rect width="24.000000" height="24.000000" fill="white" fill-opacity="0"></rect></clipPath></defs><rect width="24.000000" height="24.000000" fill="currentColor" fill-opacity="0"></rect><rect width="24.000000" height="24.000000" fill-opacity="1.000000"></rect><g clip-path="url(#clip1_20935)"><path d="M1 3L22 3C22.5523 3 23 3.44771 23 4L23 20C23 20.5523 22.5523 21 22 21L1 21C0.447723 21 0 20.5523 0 20L0 4C0 3.44771 0.447723 3 1 3ZM22 4L1 4L1 20L22 20L22 4ZM2 5L8 5L8 9L2 9L2 5ZM9 5L14 5L14 9L9 9L9 5ZM15 5L21 5L21 9L15 9L15 5ZM2 10L8 10L8 14L2 14L2 10ZM9 10L14 10L14 14L9 14L9 10ZM15 10L21 10L21 14L15 14L15 10ZM2 15L8 15L8 19L2 19L2 15ZM9 15L14 15L14 19L9 19L9 15ZM15 15L21 15L21 19L15 19L15 15Z" clip-rule="evenodd" fill="currentColor" fill-opacity="0.701961" fill-rule="evenodd"></path></g></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
+  <rect x="2" y="3" width="20" height="18" rx="1" fill="none" stroke="currentColor" stroke-width="1.25" />
+  <line x1="8.67" y1="3" x2="8.67" y2="21" stroke="currentColor" stroke-width="1.25" />
+  <line x1="15.33" y1="3" x2="15.33" y2="21" stroke="currentColor" stroke-width="1.25" />
+  <line x1="2" y1="9" x2="22" y2="9" stroke="currentColor" stroke-width="1.25" />
+  <line x1="2" y1="15" x2="22" y2="15" stroke="currentColor" stroke-width="1.25" />
+</svg>

+ 3 - 1
src/assets/icons/grid-one.svg

@@ -1 +1,3 @@
-<svg width="24.000000" height="24.000000" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><clipPath id="clip1_20931"><rect width="24.000000" height="24.000000" fill="white" fill-opacity="0"></rect></clipPath></defs><rect width="24.000000" height="24.000000" fill="currentColor" fill-opacity="0"></rect><rect width="24.000000" height="24.000000" fill-opacity="1.000000"></rect><g clip-path="url(#clip1_20931)"><path d="M1 3L22 3C22.5523 3 23 3.44771 23 4L23 20C23 20.5523 22.5523 21 22 21L1 21C0.447723 21 0 20.5523 0 20L0 4C0 3.44771 0.447723 3 1 3ZM22 4L1 4L1 20L22 20L22 4ZM21 5L2 5L2 19L21 19L21 5Z" clip-rule="evenodd" fill="currentColor" fill-opacity="0.701961" fill-rule="evenodd"></path></g></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
+  <rect x="2" y="3" width="20" height="18" rx="1" fill="none" stroke="currentColor" stroke-width="1.25" />
+</svg>

+ 2 - 0
src/plugins/echarts/index.ts

@@ -22,6 +22,7 @@ import {
   TooltipComponent,
   VisualMapComponent
 } from 'echarts/components'
+import { LabelLayout } from 'echarts/features'
 
 import { CanvasRenderer } from 'echarts/renderers'
 
@@ -35,6 +36,7 @@ echarts.use([
   AriaComponent,
   ParallelComponent,
   VisualMapComponent,
+  LabelLayout,
   BarChart,
   LineChart,
   PieChart,

+ 39 - 0
src/utils/thresholdAlarmWindow.ts

@@ -0,0 +1,39 @@
+export interface AlarmPoint {
+  ts: number
+  value: number
+}
+
+export interface WindowThresholdAlarm {
+  value: number
+  threshold: number
+  baseThreshold: number
+  direction: 'high' | 'low'
+}
+
+export function getWindowThresholdAlarm(
+  points: AlarmPoint[],
+  anchorTs: number,
+  windowSeconds: number,
+  min?: number,
+  max?: number,
+  range = 0
+): WindowThresholdAlarm | undefined {
+  const startTs = anchorTs - windowSeconds * 1000
+
+  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
+
+    if (max !== undefined && point.value > max + range) {
+      return { value: point.value, threshold: max + range, baseThreshold: max, direction: 'high' }
+    }
+
+    if (min !== undefined && point.value < min - range) {
+      return { value: point.value, threshold: min - range, baseThreshold: min, direction: 'low' }
+    }
+  }
+
+  return undefined
+}

+ 8 - 0
src/utils/useSocketBus.ts

@@ -117,6 +117,8 @@ export interface Dimensions {
   isText?: boolean
   minValue?: number
   maxValue?: number
+  periodSecond?: number
+  changeValue?: number
   axisMin?: number
   axisMax?: number
   id?: number
@@ -155,6 +157,8 @@ export interface IotTdItem {
   alarmSettingId?: number
   maxValue?: unknown
   minValue?: unknown
+  periodSecond?: unknown
+  changeValue?: unknown
   axisMax?: unknown
   axisMin?: unknown
 }
@@ -172,6 +176,8 @@ export function mapToDimension(item: IotTdItem): RawDimension {
     id: item.alarmSettingId,
     maxValue: item.alarmSettingId ? normalizeRangeValue(item.maxValue) : undefined,
     minValue: item.alarmSettingId ? normalizeRangeValue(item.minValue) : undefined,
+    periodSecond: item.alarmSettingId ? normalizeRangeValue(item.periodSecond) : undefined,
+    changeValue: item.alarmSettingId ? normalizeRangeValue(item.changeValue) : undefined,
     axisMax: item.alarmSettingId ? normalizeRangeValue(item.axisMax) : undefined,
     axisMin: item.alarmSettingId ? normalizeRangeValue(item.axisMin) : undefined
   }
@@ -194,6 +200,8 @@ export interface RangeSettingDraft {
   color: string
   minValue?: number
   maxValue?: number
+  periodSecond?: number
+  changeValue?: number
   axisMin?: number
   axisMax?: number
 }

+ 135 - 43
src/views/oli-connection/monitoring-board/chart.vue

@@ -4,6 +4,7 @@ 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 {
   Dimensions,
   formatChartValue,
@@ -91,6 +92,9 @@ interface ThresholdAlarm {
   name: string
   value: number
   threshold: number
+  baseThreshold: number
+  duration: number
+  range: number
   direction: 'high' | 'low'
   suffix?: string
 }
@@ -102,7 +106,14 @@ function getThresholdAlarmText(alarm: ThresholdAlarm) {
   const directionText = alarm.direction === 'high' ? '高于上限' : '低于下限'
   const suffix = alarm.suffix || ''
 
-  return `${alarm.name}:当前 ${formatChartValue(alarm.value)}${suffix},${directionText} ${formatChartValue(alarm.threshold)}${suffix}`
+  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}`
 }
 
 function clearThresholdAlarms() {
@@ -145,6 +156,7 @@ function getPointTimestamp(value: unknown) {
 const handleMessageUpdate = (_topic: string, data: any) => {
   const list = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : []
   const valueMap = new Map<string, number>()
+  const pointMap = new Map<string, ChartPoint>()
   const updatedNames = new Set<string>()
 
   for (const item of list) {
@@ -154,23 +166,23 @@ const handleMessageUpdate = (_topic: string, data: any) => {
     if (!identity || !Number.isFinite(value)) continue
 
     valueMap.set(identity, value)
+    const point = {
+      ts: getPointTimestamp(item.remark || item.readTime || item.ts || item.time),
+      value
+    }
+    pointMap.set(identity, point)
 
     const modelName = dimensions.value.find((item) => item.identifier === identity)?.name
 
     if (modelName && chartData.value[modelName]) {
-      const ts = getPointTimestamp(item.remark || item.readTime || item.ts || item.time)
-
-      appendChartPoint(modelName, {
-        ts,
-        value
-      })
+      appendChartPoint(modelName, point)
 
       updatedNames.add(modelName)
     }
   }
 
   updateDimensionValues(valueMap)
-  updateThresholdAlarm(valueMap)
+  updateThresholdAlarm(pointMap)
 
   if (!updatedNames.size) return
 
@@ -459,36 +471,34 @@ function updateDimensionValues(valueMap: Map<string, number>) {
   })
 }
 
-function updateThresholdAlarm(valueMap: Map<string, number>) {
+function updateThresholdAlarm(pointMap: Map<string, ChartPoint>) {
   const nextAlarms = { ...thresholdAlarms.value }
 
   dimensions.value.forEach((item) => {
-    const value = valueMap.get(item.identifier)
-    if (value === undefined) return
+    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}`
-    if (max !== undefined && value > max) {
-      nextAlarms[alarmKey] = {
-        key: alarmKey,
-        name: item.name,
-        value,
-        threshold: max,
-        direction: 'high',
-        suffix: item.suffix
-      }
-    } else if (min !== undefined && value < min) {
-      nextAlarms[alarmKey] = {
-        key: alarmKey,
-        name: item.name,
-        value,
-        threshold: min,
-        direction: 'low',
-        suffix: item.suffix
-      }
-    } else {
+    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)
+
+    if (!alarm) {
       delete nextAlarms[alarmKey]
+      return
+    }
+
+    nextAlarms[alarmKey] = {
+      key: alarmKey,
+      name: item.name,
+      ...alarm,
+      duration,
+      range,
+      suffix: item.suffix
     }
   })
 
@@ -904,6 +914,8 @@ function buildRangeSettingDrafts() {
     color: item.color,
     minValue: normalizeRangeValue(item.minValue),
     maxValue: normalizeRangeValue(item.maxValue),
+    periodSecond: normalizeRangeValue(item.periodSecond),
+    changeValue: normalizeRangeValue(item.changeValue),
     axisMin: normalizeRangeValue(item.axisMin),
     axisMax: normalizeRangeValue(item.axisMax)
   }))
@@ -942,21 +954,34 @@ 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 && axisMin === undefined && axisMax === undefined) {
+  if (
+    min === undefined &&
+    max === undefined &&
+    periodSecond === undefined &&
+    changeValue === undefined &&
+    axisMin === undefined &&
+    axisMax === undefined
+  ) {
     if (item.id) {
       await IotDeviceApi.deleteMaxMin({ id: item.id })
     }
 
     item.minValue = undefined
     item.maxValue = undefined
+    item.periodSecond = undefined
+    item.changeValue = undefined
     item.axisMin = undefined
     item.axisMax = undefined
     item.id = undefined
@@ -975,6 +1000,22 @@ async function saveRangeSetting(
     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 的整数秒`)
+  }
+
+  if (changeValue !== undefined && changeValue < 0) {
+    throw new Error(`${item.name} 的超限范围不能小于 0`)
+  }
+
   if ((axisMin === undefined) !== (axisMax === undefined)) {
     throw new Error(`${item.name} 的 Y 轴上限和 Y 轴下限需要同时填写`)
   }
@@ -986,6 +1027,8 @@ async function saveRangeSetting(
   const body = {
     minValue: min ?? null,
     maxValue: max ?? null,
+    periodSecond: periodSecond ?? null,
+    changeValue: changeValue ?? null,
     axisMin: axisMin ?? null,
     axisMax: axisMax ?? null,
     deviceId: props.id,
@@ -1000,6 +1043,8 @@ async function saveRangeSetting(
   if (res.id) item.id = res.id
   item.minValue = min
   item.maxValue = max
+  item.periodSecond = periodSecond
+  item.changeValue = changeValue
   item.axisMin = axisMin
   item.axisMax = axisMax
 }
@@ -1013,9 +1058,18 @@ async function handleSettingsSave() {
       const item = dimensions.value.find((dimension) => dimension.identifier === draft.identifier)
       if (!item) continue
 
-      await saveRangeSetting(item, draft.minValue, draft.maxValue, draft.axisMin, draft.axisMax)
+      await saveRangeSetting(
+        item,
+        draft.minValue,
+        draft.maxValue,
+        draft.periodSecond,
+        draft.changeValue,
+        draft.axisMin,
+        draft.axisMax
+      )
     }
 
+    clearThresholdAlarms()
     updateSeriesByNames(dimensions.value.map((item) => item.name))
     settingsDialogVisible.value = false
     message.success('设置成功')
@@ -1031,6 +1085,8 @@ function handleRangeDialogReset() {
     ...item,
     minValue: undefined,
     maxValue: undefined,
+    periodSecond: undefined,
+    changeValue: undefined,
     axisMin: undefined,
     axisMax: undefined
   }))
@@ -1039,6 +1095,8 @@ function handleRangeDialogReset() {
 function clearRangeDraft(row: RangeSettingDraft) {
   row.minValue = undefined
   row.maxValue = undefined
+  row.periodSecond = undefined
+  row.changeValue = undefined
   row.axisMin = undefined
   row.axisMax = undefined
 }
@@ -1109,7 +1167,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
     <el-dialog
       v-model="settingsDialogVisible"
       title="设备设置"
-      width="1100px"
+      width="min(1140px, calc(100vw - 32px))"
       class="board-settings-dialog"
       modal-class="board-settings-overlay"
       append-to-body
@@ -1125,19 +1183,24 @@ 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">
-          <ZmTableColumn label="曲线" min-width="180">
+        <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">
             <template #default="{ row }">
               <div class="flex items-center gap-2 min-w-0">
                 <span
                   class="inline-block size-2.5 rounded-full shrink-0"
                   :style="{ backgroundColor: row.color }"></span>
-                <span class="board-settings-line-name">{{ row.name }}</span>
+                <span class="board-settings-line-name" :title="row.name">{{ row.name }}</span>
               </div>
             </template>
           </ZmTableColumn>
-          <ZmTableColumn label="告警下限" width="140">
+          <ZmTableColumn label="告警下限" width="115">
             <template #default="{ row }">
               <el-input-number
                 v-model="row.minValue"
@@ -1146,7 +1209,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
                 placeholder="未设置" />
             </template>
           </ZmTableColumn>
-          <ZmTableColumn label="告警上限" width="140">
+          <ZmTableColumn label="告警上限" width="115">
             <template #default="{ row }">
               <el-input-number
                 v-model="row.maxValue"
@@ -1155,7 +1218,28 @@ function clearRangeDraft(row: RangeSettingDraft) {
                 placeholder="未设置" />
             </template>
           </ZmTableColumn>
-          <ZmTableColumn label="Y 轴下限" width="140">
+          <ZmTableColumn label="检查时间(秒)" width="125">
+            <template #default="{ row }">
+              <el-input-number
+                v-model="row.periodSecond"
+                class="!w-full"
+                :controls="false"
+                :min="1"
+                :precision="0"
+                placeholder="仅当前值" />
+            </template>
+          </ZmTableColumn>
+          <ZmTableColumn label="超限范围" width="115">
+            <template #default="{ row }">
+              <el-input-number
+                v-model="row.changeValue"
+                class="!w-full"
+                :controls="false"
+                :min="0"
+                placeholder="0" />
+            </template>
+          </ZmTableColumn>
+          <ZmTableColumn label="Y 轴下限" width="110">
             <template #default="{ row }">
               <el-input-number
                 v-model="row.axisMin"
@@ -1164,7 +1248,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
                 :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
             </template>
           </ZmTableColumn>
-          <ZmTableColumn label="Y 轴上限" width="140">
+          <ZmTableColumn label="Y 轴上限" width="110">
             <template #default="{ row }">
               <el-input-number
                 v-model="row.axisMax"
@@ -1173,7 +1257,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
                 :placeholder="formatChartValue(getSeriesRange(row.name).labelMax)" />
             </template>
           </ZmTableColumn>
-          <ZmTableColumn label="当前 Y 轴范围" width="150">
+          <ZmTableColumn label="当前 Y 轴范围" width="120">
             <template #default="{ row }">
               <span class="board-settings-range-text">
                 {{ formatChartValue(getSeriesRange(row.name).labelMin) }}
@@ -1182,7 +1266,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
               </span>
             </template>
           </ZmTableColumn>
-          <ZmTableColumn label="操作" width="90">
+          <ZmTableColumn label="操作" width="66">
             <template #default="{ row }">
               <el-button size="small" link type="primary" @click="clearRangeDraft(row)">
                 清空
@@ -1298,6 +1382,14 @@ function clearRangeDraft(row: RangeSettingDraft) {
   color: #334155;
 }
 
+.board-settings-line-name {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .chart-main {
   display: grid;
   grid-template-columns: auto minmax(0, 1fr);

+ 88 - 13
src/views/oli-connection/monitoring/detail.vue

@@ -918,6 +918,8 @@ function openRangeDialog(targetName?: string) {
     color: item.color,
     minValue: normalizeRangeValue(item.minValue),
     maxValue: normalizeRangeValue(item.maxValue),
+    periodSecond: normalizeRangeValue(item.periodSecond),
+    changeValue: normalizeRangeValue(item.changeValue),
     axisMin: normalizeRangeValue(item.axisMin),
     axisMax: normalizeRangeValue(item.axisMax)
   }))
@@ -928,21 +930,34 @@ 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 && axisMin === undefined && axisMax === undefined) {
+  if (
+    min === undefined &&
+    max === undefined &&
+    periodSecond === undefined &&
+    changeValue === undefined &&
+    axisMin === undefined &&
+    axisMax === undefined
+  ) {
     if (item.id) {
       await IotDeviceApi.deleteMaxMin({ id: item.id })
     }
 
     item.minValue = undefined
     item.maxValue = undefined
+    item.periodSecond = undefined
+    item.changeValue = undefined
     item.axisMin = undefined
     item.axisMax = undefined
     item.id = undefined
@@ -961,6 +976,22 @@ async function saveRangeSetting(
     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 的整数秒`)
+  }
+
+  if (changeValue !== undefined && changeValue < 0) {
+    throw new Error(`${item.name} 的超限范围不能小于 0`)
+  }
+
   if ((axisMin === undefined) !== (axisMax === undefined)) {
     throw new Error(`${item.name} 的 Y 轴上限和 Y 轴下限需要同时填写`)
   }
@@ -972,6 +1003,8 @@ async function saveRangeSetting(
   const body = {
     minValue: min ?? null,
     maxValue: max ?? null,
+    periodSecond: periodSecond ?? null,
+    changeValue: changeValue ?? null,
     axisMin: axisMin ?? null,
     axisMax: axisMax ?? null,
     deviceId: query.id,
@@ -986,6 +1019,8 @@ async function saveRangeSetting(
   if (res.id) item.id = res.id
   item.minValue = min
   item.maxValue = max
+  item.periodSecond = periodSecond
+  item.changeValue = changeValue
   item.axisMin = axisMin
   item.axisMax = axisMax
 }
@@ -996,7 +1031,15 @@ async function handleRangeDialogSave() {
       const item = dimensions.value.find((dimension) => dimension.identifier === draft.identifier)
       if (!item) continue
 
-      await saveRangeSetting(item, draft.minValue, draft.maxValue, draft.axisMin, draft.axisMax)
+      await saveRangeSetting(
+        item,
+        draft.minValue,
+        draft.maxValue,
+        draft.periodSecond,
+        draft.changeValue,
+        draft.axisMin,
+        draft.axisMax
+      )
     }
 
     updateSeriesByNames(dimensions.value.map((item) => item.name))
@@ -1012,6 +1055,8 @@ function handleRangeDialogReset() {
     ...item,
     minValue: undefined,
     maxValue: undefined,
+    periodSecond: undefined,
+    changeValue: undefined,
     axisMin: undefined,
     axisMax: undefined
   }))
@@ -1020,6 +1065,8 @@ function handleRangeDialogReset() {
 function clearRangeDraft(row: RangeSettingDraft) {
   row.minValue = undefined
   row.maxValue = undefined
+  row.periodSecond = undefined
+  row.changeValue = undefined
   row.axisMin = undefined
   row.axisMax = undefined
 }
@@ -1229,22 +1276,29 @@ onUnmounted(() => {
 
     <el-dialog
       v-model="rangeDialogVisible"
-      title="告警阈值与 Y 轴范围"
-      width="1100px"
+      title="告警阈值、检查时间、超限范围与 Y 轴范围"
+      width="min(1140px, calc(100vw - 32px))"
       append-to-body
       destroy-on-close>
-      <ZmTable :data="rangeSettingDrafts" :loading="false" :max-height="520" :show-border="true">
-        <ZmTableColumn label="曲线" min-width="180">
+      <ZmTable
+        :data="rangeSettingDrafts"
+        :loading="false"
+        :max-height="520"
+        :show-border="true"
+        :fit="false">
+        <ZmTableColumn label="曲线" width="210" fixed="left" align="left">
           <template #default="{ row }">
             <div class="flex items-center gap-2 min-w-0">
               <span
                 class="inline-block size-2.5 rounded-full shrink-0"
                 :style="{ backgroundColor: row.color }"></span>
-              <span class="truncate font-medium text-slate-700">{{ row.name }}</span>
+              <span class="min-w-0 flex-1 truncate font-medium text-slate-700" :title="row.name">
+                {{ row.name }}
+              </span>
             </div>
           </template>
         </ZmTableColumn>
-        <ZmTableColumn label="告警下限" width="140">
+        <ZmTableColumn label="告警下限" width="115">
           <template #default="{ row }">
             <el-input-number
               v-model="row.minValue"
@@ -1253,7 +1307,7 @@ onUnmounted(() => {
               placeholder="未设置" />
           </template>
         </ZmTableColumn>
-        <ZmTableColumn label="告警上限" width="140">
+        <ZmTableColumn label="告警上限" width="115">
           <template #default="{ row }">
             <el-input-number
               v-model="row.maxValue"
@@ -1262,7 +1316,28 @@ onUnmounted(() => {
               placeholder="未设置" />
           </template>
         </ZmTableColumn>
-        <ZmTableColumn label="Y 轴下限" width="140">
+        <ZmTableColumn label="检查时间(秒)" width="125">
+          <template #default="{ row }">
+            <el-input-number
+              v-model="row.periodSecond"
+              class="!w-full"
+              :controls="false"
+              :min="1"
+              :precision="0"
+              placeholder="仅当前值" />
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn label="超限范围" width="115">
+          <template #default="{ row }">
+            <el-input-number
+              v-model="row.changeValue"
+              class="!w-full"
+              :controls="false"
+              :min="0"
+              placeholder="0" />
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn label="Y 轴下限" width="110">
           <template #default="{ row }">
             <el-input-number
               v-model="row.axisMin"
@@ -1271,7 +1346,7 @@ onUnmounted(() => {
               :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
           </template>
         </ZmTableColumn>
-        <ZmTableColumn label="Y 轴上限" width="140">
+        <ZmTableColumn label="Y 轴上限" width="110">
           <template #default="{ row }">
             <el-input-number
               v-model="row.axisMax"
@@ -1280,7 +1355,7 @@ onUnmounted(() => {
               :placeholder="formatChartValue(getSeriesRange(row.name).labelMax)" />
           </template>
         </ZmTableColumn>
-        <ZmTableColumn label="当前 Y 轴范围" width="150">
+        <ZmTableColumn label="当前 Y 轴范围" width="120">
           <template #default="{ row }">
             <span class="text-xs text-slate-500">
               {{ formatChartValue(getSeriesRange(row.name).labelMin) }}
@@ -1289,7 +1364,7 @@ onUnmounted(() => {
             </span>
           </template>
         </ZmTableColumn>
-        <ZmTableColumn label="操作" width="90">
+        <ZmTableColumn label="操作" width="66">
           <template #default="{ row }">
             <el-button size="small" link type="primary" @click="clearRangeDraft(row)">
               清空

+ 119 - 27
src/views/pms/iotrhdailyreport/components/DailyStatistics.vue

@@ -261,21 +261,29 @@ const getList = useDebounceFn(async () => {
   }
 }, 500)
 
-const tab = ref<'表格' | '看板'>('表格')
+type ViewTab = '表格' | '日看板' | '月看板'
+type ChartTab = Exclude<ViewTab, '表格'>
 
-const currentTab = ref<'表格' | '看板'>('表格')
+const tab = ref<ViewTab>('表格')
+
+const currentTab = ref<ViewTab>('表格')
 
 const direction = ref<'left' | 'right'>('right')
 
-const handleSelectTab = (val: '表格' | '看板') => {
+const isChartTab = (val: ViewTab): val is ChartTab => val !== '表格'
+
+const handleSelectTab = (val: ViewTab) => {
   if (tab.value === val && currentTab.value === val) return
 
   tab.value = val
-  direction.value = val === '看板' ? 'right' : 'left'
+  direction.value = val === '表格' ? 'left' : 'right'
   nextTick(() => {
     currentTab.value = val
-    setTimeout(() => {
-      if (val === '看板') scheduleChartRender()
+    if (!isChartTab(val)) return
+
+    selectedLegends.value = { ...legendSelections[val] }
+    getChart(val).then(() => {
+      scheduleChartRender()
     })
   })
 }
@@ -283,7 +291,7 @@ const handleSelectTab = (val: '表格' | '看板') => {
 watch(
   () => props.defaultView,
   (val) => {
-    handleSelectTab(val || '表格')
+    handleSelectTab(val === '看板' ? '日看板' : '表格')
   },
   { immediate: true }
 )
@@ -300,7 +308,7 @@ type ChartKey =
   | 'cumulativeGasInjection'
   | 'cumulativePowerConsumption'
   | 'cumulativeWaterInjection'
-  | 'transitTime'
+  | 'hourUtilizationRate'
   | 'utilizationRate'
 
 interface LegendItem {
@@ -315,10 +323,25 @@ const legendItems: LegendItem[] = [
   { name: '累计注气量 (万方)', key: 'cumulativeGasInjection', unit: '万方', decimals: 2 },
   { name: '累计用电量 (KWh)', key: 'cumulativePowerConsumption', unit: 'KWh', decimals: 2 },
   { name: '累计注水量 (方)', key: 'cumulativeWaterInjection', unit: '方', decimals: 2 },
-  { name: '平均时效 (%)', key: 'transitTime', unit: '%', decimals: 2 },
+  { name: '平均时效 (%)', key: 'hourUtilizationRate', unit: '%', decimals: 2 },
   { name: '设备利用率 (%)', key: 'utilizationRate', unit: '%', decimals: 2 }
 ]
 
+const monthDefaultLegendNames = new Set(['累计注气量 (万方)', '平均时效 (%)', '设备利用率 (%)'])
+
+const createLegendSelection = (chartTab: ChartTab) =>
+  Object.fromEntries(
+    legendItems.map(({ name }) => [
+      name,
+      chartTab === '日看板' || monthDefaultLegendNames.has(name)
+    ])
+  )
+
+const legendSelections: Record<ChartTab, Record<string, boolean>> = {
+  日看板: createLegendSelection('日看板'),
+  月看板: createLegendSelection('月看板')
+}
+
 const legendItemMap = legendItems.reduce<Record<string, LegendItem>>((map, item) => {
   map[item.name] = item
   return map
@@ -329,17 +352,25 @@ const chartData = ref<Record<ChartKey, number[]>>({
   cumulativeGasInjection: [],
   cumulativePowerConsumption: [],
   cumulativeWaterInjection: [],
-  transitTime: [],
+  hourUtilizationRate: [],
   utilizationRate: []
 })
 
-let chartLoading = ref(false)
+const chartLoading = ref(false)
+let chartRequestId = 0
 
-const getChart = useDebounceFn(async () => {
+const getChart = useDebounceFn(async (chartTab: ChartTab = '日看板') => {
+  const requestId = ++chartRequestId
   chartLoading.value = true
 
   try {
-    const res = await IotRhDailyReportApi.getIotRhDailyReportSummaryPolyline(props.query)
+    const getPolyline =
+      chartTab === '月看板'
+        ? IotRhDailyReportApi.getIotRhDailyReportSummaryMonthPolyline
+        : IotRhDailyReportApi.getIotRhDailyReportSummaryPolyline
+    const res = await getPolyline(props.query)
+
+    if (requestId !== chartRequestId) return
 
     chartData.value = {
       cumulativeFuelConsumption: res.map((item) => item.cumulativeFuelConsumption || 0),
@@ -349,14 +380,16 @@ const getChart = useDebounceFn(async () => {
       // cumulativePowerConsumption: res.map((item) => (item.cumulativePowerConsumption || 0) / 1000),
       cumulativeWaterInjection: res.map((item) => item.cumulativeWaterInjection || 0),
       // cumulativeWaterInjection: res.map((item) => (item.cumulativeWaterInjection || 0) / 10000),
-      transitTime: res.map((item) => (item.transitTime || 0) * 100),
+      hourUtilizationRate: res.map((item) => (item.hourUtilizationRate || 0) * 100),
       utilizationRate: res.map((item) => Number(((item.utilizationRate || 0) * 100).toFixed(2)))
     }
 
     xAxisData.value = res.map((item) => item.reportDate || '')
     resetVisibleZoomRange()
   } finally {
-    chartLoading.value = false
+    if (requestId === chartRequestId) {
+      chartLoading.value = false
+    }
   }
 }, 500)
 
@@ -364,7 +397,7 @@ const resizer = useDebounceFn(() => {
   chart?.resize()
 }, 100)
 
-const selectedLegends = ref<Record<string, boolean>>({})
+const selectedLegends = ref<Record<string, boolean>>({ ...legendSelections['日看板'] })
 const visibleZoomRange = ref({
   startIndex: 0,
   endIndex: 0
@@ -376,9 +409,11 @@ const NORMALIZED_AXIS_CENTER = 50
 const NORMALIZED_AXIS_PADDING = 4
 
 const ensureLegendSelection = () => {
+  const chartTab = isChartTab(currentTab.value) ? currentTab.value : '日看板'
+
   legendItems.forEach(({ name }) => {
     if (selectedLegends.value[name] === undefined) {
-      selectedLegends.value[name] = true
+      selectedLegends.value[name] = legendSelections[chartTab][name]
     }
   })
 }
@@ -484,8 +519,27 @@ const formatSeriesValue = (name: string, value: number) => {
   return `${safeValue.toFixed(item.decimals)} ${item.unit}`
 }
 
+const MONTH_LABEL_GAP = 18
+
+const getMonthLabelOffset = (name: string): [number, number] => {
+  const visibleLegendNames = legendItems
+    .filter((item) => selectedLegends.value[item.name] !== false)
+    .map((item) => item.name)
+  const visibleIndex = visibleLegendNames.indexOf(name)
+
+  if (visibleIndex < 0) return [0, 0]
+
+  return [0, (visibleIndex - (visibleLegendNames.length - 1) / 2) * MONTH_LABEL_GAP]
+}
+
+const formatPointLabelValue = (item: LegendItem, dataIndex: number) => {
+  const value = chartData.value[item.key][dataIndex] ?? 0
+  return `${value.toFixed(item.decimals)}${item.unit}`
+}
+
 const getSeries = () => {
   const enableSampling = xAxisData.value.length > 120
+  const showPointLabel = currentTab.value === '月看板'
 
   return legendItems.map((item) => ({
     name: item.name,
@@ -501,6 +555,23 @@ const getSeries = () => {
     emphasis: {
       focus: 'series'
     },
+    label: {
+      show: showPointLabel,
+      position: 'top',
+      distance: 8,
+      formatter: (params: any) => formatPointLabelValue(item, params.dataIndex),
+      fontSize: 13,
+      backgroundColor: 'rgba(255, 255, 255, 0.82)',
+      borderRadius: 2,
+      padding: [2, 4]
+    },
+    labelLayout: showPointLabel
+      ? {
+          dy: getMonthLabelOffset(item.name)[1],
+          moveOverlap: 'shiftY',
+          hideOverlap: false
+        }
+      : undefined,
     sampling: enableSampling ? 'lttb' : undefined,
     progressive: 300,
     progressiveThreshold: 1500,
@@ -613,6 +684,17 @@ const initChart = () => {
   chart.off('legendselectchanged')
   chart.on('legendselectchanged', (params: any) => {
     selectedLegends.value = { ...params.selected }
+
+    if (isChartTab(currentTab.value)) {
+      legendSelections[currentTab.value] = { ...params.selected }
+    }
+
+    chart?.setOption(
+      {
+        series: getSeries()
+      },
+      { lazyUpdate: true }
+    )
   })
 
   chart.off('datazoom')
@@ -643,7 +725,7 @@ const scheduleChartRender = (attempt = 0) => {
   chartRenderRaf = requestAnimationFrame(() => {
     chartRenderRaf = 0
 
-    if (currentTab.value !== '看板') return
+    if (!isChartTab(currentTab.value)) return
 
     const chartEl = chartRef.value
 
@@ -668,8 +750,10 @@ onUnmounted(() => {
 })
 
 const handleQuery = () => {
-  getChart().then(() => {
-    if (currentTab.value === '看板') {
+  const chartTab = currentTab.value === '月看板' ? '月看板' : '日看板'
+
+  getChart(chartTab).then(() => {
+    if (isChartTab(currentTab.value)) {
       scheduleChartRender()
     }
   })
@@ -719,7 +803,7 @@ const exportChart = () => {
     let event = new MouseEvent('click')
 
     a.href = dataURL
-    a.download = `瑞恒日报统计数据.png`
+    a.download = `瑞恒${currentTab.value}统计数据.png`
     a.dispatchEvent(event)
   }
 }
@@ -731,7 +815,7 @@ const exportData = async () => {
 }
 
 const exportAll = async () => {
-  if (tab.value === '看板') exportChart()
+  if (isChartTab(tab.value)) exportChart()
   else exportData()
 }
 
@@ -769,8 +853,10 @@ const { ZmTable, ZmTableColumn } = useTableComponents()
 </script>
 
 <template>
-  <div class="grid grid-rows-[96px_1fr] gap-2 h-full min-h-0">
-    <div class="grid grid-cols-8 gap-4">
+  <div
+    class="grid gap-2 h-full min-h-0"
+    :class="tab === '月看板' ? 'grid-rows-[1fr]' : 'grid-rows-[96px_1fr]'">
+    <div v-if="tab !== '月看板'" class="grid grid-cols-8 gap-4">
       <template v-for="info in totalWorkKeys" :key="info[0]">
         <el-tooltip :disabled="info[0] !== 'totalGasInjection'" placement="top">
           <template #content>
@@ -821,9 +907,15 @@ const { ZmTable, ZmTableColumn } = useTableComponents()
           </el-button>
           <el-button
             size="default"
-            :type="tab === '看板' ? 'primary' : 'default'"
-            @click="handleSelectTab('看板')"
-            >看板
+            :type="tab === '日看板' ? 'primary' : 'default'"
+            @click="handleSelectTab('日看板')"
+            >日看板
+          </el-button>
+          <el-button
+            size="default"
+            :type="tab === '月看板' ? 'primary' : 'default'"
+            @click="handleSelectTab('月看板')"
+            >月看板
           </el-button>
         </el-button-group>
         <h3 class="text-xl font-medium">{{ `${props.deptName}-${tab}` }}</h3>

+ 6 - 1
src/views/pms/video_center/haikang/index.vue

@@ -87,6 +87,11 @@ const splitOptions: Array<{ count: SplitCount; columns: HikvisionSplitColumns; i
   { count: 16, columns: 4, icon: grid16Icon }
 ]
 
+// 生产构建会把小 SVG 内联为带引号的 data: URL;CSS url() 不加引号会让整个 mask 声明无效。
+function toSplitIconUrl(icon: string) {
+  return `url("${icon}")`
+}
+
 /**
  * 八方向控制盘的展示配置与接口命令映射。
  * gridArea 将八个按钮固定在 3×3 网格四周,中间单元格留给不可点击的云台标识。
@@ -2002,7 +2007,7 @@ onBeforeUnmount(() => {
                   @click="handleSplitChange(option.count, option.columns)">
                   <span
                     class="split-menu-icon"
-                    :style="{ '--split-icon': `url(${option.icon})` }"
+                    :style="{ '--split-icon': toSplitIconUrl(option.icon) }"
                     aria-hidden="true"></span>
                 </button>
               </div>

+ 9 - 0
src/views/video-center/index.vue

@@ -0,0 +1,9 @@
+<template>
+  <HaiKang />
+</template>
+
+<script setup lang="ts">
+import HaiKang from '@/views/pms/video_center/haikang/index.vue'
+
+defineOptions({ name: 'VideoCenter' })
+</script>

+ 42 - 0
tests/hikvisionSplitIcons.test.mjs

@@ -0,0 +1,42 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import test from 'node:test'
+
+const icons = [
+  ['grid-one', 1],
+  ['grid-four', 2],
+  ['grid-nine', 3],
+  ['grid-16', 4]
+]
+
+const page = readFileSync(
+  new URL('../src/views/pms/video_center/haikang/index.vue', import.meta.url),
+  'utf8'
+)
+assert.match(page, /mask:\s*var\(--split-icon\)/, 'the split icons are rendered as CSS alpha masks')
+test('the SVG data URL is quoted before becoming a CSS mask URL', () => {
+  const splitStyle = page.split('\n').find((line) => line.includes("'--split-icon':")) ?? ''
+  assert.match(splitStyle, /'--split-icon':\s*toSplitIconUrl\(option\.icon\)/)
+  assert.match(
+    page,
+    /function toSplitIconUrl\(icon: string\)\s*\{\s*return `url\("\$\{icon\}"\)`/,
+    'unquoted Vite SVG data URLs contain quotes and invalidate the mask declaration'
+  )
+})
+
+for (const [iconName, columns] of icons) {
+  test(`${iconName} draws ${columns}×${columns} hollow cells instead of solid blocks`, () => {
+    const iconPath = new URL(`../src/assets/icons/${iconName}.svg`, import.meta.url)
+    const svg = readFileSync(fileURLToPath(iconPath), 'utf8')
+    const frame = svg.match(/<rect\b[^>]*>/g) ?? []
+    const dividers = svg.match(/<line\b[^>]*>/g) ?? []
+
+    assert.equal(frame.length, 1, 'the grid needs one outlined outer frame')
+    assert.match(frame[0], /\bfill="none"/, 'the panel interiors must remain transparent')
+    assert.match(frame[0], /\bstroke="currentColor"/, 'the frame must be visible in the mask')
+    assert.equal(dividers.length, 2 * (columns - 1), 'each row and column needs a divider')
+    for (const divider of dividers) assert.match(divider, /\bstroke="currentColor"/)
+    assert.doesNotMatch(svg, /<path\b/, 'filled paths turn the mask back into solid tiles')
+  })
+}

+ 80 - 0
tests/thresholdAlarmWindow.test.mjs

@@ -0,0 +1,80 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import { getWindowThresholdAlarm } 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('第 15 秒的越限点不属于第 25 秒的 10 秒窗口', () => {
+  const points = [
+    { ts: second(15), value: 1101 },
+    { ts: second(25), value: 800 }
+  ]
+
+  assert.equal(getWindowThresholdAlarm(points, second(25), 10, 500, 1000, 100), undefined)
+})
+
+test('窗口随当前时间右移,旧越限点退出后告警解除', () => {
+  const points = [
+    { ts: second(16), value: 1101 },
+    { ts: second(25), value: 800 },
+    { ts: second(26), value: 800 }
+  ]
+
+  assert.equal(getWindowThresholdAlarm(points, second(26), 10, 500, 1000, 100), undefined)
+})
+
+test('下限按阈值减去范围检查,优先展示最近一次越限点', () => {
+  const points = [
+    { ts: second(16), value: 1101 },
+    { ts: second(22), value: 399 },
+    { ts: second(25), value: 800 }
+  ]
+
+  assert.deepEqual(getWindowThresholdAlarm(points, second(25), 10, 500, 1000, 100), {
+    value: 399,
+    threshold: 400,
+    baseThreshold: 500,
+    direction: 'low'
+  })
+})
+
+test('范围边界相等时不触发', () => {
+  const points = [
+    { ts: second(24), value: 1100 },
+    { ts: second(25), value: 400 }
+  ]
+
+  assert.equal(getWindowThresholdAlarm(points, second(25), 10, 500, 1000, 100), undefined)
+})
+
+test('乱序数据中晚于当前时间的点不参与检查', () => {
+  const points = [
+    { ts: second(25), value: 800 },
+    { ts: second(26), value: 1101 }
+  ]
+
+  assert.equal(getWindowThresholdAlarm(points, second(25), 10, 500, 1000, 100), undefined)
+})
+
+test('未设置窗口时只检查当前点,保留原有立即告警语义', () => {
+  const points = [
+    { ts: second(24), value: 1001 },
+    { ts: second(25), value: 800 }
+  ]
+
+  assert.equal(getWindowThresholdAlarm(points, second(25), 0, 500, 1000), undefined)
+})