|
@@ -4,6 +4,8 @@ import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
|
|
|
import { useTableComponents } from '@/components/ZmTable/useTableComponents'
|
|
import { useTableComponents } from '@/components/ZmTable/useTableComponents'
|
|
|
import { useAlarmSound } from '@/utils/useAlarmSound'
|
|
import { useAlarmSound } from '@/utils/useAlarmSound'
|
|
|
import { useMqtt } from '@/utils/useMqtt'
|
|
import { useMqtt } from '@/utils/useMqtt'
|
|
|
|
|
+import { getWindowChangeAlarm } from '@/utils/thresholdAlarmWindow'
|
|
|
|
|
+import { createThresholdAlarmNoticeTracker } from '@/utils/thresholdAlarmNoticeTracker'
|
|
|
import {
|
|
import {
|
|
|
Dimensions,
|
|
Dimensions,
|
|
|
formatChartValue,
|
|
formatChartValue,
|
|
@@ -87,29 +89,56 @@ const message = useMessage()
|
|
|
const { setAlarmActive } = useAlarmSound()
|
|
const { setAlarmActive } = useAlarmSound()
|
|
|
|
|
|
|
|
interface ThresholdAlarm {
|
|
interface ThresholdAlarm {
|
|
|
|
|
+ alarmAt: number
|
|
|
|
|
+ alarmKey: string
|
|
|
|
|
+ fingerprint: string
|
|
|
key: string
|
|
key: string
|
|
|
name: string
|
|
name: string
|
|
|
- value: number
|
|
|
|
|
- threshold: number
|
|
|
|
|
- direction: 'high' | 'low'
|
|
|
|
|
|
|
+ change: number
|
|
|
|
|
+ direction: 'increase' | 'decrease'
|
|
|
|
|
+ fromValue: number
|
|
|
|
|
+ minValue: number
|
|
|
|
|
+ maxValue: number
|
|
|
|
|
+ toValue: number
|
|
|
|
|
+ duration: number
|
|
|
|
|
+ range: number
|
|
|
suffix?: string
|
|
suffix?: string
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const thresholdAlarms = shallowRef<Record<string, ThresholdAlarm>>({})
|
|
const thresholdAlarms = shallowRef<Record<string, ThresholdAlarm>>({})
|
|
|
const activeThresholdAlarms = computed(() => Object.values(thresholdAlarms.value))
|
|
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) {
|
|
function getThresholdAlarmText(alarm: ThresholdAlarm) {
|
|
|
- const directionText = alarm.direction === 'high' ? '高于上限' : '低于下限'
|
|
|
|
|
const suffix = alarm.suffix || ''
|
|
const suffix = alarm.suffix || ''
|
|
|
|
|
+ const directionText = alarm.direction === 'increase' ? '增加' : '减少'
|
|
|
|
|
|
|
|
- return `${alarm.name}:当前 ${formatChartValue(alarm.value)}${suffix},${directionText} ${formatChartValue(alarm.threshold)}${suffix}`
|
|
|
|
|
|
|
+ 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 = {}
|
|
thresholdAlarms.value = {}
|
|
|
|
|
+ thresholdAlarmNoticeTracker.clear()
|
|
|
setAlarmActive(false)
|
|
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 currentOtherName = ref(props.otherName)
|
|
|
|
|
|
|
|
const displayDeviceName = computed(() => currentOtherName.value || props.deviceName)
|
|
const displayDeviceName = computed(() => currentOtherName.value || props.deviceName)
|
|
@@ -125,6 +154,10 @@ const { client, connect, destroy, isConnected, subscribe } = useMqtt()
|
|
|
|
|
|
|
|
const REALTIME_WINDOW_MS = 10 * 60 * 1000
|
|
const REALTIME_WINDOW_MS = 10 * 60 * 1000
|
|
|
const REALTIME_RIGHT_BLANK_MS = 60 * 1000
|
|
const REALTIME_RIGHT_BLANK_MS = 60 * 1000
|
|
|
|
|
+const SUSPENSION_WEIGHT_IDENTIFIER = '1001'
|
|
|
|
|
+const SUSPENSION_LIGHT_IDENTIFIER = '1008'
|
|
|
|
|
+const SUSPENSION_COMBINED_NAME = '悬重/悬轻'
|
|
|
|
|
+const suspensionWeightAndLightEnabled = ref(false)
|
|
|
|
|
|
|
|
function getPointTimestamp(value: unknown) {
|
|
function getPointTimestamp(value: unknown) {
|
|
|
if (value === null || value === undefined || value === '') {
|
|
if (value === null || value === undefined || value === '') {
|
|
@@ -142,35 +175,88 @@ function getPointTimestamp(value: unknown) {
|
|
|
return Number.isFinite(parsedValue) ? parsedValue : Date.now()
|
|
return Number.isFinite(parsedValue) ? parsedValue : Date.now()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-const handleMessageUpdate = (_topic: string, data: any) => {
|
|
|
|
|
|
|
+const handleMessageUpdate = (topic: string, data: any) => {
|
|
|
|
|
+ console.log('[监控看板 MQTT 返回数据]', {
|
|
|
|
|
+ deviceCode: props.deviceCode,
|
|
|
|
|
+ topic,
|
|
|
|
|
+ receivedAt: dayjs().format('YYYY-MM-DD HH:mm:ss.SSS'),
|
|
|
|
|
+ data
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
const list = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : []
|
|
const list = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : []
|
|
|
const valueMap = new Map<string, number>()
|
|
const valueMap = new Map<string, number>()
|
|
|
|
|
+ const pointMap = new Map<string, ChartPoint>()
|
|
|
const updatedNames = new Set<string>()
|
|
const updatedNames = new Set<string>()
|
|
|
|
|
|
|
|
for (const item of list) {
|
|
for (const item of list) {
|
|
|
const identity = String(item.id ?? item.identity ?? '')
|
|
const identity = String(item.id ?? item.identity ?? '')
|
|
|
- const value = Number(item.value ?? item.logValue ?? 0)
|
|
|
|
|
|
|
+ // 【修复前】缺少 value/logValue 时会被转换成 0。
|
|
|
|
|
+ // 【修复后】缺少数值直接跳过,但真实上报的数值 0 仍然有效。
|
|
|
|
|
+ const rawValue = item.value ?? item.logValue
|
|
|
|
|
+ const value = Number(rawValue)
|
|
|
|
|
|
|
|
- if (!identity || !Number.isFinite(value)) continue
|
|
|
|
|
|
|
+ if (
|
|
|
|
|
+ !identity ||
|
|
|
|
|
+ rawValue === null ||
|
|
|
|
|
+ rawValue === undefined ||
|
|
|
|
|
+ rawValue === '' ||
|
|
|
|
|
+ !Number.isFinite(value)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
valueMap.set(identity, value)
|
|
valueMap.set(identity, value)
|
|
|
|
|
+ const point = {
|
|
|
|
|
+ ts: normalizeChartTimestamp(
|
|
|
|
|
+ getPointTimestamp(item.remark || item.readTime || item.ts || item.time)
|
|
|
|
|
+ ),
|
|
|
|
|
+ value
|
|
|
|
|
+ }
|
|
|
|
|
+ pointMap.set(identity, point)
|
|
|
|
|
+
|
|
|
|
|
+ if (
|
|
|
|
|
+ suspensionWeightAndLightEnabled.value &&
|
|
|
|
|
+ (identity === SUSPENSION_WEIGHT_IDENTIFIER || identity === SUSPENSION_LIGHT_IDENTIFIER)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
const modelName = dimensions.value.find((item) => item.identifier === identity)?.name
|
|
const modelName = dimensions.value.find((item) => item.identifier === identity)?.name
|
|
|
|
|
|
|
|
if (modelName && chartData.value[modelName]) {
|
|
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)
|
|
updatedNames.add(modelName)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ if (suspensionWeightAndLightEnabled.value) {
|
|
|
|
|
+ const combinedPoint = getCombinedSuspensionPoint(pointMap)
|
|
|
|
|
+
|
|
|
|
|
+ if (combinedPoint) {
|
|
|
|
|
+ valueMap.set(SUSPENSION_WEIGHT_IDENTIFIER, combinedPoint.value)
|
|
|
|
|
+ pointMap.set(SUSPENSION_WEIGHT_IDENTIFIER, combinedPoint)
|
|
|
|
|
+ pointMap.delete(SUSPENSION_LIGHT_IDENTIFIER)
|
|
|
|
|
+
|
|
|
|
|
+ const modelName = dimensions.value.find(
|
|
|
|
|
+ (item) => String(item.identifier) === SUSPENSION_WEIGHT_IDENTIFIER
|
|
|
|
|
+ )?.name
|
|
|
|
|
+
|
|
|
|
|
+ if (modelName && chartData.value[modelName]) {
|
|
|
|
|
+ appendChartPoint(modelName, combinedPoint)
|
|
|
|
|
+ updatedNames.add(modelName)
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 当前消息没有有效的悬重或悬轻数据时,不更新组合值。
|
|
|
|
|
+ valueMap.delete(SUSPENSION_WEIGHT_IDENTIFIER)
|
|
|
|
|
+ valueMap.delete(SUSPENSION_LIGHT_IDENTIFIER)
|
|
|
|
|
+ pointMap.delete(SUSPENSION_WEIGHT_IDENTIFIER)
|
|
|
|
|
+ pointMap.delete(SUSPENSION_LIGHT_IDENTIFIER)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
updateDimensionValues(valueMap)
|
|
updateDimensionValues(valueMap)
|
|
|
- updateThresholdAlarm(valueMap)
|
|
|
|
|
|
|
+ updateThresholdAlarm(pointMap)
|
|
|
|
|
|
|
|
if (!updatedNames.size) return
|
|
if (!updatedNames.size) return
|
|
|
|
|
|
|
@@ -203,19 +289,41 @@ watch(isConnected, (newVal) => {
|
|
|
subscribe(props.mqttUrl)
|
|
subscribe(props.mqttUrl)
|
|
|
// subscribe('/636/YF649/property/post')
|
|
// subscribe('/636/YF649/property/post')
|
|
|
} else {
|
|
} else {
|
|
|
- clearThresholdAlarms()
|
|
|
|
|
|
|
+ clearActiveThresholdAlarms()
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
async function loadDimensions() {
|
|
async function loadDimensions() {
|
|
|
if (!props.id) return
|
|
if (!props.id) return
|
|
|
try {
|
|
try {
|
|
|
- dimensions.value = (
|
|
|
|
|
|
|
+ const dimensionList = (
|
|
|
((await IotDeviceApi.getIotDeviceTds(Number(props.id))) as IotTdItem[]) ?? []
|
|
((await IotDeviceApi.getIotDeviceTds(Number(props.id))) as IotTdItem[]) ?? []
|
|
|
|
|
+ ).map((item) => ({ ...item, identifier: String(item.identifier).trim() }))
|
|
|
|
|
+ const identifiers = new Set(dimensionList.map((item) => item.identifier))
|
|
|
|
|
+
|
|
|
|
|
+ // 以属性接口的完整列表决定是否合并,不能以某条 MQTT 消息是否包含悬轻来判断。
|
|
|
|
|
+ suspensionWeightAndLightEnabled.value =
|
|
|
|
|
+ identifiers.has(SUSPENSION_WEIGHT_IDENTIFIER) && identifiers.has(SUSPENSION_LIGHT_IDENTIFIER)
|
|
|
|
|
+
|
|
|
|
|
+ const weightDimension = dimensionList.find(
|
|
|
|
|
+ (item) => item.identifier === SUSPENSION_WEIGHT_IDENTIFIER
|
|
|
)
|
|
)
|
|
|
|
|
+
|
|
|
|
|
+ if (weightDimension) {
|
|
|
|
|
+ weightDimension.modelName = suspensionWeightAndLightEnabled.value
|
|
|
|
|
+ ? SUSPENSION_COMBINED_NAME
|
|
|
|
|
+ : '悬重'
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ dimensions.value = dimensionList
|
|
|
.sort((a, b) => (b.modelOrder ?? 0) - (a.modelOrder ?? 0))
|
|
.sort((a, b) => (b.modelOrder ?? 0) - (a.modelOrder ?? 0))
|
|
|
.map(mapToDimension)
|
|
.map(mapToDimension)
|
|
|
.filter((item) => item.isText === false)
|
|
.filter((item) => item.isText === false)
|
|
|
|
|
+ .filter(
|
|
|
|
|
+ (item) =>
|
|
|
|
|
+ !suspensionWeightAndLightEnabled.value ||
|
|
|
|
|
+ String(item.identifier) !== SUSPENSION_LIGHT_IDENTIFIER
|
|
|
|
|
+ )
|
|
|
.map(withDisplayStyle)
|
|
.map(withDisplayStyle)
|
|
|
|
|
|
|
|
selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, true]))
|
|
selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, true]))
|
|
@@ -235,6 +343,46 @@ interface ChartData {
|
|
|
|
|
|
|
|
const chartData = shallowRef<ChartData>({})
|
|
const chartData = shallowRef<ChartData>({})
|
|
|
|
|
|
|
|
|
|
+// 【修复前】每条 MQTT 消息只在当前 payload 内查找 1001(悬重)和 1008(悬轻)。
|
|
|
|
|
+// 如果设备把两个测点拆成两条消息上报,当前消息缺少的那一侧会被当成 0,
|
|
|
|
|
+// 组合曲线就会出现错误的 0 点。
|
|
|
|
|
+//
|
|
|
|
|
+// 保存两侧最近一次有效点。尚未收到另一侧时展示已收到的单侧数据,
|
|
|
|
|
+// 悬重为正向、悬轻为负向;两侧齐全后再计算差值,不丢弃有效的单侧上报。
|
|
|
|
|
+const latestSuspensionPoints = new Map<string, ChartPoint>()
|
|
|
|
|
+
|
|
|
|
|
+function normalizeChartTimestamp(timestamp: number) {
|
|
|
|
|
+ // 所有曲线使用相同的秒级坐标,避免组合曲线与其他曲线因毫秒差拆成两个 tooltip。
|
|
|
|
|
+ return Math.floor(timestamp / 1000) * 1000
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getCombinedSuspensionPoint(pointMap: Map<string, ChartPoint>) {
|
|
|
|
|
+ if (!pointMap.has(SUSPENSION_WEIGHT_IDENTIFIER) && !pointMap.has(SUSPENSION_LIGHT_IDENTIFIER)) {
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // MQTT 可能把悬重和悬轻拆成不同消息,因此先更新最近值缓存,再计算组合值。
|
|
|
|
|
+ for (const identifier of [SUSPENSION_WEIGHT_IDENTIFIER, SUSPENSION_LIGHT_IDENTIFIER]) {
|
|
|
|
|
+ const point = pointMap.get(identifier)
|
|
|
|
|
+ if (point) latestSuspensionPoints.set(identifier, point)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const weightPoint = latestSuspensionPoints.get(SUSPENSION_WEIGHT_IDENTIFIER)
|
|
|
|
|
+ const lightPoint = latestSuspensionPoints.get(SUSPENSION_LIGHT_IDENTIFIER)
|
|
|
|
|
+
|
|
|
|
|
+ if (!weightPoint && !lightPoint) return null
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ ts: normalizeChartTimestamp(Math.max(weightPoint?.ts ?? 0, lightPoint?.ts ?? 0)),
|
|
|
|
|
+ value:
|
|
|
|
|
+ weightPoint && lightPoint
|
|
|
|
|
+ ? weightPoint.value - lightPoint.value
|
|
|
|
|
+ : weightPoint
|
|
|
|
|
+ ? weightPoint.value
|
|
|
|
|
+ : -lightPoint!.value
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
const chartRef = ref<HTMLDivElement | null>(null)
|
|
const chartRef = ref<HTMLDivElement | null>(null)
|
|
|
let chart: echarts.ECharts | null = null
|
|
let chart: echarts.ECharts | null = null
|
|
|
|
|
|
|
@@ -249,7 +397,7 @@ watch(
|
|
|
if (!active) {
|
|
if (!active) {
|
|
|
destroy()
|
|
destroy()
|
|
|
cancelRealtimeChartUpdate()
|
|
cancelRealtimeChartUpdate()
|
|
|
- clearThresholdAlarms()
|
|
|
|
|
|
|
+ clearActiveThresholdAlarms()
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -280,7 +428,10 @@ function getSeriesRange(name: string) {
|
|
|
const configuredMax = normalizeRangeValue(dimension?.axisMax)
|
|
const configuredMax = normalizeRangeValue(dimension?.axisMax)
|
|
|
const configured =
|
|
const configured =
|
|
|
configuredMin !== undefined && configuredMax !== undefined && configuredMin < configuredMax
|
|
configuredMin !== undefined && configuredMax !== undefined && configuredMin < configuredMax
|
|
|
- const min = configured ? configuredMin : 0
|
|
|
|
|
|
|
+ const isSuspensionCurve =
|
|
|
|
|
+ suspensionWeightAndLightEnabled.value &&
|
|
|
|
|
+ String(dimension?.identifier) === SUSPENSION_WEIGHT_IDENTIFIER
|
|
|
|
|
+ const min = configured ? configuredMin : isSuspensionCurve ? -100 : 0
|
|
|
const max = configured ? configuredMax : 100
|
|
const max = configured ? configuredMax : 100
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
@@ -459,40 +610,77 @@ function updateDimensionValues(valueMap: Map<string, number>) {
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function updateThresholdAlarm(valueMap: Map<string, number>) {
|
|
|
|
|
|
|
+function updateThresholdAlarm(pointMap: Map<string, ChartPoint>) {
|
|
|
const nextAlarms = { ...thresholdAlarms.value }
|
|
const nextAlarms = { ...thresholdAlarms.value }
|
|
|
|
|
+ const nextNotices = { ...thresholdAlarmNotices.value }
|
|
|
|
|
|
|
|
dimensions.value.forEach((item) => {
|
|
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 alarmKey = `${item.identifier}|${item.name}`
|
|
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 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 = getWindowChangeAlarm(series, anchorTs, duration, range)
|
|
|
|
|
+
|
|
|
|
|
+ if (!alarm) {
|
|
|
delete nextAlarms[alarmKey]
|
|
delete nextAlarms[alarmKey]
|
|
|
|
|
+ thresholdAlarmNoticeTracker.pass(alarmKey)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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,
|
|
|
|
|
+ duration,
|
|
|
|
|
+ range,
|
|
|
|
|
+ suffix: item.suffix
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ nextAlarms[alarmKey] = activeAlarm
|
|
|
|
|
+
|
|
|
|
|
+ if (thresholdAlarmNoticeTracker.shouldCreate(alarmKey, fingerprint)) {
|
|
|
|
|
+ const noticeKey = `${alarmKey}|${anchorTs}|${++thresholdAlarmNoticeSequence}`
|
|
|
|
|
+
|
|
|
|
|
+ // 告警状态可能已经恢复过,追踪器会清理当前键,但历史提示仍需保留到手动关闭。
|
|
|
|
|
+ // 新一轮告警创建时,先清理同一曲线的历史提示,确保每条曲线只展示最新一条。
|
|
|
|
|
+ Object.values(nextNotices).forEach((notice) => {
|
|
|
|
|
+ if (notice.alarmKey === alarmKey) delete nextNotices[notice.key]
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ nextNotices[noticeKey] = {
|
|
|
|
|
+ ...activeAlarm,
|
|
|
|
|
+ alarmAt: anchorTs,
|
|
|
|
|
+ key: noticeKey
|
|
|
|
|
+ }
|
|
|
|
|
+ const previousNoticeKey = thresholdAlarmNoticeTracker.record(alarmKey, noticeKey, fingerprint)
|
|
|
|
|
+ if (previousNoticeKey) delete nextNotices[previousNoticeKey]
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
thresholdAlarms.value = nextAlarms
|
|
thresholdAlarms.value = nextAlarms
|
|
|
|
|
+ thresholdAlarmNotices.value = nextNotices
|
|
|
setAlarmActive(activeThresholdAlarms.value.length > 0)
|
|
setAlarmActive(activeThresholdAlarms.value.length > 0)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -520,6 +708,7 @@ function updateSeriesByNames(names: string[]) {
|
|
|
series: names.map((name) => ({
|
|
series: names.map((name) => ({
|
|
|
id: name,
|
|
id: name,
|
|
|
name,
|
|
name,
|
|
|
|
|
+ showSymbol: chartData.value[name]?.length === 1,
|
|
|
data: buildSeriesData(name)
|
|
data: buildSeriesData(name)
|
|
|
}))
|
|
}))
|
|
|
},
|
|
},
|
|
@@ -555,6 +744,7 @@ function flushRealtimeChartUpdate() {
|
|
|
series: names.map((name) => ({
|
|
series: names.map((name) => ({
|
|
|
id: name,
|
|
id: name,
|
|
|
name,
|
|
name,
|
|
|
|
|
+ showSymbol: chartData.value[name]?.length === 1,
|
|
|
data: buildSeriesData(name)
|
|
data: buildSeriesData(name)
|
|
|
}))
|
|
}))
|
|
|
},
|
|
},
|
|
@@ -738,7 +928,7 @@ function render() {
|
|
|
type: 'line',
|
|
type: 'line',
|
|
|
sampling: 'lttb',
|
|
sampling: 'lttb',
|
|
|
smooth: 0.24,
|
|
smooth: 0.24,
|
|
|
- showSymbol: false,
|
|
|
|
|
|
|
+ showSymbol: chartData.value[item.name]?.length === 1,
|
|
|
connectNulls: true,
|
|
connectNulls: true,
|
|
|
endLabel: {
|
|
endLabel: {
|
|
|
show: true,
|
|
show: true,
|
|
@@ -770,12 +960,70 @@ function render() {
|
|
|
|
|
|
|
|
const chartLoading = ref(false)
|
|
const chartLoading = ref(false)
|
|
|
|
|
|
|
|
|
|
+function normalizeHistoryPoints(data: unknown): ChartPoint[] {
|
|
|
|
|
+ if (!Array.isArray(data)) return []
|
|
|
|
|
+
|
|
|
|
|
+ const points = data
|
|
|
|
|
+ .filter(
|
|
|
|
|
+ (item) => item.value !== null && item.value !== undefined && String(item.value).trim() !== ''
|
|
|
|
|
+ )
|
|
|
|
|
+ .map((item) => ({
|
|
|
|
|
+ ts: Number(item.ts),
|
|
|
|
|
+ value: Number(item.value)
|
|
|
|
|
+ }))
|
|
|
|
|
+ .filter((item) => Number.isFinite(item.ts) && Number.isFinite(item.value))
|
|
|
|
|
+ .sort((a, b) => a.ts - b.ts)
|
|
|
|
|
+
|
|
|
|
|
+ // 同一秒保留最后采样值,历史和实时均使用同一套坐标精度。
|
|
|
|
|
+ const pointsBySecond = new Map<number, ChartPoint>()
|
|
|
|
|
+ points.forEach((point) => {
|
|
|
|
|
+ const ts = normalizeChartTimestamp(point.ts)
|
|
|
|
|
+ pointsBySecond.set(ts, { ts, value: point.value })
|
|
|
|
|
+ })
|
|
|
|
|
+ return Array.from(pointsBySecond.values())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function mergeSuspensionHistory(weightPoints: ChartPoint[], lightPoints: ChartPoint[]) {
|
|
|
|
|
+ const mergedPoints = new Map<number, { weight?: number; light?: number }>()
|
|
|
|
|
+
|
|
|
|
|
+ weightPoints.forEach((point) => {
|
|
|
|
|
+ const timestamp = normalizeChartTimestamp(point.ts)
|
|
|
|
|
+ mergedPoints.set(timestamp, { ...mergedPoints.get(timestamp), weight: point.value })
|
|
|
|
|
+ })
|
|
|
|
|
+ lightPoints.forEach((point) => {
|
|
|
|
|
+ const timestamp = normalizeChartTimestamp(point.ts)
|
|
|
|
|
+ mergedPoints.set(timestamp, { ...mergedPoints.get(timestamp), light: point.value })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ let latestWeight: number | undefined
|
|
|
|
|
+ let latestLight: number | undefined
|
|
|
|
|
+
|
|
|
|
|
+ return Array.from(mergedPoints)
|
|
|
|
|
+ .sort(([a], [b]) => a - b)
|
|
|
|
|
+ .map(([ts, value]) => {
|
|
|
|
|
+ // 按时间保留最近值,允许两侧分开上报;尚未收到另一侧时显示单侧值。
|
|
|
|
|
+ if (value.weight !== undefined) latestWeight = value.weight
|
|
|
|
|
+ if (value.light !== undefined) latestLight = value.light
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ ts,
|
|
|
|
|
+ value:
|
|
|
|
|
+ latestWeight !== undefined && latestLight !== undefined
|
|
|
|
|
+ ? latestWeight - latestLight
|
|
|
|
|
+ : latestWeight !== undefined
|
|
|
|
|
+ ? latestWeight
|
|
|
|
|
+ : -latestLight!
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
async function initLoadChartData(real_time: boolean = true) {
|
|
async function initLoadChartData(real_time: boolean = true) {
|
|
|
if (!dimensions.value.length) return
|
|
if (!dimensions.value.length) return
|
|
|
|
|
|
|
|
const loadVersion = ++chartLoadVersion
|
|
const loadVersion = ++chartLoadVersion
|
|
|
chartRealtimeMode.value = real_time
|
|
chartRealtimeMode.value = real_time
|
|
|
chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
|
|
chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
|
|
|
|
|
+ latestSuspensionPoints.clear()
|
|
|
chartLoading.value = true
|
|
chartLoading.value = true
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
@@ -790,9 +1038,33 @@ async function initLoadChartData(real_time: boolean = true) {
|
|
|
props.date[1]
|
|
props.date[1]
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- chartData.value[item.name] = (((res as any[]) ?? []) as any[])
|
|
|
|
|
- .sort((a, b) => a.ts - b.ts)
|
|
|
|
|
- .map((item) => ({ ts: item.ts, value: item.value }))
|
|
|
|
|
|
|
+ if (
|
|
|
|
|
+ suspensionWeightAndLightEnabled.value &&
|
|
|
|
|
+ String(item.identifier) === SUSPENSION_WEIGHT_IDENTIFIER
|
|
|
|
|
+ ) {
|
|
|
|
|
+ const lightRes = await IotStatApi.getDeviceInfoChartly(
|
|
|
|
|
+ props.deviceCode,
|
|
|
|
|
+ SUSPENSION_LIGHT_IDENTIFIER,
|
|
|
|
|
+ props.date[0],
|
|
|
|
|
+ props.date[1]
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ const weightPoints = normalizeHistoryPoints(res)
|
|
|
|
|
+ const lightPoints = normalizeHistoryPoints(lightRes)
|
|
|
|
|
+ const latestWeightPoint = weightPoints[weightPoints.length - 1]
|
|
|
|
|
+ const latestLightPoint = lightPoints[lightPoints.length - 1]
|
|
|
|
|
+
|
|
|
|
|
+ if (latestWeightPoint) {
|
|
|
|
|
+ latestSuspensionPoints.set(SUSPENSION_WEIGHT_IDENTIFIER, latestWeightPoint)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (latestLightPoint) {
|
|
|
|
|
+ latestSuspensionPoints.set(SUSPENSION_LIGHT_IDENTIFIER, latestLightPoint)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ chartData.value[item.name] = mergeSuspensionHistory(weightPoints, lightPoints)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ chartData.value[item.name] = normalizeHistoryPoints(res)
|
|
|
|
|
+ }
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
console.error(error)
|
|
console.error(error)
|
|
|
} finally {
|
|
} finally {
|
|
@@ -862,6 +1134,7 @@ watch(
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
onUnmounted(() => {
|
|
|
chartLoadVersion += 1
|
|
chartLoadVersion += 1
|
|
|
|
|
+ latestSuspensionPoints.clear()
|
|
|
destroy()
|
|
destroy()
|
|
|
cancelRealtimeChartUpdate()
|
|
cancelRealtimeChartUpdate()
|
|
|
|
|
|
|
@@ -902,8 +1175,8 @@ function buildRangeSettingDrafts() {
|
|
|
name: item.name,
|
|
name: item.name,
|
|
|
identifier: item.identifier,
|
|
identifier: item.identifier,
|
|
|
color: item.color,
|
|
color: item.color,
|
|
|
- minValue: normalizeRangeValue(item.minValue),
|
|
|
|
|
- maxValue: normalizeRangeValue(item.maxValue),
|
|
|
|
|
|
|
+ periodSecond: normalizeRangeValue(item.periodSecond),
|
|
|
|
|
+ changeValue: normalizeRangeValue(item.changeValue),
|
|
|
axisMin: normalizeRangeValue(item.axisMin),
|
|
axisMin: normalizeRangeValue(item.axisMin),
|
|
|
axisMax: normalizeRangeValue(item.axisMax)
|
|
axisMax: normalizeRangeValue(item.axisMax)
|
|
|
}))
|
|
}))
|
|
@@ -940,39 +1213,46 @@ async function saveDeviceName() {
|
|
|
|
|
|
|
|
async function saveRangeSetting(
|
|
async function saveRangeSetting(
|
|
|
item: Dimensions,
|
|
item: Dimensions,
|
|
|
- minValue?: number,
|
|
|
|
|
- maxValue?: number,
|
|
|
|
|
|
|
+ periodSecondValue?: number,
|
|
|
|
|
+ changeValueValue?: number,
|
|
|
axisMinValue?: number,
|
|
axisMinValue?: number,
|
|
|
axisMaxValue?: number
|
|
axisMaxValue?: number
|
|
|
) {
|
|
) {
|
|
|
- const min = normalizeRangeValue(minValue)
|
|
|
|
|
- const max = normalizeRangeValue(maxValue)
|
|
|
|
|
|
|
+ const periodSecond = normalizeRangeValue(periodSecondValue)
|
|
|
|
|
+ const changeValue = normalizeRangeValue(changeValueValue)
|
|
|
const axisMin = normalizeRangeValue(axisMinValue)
|
|
const axisMin = normalizeRangeValue(axisMinValue)
|
|
|
const axisMax = normalizeRangeValue(axisMaxValue)
|
|
const axisMax = normalizeRangeValue(axisMaxValue)
|
|
|
|
|
|
|
|
- if (min === undefined && max === undefined && axisMin === undefined && axisMax === undefined) {
|
|
|
|
|
|
|
+ if (
|
|
|
|
|
+ periodSecond === undefined &&
|
|
|
|
|
+ changeValue === undefined &&
|
|
|
|
|
+ axisMin === undefined &&
|
|
|
|
|
+ axisMax === undefined
|
|
|
|
|
+ ) {
|
|
|
if (item.id) {
|
|
if (item.id) {
|
|
|
await IotDeviceApi.deleteMaxMin({ id: item.id })
|
|
await IotDeviceApi.deleteMaxMin({ id: item.id })
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
item.minValue = undefined
|
|
item.minValue = undefined
|
|
|
item.maxValue = undefined
|
|
item.maxValue = undefined
|
|
|
|
|
+ item.periodSecond = undefined
|
|
|
|
|
+ item.changeValue = undefined
|
|
|
item.axisMin = undefined
|
|
item.axisMin = undefined
|
|
|
item.axisMax = undefined
|
|
item.axisMax = undefined
|
|
|
item.id = undefined
|
|
item.id = undefined
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if ((min === undefined) !== (max === undefined)) {
|
|
|
|
|
- throw new Error(`${item.name} 的告警上限和告警下限需要同时填写`)
|
|
|
|
|
|
|
+ if ((periodSecond === undefined) !== (changeValue === undefined)) {
|
|
|
|
|
+ throw new Error(`${item.name} 的检查时间和超限范围需要同时填写`)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (min !== undefined && max !== undefined && min > max) {
|
|
|
|
|
- throw new Error(`${item.name} 的告警下限不能大于告警上限`)
|
|
|
|
|
|
|
+ if (periodSecond !== undefined && (!Number.isInteger(periodSecond) || periodSecond <= 0)) {
|
|
|
|
|
+ throw new Error(`${item.name} 的检查时间必须为大于 0 的整数秒`)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (min !== undefined && max !== undefined && min === max) {
|
|
|
|
|
- throw new Error(`${item.name} 的告警上限和告警下限不能相等`)
|
|
|
|
|
|
|
+ if (changeValue !== undefined && changeValue < 0) {
|
|
|
|
|
+ throw new Error(`${item.name} 的超限范围不能小于 0`)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if ((axisMin === undefined) !== (axisMax === undefined)) {
|
|
if ((axisMin === undefined) !== (axisMax === undefined)) {
|
|
@@ -984,8 +1264,10 @@ async function saveRangeSetting(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const body = {
|
|
const body = {
|
|
|
- minValue: min ?? null,
|
|
|
|
|
- maxValue: max ?? null,
|
|
|
|
|
|
|
+ minValue: null,
|
|
|
|
|
+ maxValue: null,
|
|
|
|
|
+ periodSecond: periodSecond ?? null,
|
|
|
|
|
+ changeValue: changeValue ?? null,
|
|
|
axisMin: axisMin ?? null,
|
|
axisMin: axisMin ?? null,
|
|
|
axisMax: axisMax ?? null,
|
|
axisMax: axisMax ?? null,
|
|
|
deviceId: props.id,
|
|
deviceId: props.id,
|
|
@@ -998,8 +1280,10 @@ async function saveRangeSetting(
|
|
|
const res = await IotDeviceApi.saveMaxMin(body)
|
|
const res = await IotDeviceApi.saveMaxMin(body)
|
|
|
|
|
|
|
|
if (res.id) item.id = res.id
|
|
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
|
|
item.axisMin = axisMin
|
|
|
item.axisMax = axisMax
|
|
item.axisMax = axisMax
|
|
|
}
|
|
}
|
|
@@ -1013,9 +1297,16 @@ async function handleSettingsSave() {
|
|
|
const item = dimensions.value.find((dimension) => dimension.identifier === draft.identifier)
|
|
const item = dimensions.value.find((dimension) => dimension.identifier === draft.identifier)
|
|
|
if (!item) continue
|
|
if (!item) continue
|
|
|
|
|
|
|
|
- await saveRangeSetting(item, draft.minValue, draft.maxValue, draft.axisMin, draft.axisMax)
|
|
|
|
|
|
|
+ await saveRangeSetting(
|
|
|
|
|
+ item,
|
|
|
|
|
+ draft.periodSecond,
|
|
|
|
|
+ draft.changeValue,
|
|
|
|
|
+ draft.axisMin,
|
|
|
|
|
+ draft.axisMax
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ clearActiveThresholdAlarms()
|
|
|
updateSeriesByNames(dimensions.value.map((item) => item.name))
|
|
updateSeriesByNames(dimensions.value.map((item) => item.name))
|
|
|
settingsDialogVisible.value = false
|
|
settingsDialogVisible.value = false
|
|
|
message.success('设置成功')
|
|
message.success('设置成功')
|
|
@@ -1029,16 +1320,16 @@ async function handleSettingsSave() {
|
|
|
function handleRangeDialogReset() {
|
|
function handleRangeDialogReset() {
|
|
|
rangeSettingDrafts.value = rangeSettingDrafts.value.map((item) => ({
|
|
rangeSettingDrafts.value = rangeSettingDrafts.value.map((item) => ({
|
|
|
...item,
|
|
...item,
|
|
|
- minValue: undefined,
|
|
|
|
|
- maxValue: undefined,
|
|
|
|
|
|
|
+ periodSecond: undefined,
|
|
|
|
|
+ changeValue: undefined,
|
|
|
axisMin: undefined,
|
|
axisMin: undefined,
|
|
|
axisMax: undefined
|
|
axisMax: undefined
|
|
|
}))
|
|
}))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function clearRangeDraft(row: RangeSettingDraft) {
|
|
function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
- row.minValue = undefined
|
|
|
|
|
- row.maxValue = undefined
|
|
|
|
|
|
|
+ row.periodSecond = undefined
|
|
|
|
|
+ row.changeValue = undefined
|
|
|
row.axisMin = undefined
|
|
row.axisMin = undefined
|
|
|
row.axisMax = undefined
|
|
row.axisMax = undefined
|
|
|
}
|
|
}
|
|
@@ -1049,9 +1340,9 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
<div class="flex items-center">
|
|
<div class="flex items-center">
|
|
|
<div class="title-icon"></div>
|
|
<div class="title-icon"></div>
|
|
|
<div>{{ `${props.deviceCode}-${displayDeviceName}` }}</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>
|
|
<i class="i-material-symbols:warning-rounded"></i>
|
|
|
- {{ activeThresholdAlarms.length }} 项超限
|
|
|
|
|
|
|
+ {{ activeThresholdAlarmNotices.length }} 条告警
|
|
|
</span>
|
|
</span>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="chart-header__actions">
|
|
<div class="chart-header__actions">
|
|
@@ -1090,16 +1381,23 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
|
|
|
|
|
<div class="chart-stage">
|
|
<div class="chart-stage">
|
|
|
<div
|
|
<div
|
|
|
- v-if="activeThresholdAlarms.length"
|
|
|
|
|
|
|
+ v-if="activeThresholdAlarmNotices.length"
|
|
|
class="chart-alarm-panel"
|
|
class="chart-alarm-panel"
|
|
|
role="alert"
|
|
role="alert"
|
|
|
aria-live="polite">
|
|
aria-live="polite">
|
|
|
<span
|
|
<span
|
|
|
- v-for="alarm in activeThresholdAlarms"
|
|
|
|
|
|
|
+ v-for="alarm in activeThresholdAlarmNotices"
|
|
|
:key="alarm.key"
|
|
:key="alarm.key"
|
|
|
class="chart-alarm-panel__item">
|
|
class="chart-alarm-panel__item">
|
|
|
<i class="i-material-symbols:warning-rounded"></i>
|
|
<i class="i-material-symbols:warning-rounded"></i>
|
|
|
<span>{{ getThresholdAlarmText(alarm) }}</span>
|
|
<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>
|
|
</span>
|
|
|
</div>
|
|
</div>
|
|
|
<div ref="chartRef" class="chart-canvas"></div>
|
|
<div ref="chartRef" class="chart-canvas"></div>
|
|
@@ -1109,7 +1407,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
<el-dialog
|
|
<el-dialog
|
|
|
v-model="settingsDialogVisible"
|
|
v-model="settingsDialogVisible"
|
|
|
title="设备设置"
|
|
title="设备设置"
|
|
|
- width="1100px"
|
|
|
|
|
|
|
+ width="min(1140px, calc(100vw - 32px))"
|
|
|
class="board-settings-dialog"
|
|
class="board-settings-dialog"
|
|
|
modal-class="board-settings-overlay"
|
|
modal-class="board-settings-overlay"
|
|
|
append-to-body
|
|
append-to-body
|
|
@@ -1125,37 +1423,40 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
</el-form>
|
|
</el-form>
|
|
|
|
|
|
|
|
<div class="board-settings-section">
|
|
<div class="board-settings-section">
|
|
|
- <div class="board-settings-section__title">告警阈值与 Y 轴范围</div>
|
|
|
|
|
|
|
+ <div class="board-settings-section__title">检查时间、超限范围与 Y 轴范围</div>
|
|
|
<ZmTable :data="rangeSettingDrafts" :loading="false" :max-height="420" :show-border="true">
|
|
<ZmTable :data="rangeSettingDrafts" :loading="false" :max-height="420" :show-border="true">
|
|
|
- <ZmTableColumn label="曲线" min-width="180">
|
|
|
|
|
|
|
+ <ZmTableColumn label="曲线" align="left">
|
|
|
<template #default="{ row }">
|
|
<template #default="{ row }">
|
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
|
<span
|
|
<span
|
|
|
class="inline-block size-2.5 rounded-full shrink-0"
|
|
class="inline-block size-2.5 rounded-full shrink-0"
|
|
|
:style="{ backgroundColor: row.color }"></span>
|
|
: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>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="告警下限" width="140">
|
|
|
|
|
|
|
+ <ZmTableColumn label="检查时间(秒)">
|
|
|
<template #default="{ row }">
|
|
<template #default="{ row }">
|
|
|
<el-input-number
|
|
<el-input-number
|
|
|
- v-model="row.minValue"
|
|
|
|
|
|
|
+ v-model="row.periodSecond"
|
|
|
class="!w-full"
|
|
class="!w-full"
|
|
|
:controls="false"
|
|
:controls="false"
|
|
|
|
|
+ :min="1"
|
|
|
|
|
+ :precision="0"
|
|
|
placeholder="未设置" />
|
|
placeholder="未设置" />
|
|
|
</template>
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="告警上限" width="140">
|
|
|
|
|
|
|
+ <ZmTableColumn label="超限范围">
|
|
|
<template #default="{ row }">
|
|
<template #default="{ row }">
|
|
|
<el-input-number
|
|
<el-input-number
|
|
|
- v-model="row.maxValue"
|
|
|
|
|
|
|
+ v-model="row.changeValue"
|
|
|
class="!w-full"
|
|
class="!w-full"
|
|
|
:controls="false"
|
|
:controls="false"
|
|
|
- placeholder="未设置" />
|
|
|
|
|
|
|
+ :min="0"
|
|
|
|
|
+ placeholder="0" />
|
|
|
</template>
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="Y 轴下限" width="140">
|
|
|
|
|
|
|
+ <ZmTableColumn label="Y 轴下限">
|
|
|
<template #default="{ row }">
|
|
<template #default="{ row }">
|
|
|
<el-input-number
|
|
<el-input-number
|
|
|
v-model="row.axisMin"
|
|
v-model="row.axisMin"
|
|
@@ -1164,7 +1465,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
:placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
|
|
:placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
|
|
|
</template>
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="Y 轴上限" width="140">
|
|
|
|
|
|
|
+ <ZmTableColumn label="Y 轴上限">
|
|
|
<template #default="{ row }">
|
|
<template #default="{ row }">
|
|
|
<el-input-number
|
|
<el-input-number
|
|
|
v-model="row.axisMax"
|
|
v-model="row.axisMax"
|
|
@@ -1173,7 +1474,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
:placeholder="formatChartValue(getSeriesRange(row.name).labelMax)" />
|
|
:placeholder="formatChartValue(getSeriesRange(row.name).labelMax)" />
|
|
|
</template>
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="当前 Y 轴范围" width="150">
|
|
|
|
|
|
|
+ <ZmTableColumn label="当前 Y 轴范围">
|
|
|
<template #default="{ row }">
|
|
<template #default="{ row }">
|
|
|
<span class="board-settings-range-text">
|
|
<span class="board-settings-range-text">
|
|
|
{{ formatChartValue(getSeriesRange(row.name).labelMin) }}
|
|
{{ formatChartValue(getSeriesRange(row.name).labelMin) }}
|
|
@@ -1182,7 +1483,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
</span>
|
|
</span>
|
|
|
</template>
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="操作" width="90">
|
|
|
|
|
|
|
+ <ZmTableColumn label="操作">
|
|
|
<template #default="{ row }">
|
|
<template #default="{ row }">
|
|
|
<el-button size="small" link type="primary" @click="clearRangeDraft(row)">
|
|
<el-button size="small" link type="primary" @click="clearRangeDraft(row)">
|
|
|
清空
|
|
清空
|
|
@@ -1298,6 +1599,14 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
color: #334155;
|
|
color: #334155;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+.board-settings-line-name {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
.chart-main {
|
|
.chart-main {
|
|
|
display: grid;
|
|
display: grid;
|
|
|
grid-template-columns: auto minmax(0, 1fr);
|
|
grid-template-columns: auto minmax(0, 1fr);
|
|
@@ -1461,17 +1770,38 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
display: inline-flex;
|
|
display: inline-flex;
|
|
|
gap: 5px;
|
|
gap: 5px;
|
|
|
align-items: center;
|
|
align-items: center;
|
|
|
|
|
+ max-width: 100%;
|
|
|
padding: 3px 7px;
|
|
padding: 3px 7px;
|
|
|
font-size: 12px;
|
|
font-size: 12px;
|
|
|
font-weight: 700;
|
|
font-weight: 700;
|
|
|
line-height: 1.4;
|
|
line-height: 1.4;
|
|
|
color: #fee2e2;
|
|
color: #fee2e2;
|
|
|
- white-space: nowrap;
|
|
|
|
|
|
|
+ white-space: normal;
|
|
|
background: rgb(220 38 38 / 24%);
|
|
background: rgb(220 38 38 / 24%);
|
|
|
border: 1px solid rgb(248 113 113 / 40%);
|
|
border: 1px solid rgb(248 113 113 / 40%);
|
|
|
border-radius: 999px;
|
|
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 {
|
|
@keyframes alarm-pulse {
|
|
|
0%,
|
|
0%,
|
|
|
100% {
|
|
100% {
|