|
@@ -2,6 +2,7 @@
|
|
|
import { IotDeviceApi } from '@/api/pms/device'
|
|
import { IotDeviceApi } from '@/api/pms/device'
|
|
|
import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
|
|
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 { useMqtt } from '@/utils/useMqtt'
|
|
import { useMqtt } from '@/utils/useMqtt'
|
|
|
import {
|
|
import {
|
|
|
Dimensions,
|
|
Dimensions,
|
|
@@ -12,8 +13,8 @@ import {
|
|
|
RangeSettingDraft,
|
|
RangeSettingDraft,
|
|
|
withDisplayStyle
|
|
withDisplayStyle
|
|
|
} from '@/utils/useSocketBus'
|
|
} from '@/utils/useSocketBus'
|
|
|
-import { useDebounceFn } from '@vueuse/core'
|
|
|
|
|
import { Setting } from '@element-plus/icons-vue'
|
|
import { Setting } from '@element-plus/icons-vue'
|
|
|
|
|
+import { useDebounceFn } from '@vueuse/core'
|
|
|
import dayjs from 'dayjs'
|
|
import dayjs from 'dayjs'
|
|
|
import * as echarts from 'echarts'
|
|
import * as echarts from 'echarts'
|
|
|
|
|
|
|
@@ -69,6 +70,10 @@ const props = defineProps({
|
|
|
token: {
|
|
token: {
|
|
|
type: String,
|
|
type: String,
|
|
|
required: true
|
|
required: true
|
|
|
|
|
+ },
|
|
|
|
|
+ active: {
|
|
|
|
|
+ type: Boolean,
|
|
|
|
|
+ default: true
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
@@ -79,6 +84,31 @@ const emit = defineEmits<{
|
|
|
const dimensions = ref<Dimensions[]>([])
|
|
const dimensions = ref<Dimensions[]>([])
|
|
|
const selectedDimension = ref<Record<string, boolean>>({})
|
|
const selectedDimension = ref<Record<string, boolean>>({})
|
|
|
const message = useMessage()
|
|
const message = useMessage()
|
|
|
|
|
+const { setAlarmActive } = useAlarmSound()
|
|
|
|
|
+
|
|
|
|
|
+interface ThresholdAlarm {
|
|
|
|
|
+ key: string
|
|
|
|
|
+ name: string
|
|
|
|
|
+ value: number
|
|
|
|
|
+ threshold: number
|
|
|
|
|
+ direction: 'high' | 'low'
|
|
|
|
|
+ suffix?: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const thresholdAlarms = shallowRef<Record<string, ThresholdAlarm>>({})
|
|
|
|
|
+const activeThresholdAlarms = computed(() => Object.values(thresholdAlarms.value))
|
|
|
|
|
+
|
|
|
|
|
+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}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clearThresholdAlarms() {
|
|
|
|
|
+ thresholdAlarms.value = {}
|
|
|
|
|
+ setAlarmActive(false)
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
const currentOtherName = ref(props.otherName)
|
|
const currentOtherName = ref(props.otherName)
|
|
|
|
|
|
|
@@ -91,7 +121,7 @@ watch(
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
-const { connect, destroy, isConnected, subscribe } = useMqtt()
|
|
|
|
|
|
|
+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
|
|
@@ -139,12 +169,19 @@ const handleMessageUpdate = (_topic: string, data: any) => {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ updateDimensionValues(valueMap)
|
|
|
|
|
+ updateThresholdAlarm(valueMap)
|
|
|
|
|
+
|
|
|
if (!updatedNames.size) return
|
|
if (!updatedNames.size) return
|
|
|
|
|
|
|
|
- updateDimensionValues(valueMap)
|
|
|
|
|
scheduleRealtimeChartUpdate(Array.from(updatedNames))
|
|
scheduleRealtimeChartUpdate(Array.from(updatedNames))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function connectRealtime() {
|
|
|
|
|
+ if (!props.active || client.value) return
|
|
|
|
|
+ connect(`wss://aims.deepoil.cc/mqtt`, { password: props.token }, handleMessageUpdate)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
watch(isConnected, (newVal) => {
|
|
watch(isConnected, (newVal) => {
|
|
|
if (newVal) {
|
|
if (newVal) {
|
|
|
// subscribe(`/636/${props.deviceCode}/property/post`)
|
|
// subscribe(`/636/${props.deviceCode}/property/post`)
|
|
@@ -165,6 +202,8 @@ watch(isConnected, (newVal) => {
|
|
|
|
|
|
|
|
subscribe(props.mqttUrl)
|
|
subscribe(props.mqttUrl)
|
|
|
// subscribe('/636/YF649/property/post')
|
|
// subscribe('/636/YF649/property/post')
|
|
|
|
|
+ } else {
|
|
|
|
|
+ clearThresholdAlarms()
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
@@ -194,7 +233,7 @@ interface ChartData {
|
|
|
[key: Dimensions['name']]: ChartPoint[]
|
|
[key: Dimensions['name']]: ChartPoint[]
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-const chartData = ref<ChartData>({})
|
|
|
|
|
|
|
+const chartData = shallowRef<ChartData>({})
|
|
|
|
|
|
|
|
const chartRef = ref<HTMLDivElement | null>(null)
|
|
const chartRef = ref<HTMLDivElement | null>(null)
|
|
|
let chart: echarts.ECharts | null = null
|
|
let chart: echarts.ECharts | null = null
|
|
@@ -204,50 +243,52 @@ let chartLoadVersion = 0
|
|
|
let realtimeUpdateFrame: number | null = null
|
|
let realtimeUpdateFrame: number | null = null
|
|
|
const pendingRealtimeSeriesNames = new Set<string>()
|
|
const pendingRealtimeSeriesNames = new Set<string>()
|
|
|
|
|
|
|
|
|
|
+watch(
|
|
|
|
|
+ () => props.active,
|
|
|
|
|
+ async (active) => {
|
|
|
|
|
+ if (!active) {
|
|
|
|
|
+ destroy()
|
|
|
|
|
+ cancelRealtimeChartUpdate()
|
|
|
|
|
+ clearThresholdAlarms()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ chart?.resize()
|
|
|
|
|
+
|
|
|
|
|
+ if (chartRealtimeMode.value) {
|
|
|
|
|
+ connectRealtime()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
const TREND_AXIS_MIN = 0
|
|
const TREND_AXIS_MIN = 0
|
|
|
const TREND_AXIS_MAX = 100
|
|
const TREND_AXIS_MAX = 100
|
|
|
|
|
|
|
|
const resizeChart = useDebounceFn(() => {
|
|
const resizeChart = useDebounceFn(() => {
|
|
|
chart?.resize()
|
|
chart?.resize()
|
|
|
}, 100)
|
|
}, 100)
|
|
|
|
|
+let chartResizeObserver: ResizeObserver | null = null
|
|
|
|
|
|
|
|
function getDimensionByName(name: string) {
|
|
function getDimensionByName(name: string) {
|
|
|
return dimensions.value.find((item) => item.name === name)
|
|
return dimensions.value.find((item) => item.name === name)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function getDatasetMaxValue(name: string) {
|
|
|
|
|
- const dataset = chartData.value[name] || []
|
|
|
|
|
- let maxValue = -Infinity
|
|
|
|
|
-
|
|
|
|
|
- dataset.forEach(({ value }) => {
|
|
|
|
|
- const numberValue = Number(value)
|
|
|
|
|
- if (Number.isFinite(numberValue) && numberValue > maxValue) {
|
|
|
|
|
- maxValue = numberValue
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- const currentValue = normalizeRangeValue(getDimensionByName(name)?.value)
|
|
|
|
|
- if (currentValue !== undefined && currentValue > maxValue) {
|
|
|
|
|
- maxValue = currentValue
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return Number.isFinite(maxValue) ? maxValue : 0
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
function getSeriesRange(name: string) {
|
|
function getSeriesRange(name: string) {
|
|
|
const dimension = getDimensionByName(name)
|
|
const dimension = getDimensionByName(name)
|
|
|
- const configuredMin = normalizeRangeValue(dimension?.minValue)
|
|
|
|
|
- const configuredMax = normalizeRangeValue(dimension?.maxValue)
|
|
|
|
|
- const min = configuredMin ?? 0
|
|
|
|
|
- const max = configuredMax ?? getDatasetMaxValue(name)
|
|
|
|
|
- const normalizedMax = max > min ? max : min + 1
|
|
|
|
|
|
|
+ const configuredMin = normalizeRangeValue(dimension?.axisMin)
|
|
|
|
|
+ const configuredMax = normalizeRangeValue(dimension?.axisMax)
|
|
|
|
|
+ const configured =
|
|
|
|
|
+ configuredMin !== undefined && configuredMax !== undefined && configuredMin < configuredMax
|
|
|
|
|
+ const min = configured ? configuredMin : 0
|
|
|
|
|
+ const max = configured ? configuredMax : 100
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
|
min,
|
|
min,
|
|
|
- max: normalizedMax,
|
|
|
|
|
|
|
+ max,
|
|
|
labelMin: min,
|
|
labelMin: min,
|
|
|
- labelMax: normalizedMax,
|
|
|
|
|
- configured: configuredMin !== undefined && configuredMax !== undefined
|
|
|
|
|
|
|
+ labelMax: max,
|
|
|
|
|
+ configured
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -261,7 +302,8 @@ const chartLegendItems = computed(() =>
|
|
|
bgHover: item.bgHover,
|
|
bgHover: item.bgHover,
|
|
|
selected: selectedDimension.value[item.name] !== false,
|
|
selected: selectedDimension.value[item.name] !== false,
|
|
|
max: formatChartValue(range.labelMax),
|
|
max: formatChartValue(range.labelMax),
|
|
|
- min: formatChartValue(range.labelMin)
|
|
|
|
|
|
|
+ min: formatChartValue(range.labelMin),
|
|
|
|
|
+ alarm: thresholdAlarms.value[`${item.identifier}|${item.name}`]
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
)
|
|
)
|
|
@@ -296,16 +338,26 @@ function getLatestTimestamp() {
|
|
|
let latest: number | null = null
|
|
let latest: number | null = null
|
|
|
|
|
|
|
|
Object.values(chartData.value).forEach((series) => {
|
|
Object.values(chartData.value).forEach((series) => {
|
|
|
- series.forEach((point) => {
|
|
|
|
|
- if (latest === null || point.ts > latest) {
|
|
|
|
|
- latest = point.ts
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ const point = series[series.length - 1]
|
|
|
|
|
+ if (point && (latest === null || point.ts > latest)) latest = point.ts
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
return latest
|
|
return latest
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function findPointIndex(series: ChartPoint[], ts: number) {
|
|
|
|
|
+ let low = 0
|
|
|
|
|
+ let high = series.length
|
|
|
|
|
+
|
|
|
|
|
+ while (low < high) {
|
|
|
|
|
+ const middle = Math.floor((low + high) / 2)
|
|
|
|
|
+ if (series[middle].ts < ts) low = middle + 1
|
|
|
|
|
+ else high = middle
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return low
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function getSelectedDateRangeTimestamps() {
|
|
function getSelectedDateRangeTimestamps() {
|
|
|
const fallbackEnd = Date.now()
|
|
const fallbackEnd = Date.now()
|
|
|
const startTs = dayjs(props.date[0]).valueOf()
|
|
const startTs = dayjs(props.date[0]).valueOf()
|
|
@@ -350,9 +402,9 @@ function trimChartDataToRealtimeWindow(anchorTs = Date.now()) {
|
|
|
|
|
|
|
|
Object.keys(chartData.value).forEach((name) => {
|
|
Object.keys(chartData.value).forEach((name) => {
|
|
|
const series = chartData.value[name] || []
|
|
const series = chartData.value[name] || []
|
|
|
- const windowStartIndex = series.findIndex((point) => point.ts >= startTs)
|
|
|
|
|
|
|
+ const windowStartIndex = findPointIndex(series, startTs)
|
|
|
|
|
|
|
|
- if (windowStartIndex === -1) {
|
|
|
|
|
|
|
+ if (windowStartIndex === series.length) {
|
|
|
chartData.value[name] = series.slice(-1)
|
|
chartData.value[name] = series.slice(-1)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
@@ -365,24 +417,25 @@ function trimChartDataToRealtimeWindow(anchorTs = Date.now()) {
|
|
|
|
|
|
|
|
function appendChartPoint(name: string, point: ChartPoint) {
|
|
function appendChartPoint(name: string, point: ChartPoint) {
|
|
|
const series = chartData.value[name] ?? []
|
|
const series = chartData.value[name] ?? []
|
|
|
- const samePointIndex = series.findIndex((item) => item.ts === point.ts)
|
|
|
|
|
|
|
+ const pointIndex = findPointIndex(series, point.ts)
|
|
|
|
|
|
|
|
- if (samePointIndex >= 0) {
|
|
|
|
|
- series[samePointIndex] = point
|
|
|
|
|
|
|
+ if (series[pointIndex]?.ts === point.ts) {
|
|
|
|
|
+ series[pointIndex] = point
|
|
|
} else {
|
|
} else {
|
|
|
- series.push(point)
|
|
|
|
|
|
|
+ series.splice(pointIndex, 0, point)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- series.sort((a, b) => a.ts - b.ts)
|
|
|
|
|
chartData.value[name] = series
|
|
chartData.value[name] = series
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function mapChartPoint({ ts, value }: ChartPoint, name: string) {
|
|
|
|
|
|
|
+type SeriesRange = ReturnType<typeof getSeriesRange>
|
|
|
|
|
+
|
|
|
|
|
+function mapChartPoint({ ts, value }: ChartPoint, range: SeriesRange) {
|
|
|
const numberValue = Number(value)
|
|
const numberValue = Number(value)
|
|
|
|
|
|
|
|
if (!Number.isFinite(numberValue)) return [ts, null, null]
|
|
if (!Number.isFinite(numberValue)) return [ts, null, null]
|
|
|
|
|
|
|
|
- const { min, max } = getSeriesRange(name)
|
|
|
|
|
|
|
+ const { min, max } = range
|
|
|
const denominator = max - min || 1
|
|
const denominator = max - min || 1
|
|
|
const mappedValue = ((numberValue - min) / denominator) * 100
|
|
const mappedValue = ((numberValue - min) / denominator) * 100
|
|
|
const displayValue = Math.min(TREND_AXIS_MAX, Math.max(TREND_AXIS_MIN, mappedValue))
|
|
const displayValue = Math.min(TREND_AXIS_MAX, Math.max(TREND_AXIS_MIN, mappedValue))
|
|
@@ -392,8 +445,9 @@ function mapChartPoint({ ts, value }: ChartPoint, name: string) {
|
|
|
|
|
|
|
|
function buildSeriesData(name: string) {
|
|
function buildSeriesData(name: string) {
|
|
|
const dataset = chartData.value[name] || []
|
|
const dataset = chartData.value[name] || []
|
|
|
|
|
+ const range = getSeriesRange(name)
|
|
|
|
|
|
|
|
- return dataset.map((point) => mapChartPoint(point, name))
|
|
|
|
|
|
|
+ return dataset.map((point) => mapChartPoint(point, range))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function updateDimensionValues(valueMap: Map<string, number>) {
|
|
function updateDimensionValues(valueMap: Map<string, number>) {
|
|
@@ -405,6 +459,43 @@ function updateDimensionValues(valueMap: Map<string, number>) {
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function updateThresholdAlarm(valueMap: Map<string, number>) {
|
|
|
|
|
+ const nextAlarms = { ...thresholdAlarms.value }
|
|
|
|
|
+
|
|
|
|
|
+ dimensions.value.forEach((item) => {
|
|
|
|
|
+ const value = valueMap.get(item.identifier)
|
|
|
|
|
+ if (value === undefined) return
|
|
|
|
|
+
|
|
|
|
|
+ const min = normalizeRangeValue(item.minValue)
|
|
|
|
|
+ const max = normalizeRangeValue(item.maxValue)
|
|
|
|
|
+ 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 {
|
|
|
|
|
+ delete nextAlarms[alarmKey]
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ thresholdAlarms.value = nextAlarms
|
|
|
|
|
+ setAlarmActive(activeThresholdAlarms.value.length > 0)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function getChartAnimationOptions() {
|
|
function getChartAnimationOptions() {
|
|
|
return {
|
|
return {
|
|
|
animation: !chartRealtimeMode.value,
|
|
animation: !chartRealtimeMode.value,
|
|
@@ -474,9 +565,11 @@ function flushRealtimeChartUpdate() {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function scheduleRealtimeChartUpdate(names: string[]) {
|
|
function scheduleRealtimeChartUpdate(names: string[]) {
|
|
|
- names.forEach((name) => pendingRealtimeSeriesNames.add(name))
|
|
|
|
|
|
|
+ names.forEach((name) => {
|
|
|
|
|
+ if (selectedDimension.value[name]) pendingRealtimeSeriesNames.add(name)
|
|
|
|
|
+ })
|
|
|
|
|
|
|
|
- if (realtimeUpdateFrame !== null) return
|
|
|
|
|
|
|
+ if (!pendingRealtimeSeriesNames.size || realtimeUpdateFrame !== null) return
|
|
|
|
|
|
|
|
realtimeUpdateFrame = window.requestAnimationFrame(flushRealtimeChartUpdate)
|
|
realtimeUpdateFrame = window.requestAnimationFrame(flushRealtimeChartUpdate)
|
|
|
}
|
|
}
|
|
@@ -512,6 +605,12 @@ function chartInit() {
|
|
|
|
|
|
|
|
window.removeEventListener('resize', resizeChart)
|
|
window.removeEventListener('resize', resizeChart)
|
|
|
window.addEventListener('resize', resizeChart)
|
|
window.addEventListener('resize', resizeChart)
|
|
|
|
|
+
|
|
|
|
|
+ chartResizeObserver?.disconnect()
|
|
|
|
|
+ if (chartRef.value) {
|
|
|
|
|
+ chartResizeObserver = new ResizeObserver(resizeChart)
|
|
|
|
|
+ chartResizeObserver.observe(chartRef.value)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function render() {
|
|
function render() {
|
|
@@ -637,6 +736,7 @@ function render() {
|
|
|
id: item.name,
|
|
id: item.name,
|
|
|
name: item.name,
|
|
name: item.name,
|
|
|
type: 'line',
|
|
type: 'line',
|
|
|
|
|
+ sampling: 'lttb',
|
|
|
smooth: 0.24,
|
|
smooth: 0.24,
|
|
|
showSymbol: false,
|
|
showSymbol: false,
|
|
|
connectNulls: true,
|
|
connectNulls: true,
|
|
@@ -710,13 +810,15 @@ async function initLoadChartData(real_time: boolean = true) {
|
|
|
} else {
|
|
} else {
|
|
|
applySelectedDateWindow()
|
|
applySelectedDateWindow()
|
|
|
}
|
|
}
|
|
|
- updateSeriesByNames(dimensions.value.map((item) => item.name))
|
|
|
|
|
|
|
+ updateSeriesByNames(
|
|
|
|
|
+ dimensions.value.filter((item) => selectedDimension.value[item.name]).map((item) => item.name)
|
|
|
|
|
+ )
|
|
|
} finally {
|
|
} finally {
|
|
|
chartLoading.value = false
|
|
chartLoading.value = false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (real_time && loadVersion === chartLoadVersion) {
|
|
if (real_time && loadVersion === chartLoadVersion) {
|
|
|
- connect(`wss://aims.deepoil.cc/mqtt`, { password: props.token }, handleMessageUpdate)
|
|
|
|
|
|
|
+ connectRealtime()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -764,6 +866,8 @@ onUnmounted(() => {
|
|
|
cancelRealtimeChartUpdate()
|
|
cancelRealtimeChartUpdate()
|
|
|
|
|
|
|
|
window.removeEventListener('resize', resizeChart)
|
|
window.removeEventListener('resize', resizeChart)
|
|
|
|
|
+ chartResizeObserver?.disconnect()
|
|
|
|
|
+ chartResizeObserver = null
|
|
|
chart?.dispose()
|
|
chart?.dispose()
|
|
|
chart = null
|
|
chart = null
|
|
|
})
|
|
})
|
|
@@ -799,7 +903,9 @@ function buildRangeSettingDrafts() {
|
|
|
identifier: item.identifier,
|
|
identifier: item.identifier,
|
|
|
color: item.color,
|
|
color: item.color,
|
|
|
minValue: normalizeRangeValue(item.minValue),
|
|
minValue: normalizeRangeValue(item.minValue),
|
|
|
- maxValue: normalizeRangeValue(item.maxValue)
|
|
|
|
|
|
|
+ maxValue: normalizeRangeValue(item.maxValue),
|
|
|
|
|
+ axisMin: normalizeRangeValue(item.axisMin),
|
|
|
|
|
+ axisMax: normalizeRangeValue(item.axisMax)
|
|
|
}))
|
|
}))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -832,36 +938,56 @@ async function saveDeviceName() {
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function saveRangeSetting(item: Dimensions, minValue?: number, maxValue?: number) {
|
|
|
|
|
|
|
+async function saveRangeSetting(
|
|
|
|
|
+ item: Dimensions,
|
|
|
|
|
+ minValue?: number,
|
|
|
|
|
+ maxValue?: number,
|
|
|
|
|
+ axisMinValue?: number,
|
|
|
|
|
+ axisMaxValue?: number
|
|
|
|
|
+) {
|
|
|
const min = normalizeRangeValue(minValue)
|
|
const min = normalizeRangeValue(minValue)
|
|
|
const max = normalizeRangeValue(maxValue)
|
|
const max = normalizeRangeValue(maxValue)
|
|
|
|
|
+ const axisMin = normalizeRangeValue(axisMinValue)
|
|
|
|
|
+ const axisMax = normalizeRangeValue(axisMaxValue)
|
|
|
|
|
|
|
|
- if (min === undefined && max === undefined) {
|
|
|
|
|
|
|
+ if (min === undefined && max === 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.axisMin = undefined
|
|
|
|
|
+ item.axisMax = undefined
|
|
|
item.id = undefined
|
|
item.id = undefined
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (min === undefined || max === undefined) {
|
|
|
|
|
- throw new Error(`${item.name} 的最大值和最小值需要同时填写`)
|
|
|
|
|
|
|
+ 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 (min > max) {
|
|
|
|
|
- throw new Error(`${item.name} 的最小值不能大于最大值`)
|
|
|
|
|
|
|
+ if ((axisMin === undefined) !== (axisMax === undefined)) {
|
|
|
|
|
+ throw new Error(`${item.name} 的 Y 轴上限和 Y 轴下限需要同时填写`)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (min === max) {
|
|
|
|
|
- throw new Error(`${item.name} 的最大值和最小值不能相等`)
|
|
|
|
|
|
|
+ if (axisMin !== undefined && axisMax !== undefined && axisMin >= axisMax) {
|
|
|
|
|
+ throw new Error(`${item.name} 的 Y 轴下限必须小于 Y 轴上限`)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const body = {
|
|
const body = {
|
|
|
- minValue: min,
|
|
|
|
|
- maxValue: max,
|
|
|
|
|
|
|
+ minValue: min ?? null,
|
|
|
|
|
+ maxValue: max ?? null,
|
|
|
|
|
+ axisMin: axisMin ?? null,
|
|
|
|
|
+ axisMax: axisMax ?? null,
|
|
|
deviceId: props.id,
|
|
deviceId: props.id,
|
|
|
propertyCode: item.identifier,
|
|
propertyCode: item.identifier,
|
|
|
alarmProperty: item.name,
|
|
alarmProperty: item.name,
|
|
@@ -874,6 +1000,8 @@ async function saveRangeSetting(item: Dimensions, minValue?: number, maxValue?:
|
|
|
if (res.id) item.id = res.id
|
|
if (res.id) item.id = res.id
|
|
|
item.minValue = min
|
|
item.minValue = min
|
|
|
item.maxValue = max
|
|
item.maxValue = max
|
|
|
|
|
+ item.axisMin = axisMin
|
|
|
|
|
+ item.axisMax = axisMax
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function handleSettingsSave() {
|
|
async function handleSettingsSave() {
|
|
@@ -885,7 +1013,7 @@ 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)
|
|
|
|
|
|
|
+ await saveRangeSetting(item, draft.minValue, draft.maxValue, draft.axisMin, draft.axisMax)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
updateSeriesByNames(dimensions.value.map((item) => item.name))
|
|
updateSeriesByNames(dimensions.value.map((item) => item.name))
|
|
@@ -902,13 +1030,17 @@ function handleRangeDialogReset() {
|
|
|
rangeSettingDrafts.value = rangeSettingDrafts.value.map((item) => ({
|
|
rangeSettingDrafts.value = rangeSettingDrafts.value.map((item) => ({
|
|
|
...item,
|
|
...item,
|
|
|
minValue: undefined,
|
|
minValue: undefined,
|
|
|
- maxValue: undefined
|
|
|
|
|
|
|
+ maxValue: undefined,
|
|
|
|
|
+ axisMin: undefined,
|
|
|
|
|
+ axisMax: undefined
|
|
|
}))
|
|
}))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function clearRangeDraft(row: RangeSettingDraft) {
|
|
function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
row.minValue = undefined
|
|
row.minValue = undefined
|
|
|
row.maxValue = undefined
|
|
row.maxValue = undefined
|
|
|
|
|
+ row.axisMin = undefined
|
|
|
|
|
+ row.axisMax = undefined
|
|
|
}
|
|
}
|
|
|
</script>
|
|
</script>
|
|
|
<template>
|
|
<template>
|
|
@@ -917,6 +1049,10 @@ 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">
|
|
|
|
|
+ <i class="i-material-symbols:warning-rounded"></i>
|
|
|
|
|
+ {{ activeThresholdAlarms.length }} 项超限
|
|
|
|
|
+ </span>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="chart-header__actions">
|
|
<div class="chart-header__actions">
|
|
|
<el-button link type="primary" :icon="Setting" @click.stop="openSettingsDialog">
|
|
<el-button link type="primary" :icon="Setting" @click.stop="openSettingsDialog">
|
|
@@ -936,8 +1072,9 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
:key="item.name"
|
|
:key="item.name"
|
|
|
type="button"
|
|
type="button"
|
|
|
class="chart-legend__item"
|
|
class="chart-legend__item"
|
|
|
- :class="{ 'is-active': item.selected }"
|
|
|
|
|
|
|
+ :class="{ 'is-active': item.selected, 'is-alarm': item.alarm }"
|
|
|
:aria-pressed="item.selected"
|
|
:aria-pressed="item.selected"
|
|
|
|
|
+ :title="item.alarm ? getThresholdAlarmText(item.alarm) : undefined"
|
|
|
:style="{
|
|
:style="{
|
|
|
'--theme-color': item.color,
|
|
'--theme-color': item.color,
|
|
|
'--theme-bg-hover': item.bgHover
|
|
'--theme-bg-hover': item.bgHover
|
|
@@ -952,6 +1089,19 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div class="chart-stage">
|
|
<div class="chart-stage">
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-if="activeThresholdAlarms.length"
|
|
|
|
|
+ class="chart-alarm-panel"
|
|
|
|
|
+ role="alert"
|
|
|
|
|
+ aria-live="polite">
|
|
|
|
|
+ <span
|
|
|
|
|
+ v-for="alarm in activeThresholdAlarms"
|
|
|
|
|
+ :key="alarm.key"
|
|
|
|
|
+ class="chart-alarm-panel__item">
|
|
|
|
|
+ <i class="i-material-symbols:warning-rounded"></i>
|
|
|
|
|
+ <span>{{ getThresholdAlarmText(alarm) }}</span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
<div ref="chartRef" class="chart-canvas"></div>
|
|
<div ref="chartRef" class="chart-canvas"></div>
|
|
|
</div>
|
|
</div>
|
|
|
</main>
|
|
</main>
|
|
@@ -959,7 +1109,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
<el-dialog
|
|
<el-dialog
|
|
|
v-model="settingsDialogVisible"
|
|
v-model="settingsDialogVisible"
|
|
|
title="设备设置"
|
|
title="设备设置"
|
|
|
- width="900px"
|
|
|
|
|
|
|
+ width="1100px"
|
|
|
class="board-settings-dialog"
|
|
class="board-settings-dialog"
|
|
|
modal-class="board-settings-overlay"
|
|
modal-class="board-settings-overlay"
|
|
|
append-to-body
|
|
append-to-body
|
|
@@ -975,7 +1125,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
</el-form>
|
|
</el-form>
|
|
|
|
|
|
|
|
<div class="board-settings-section">
|
|
<div class="board-settings-section">
|
|
|
- <div class="board-settings-section__title">曲线量程设置</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="曲线" min-width="180">
|
|
|
<template #default="{ row }">
|
|
<template #default="{ row }">
|
|
@@ -987,25 +1137,43 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="最小值" width="180">
|
|
|
|
|
|
|
+ <ZmTableColumn label="告警下限" width="140">
|
|
|
<template #default="{ row }">
|
|
<template #default="{ row }">
|
|
|
<el-input-number
|
|
<el-input-number
|
|
|
v-model="row.minValue"
|
|
v-model="row.minValue"
|
|
|
class="!w-full"
|
|
class="!w-full"
|
|
|
:controls="false"
|
|
:controls="false"
|
|
|
- :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
|
|
|
|
|
|
|
+ placeholder="未设置" />
|
|
|
</template>
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="最大值" width="180">
|
|
|
|
|
|
|
+ <ZmTableColumn label="告警上限" width="140">
|
|
|
<template #default="{ row }">
|
|
<template #default="{ row }">
|
|
|
<el-input-number
|
|
<el-input-number
|
|
|
v-model="row.maxValue"
|
|
v-model="row.maxValue"
|
|
|
class="!w-full"
|
|
class="!w-full"
|
|
|
:controls="false"
|
|
:controls="false"
|
|
|
|
|
+ placeholder="未设置" />
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </ZmTableColumn>
|
|
|
|
|
+ <ZmTableColumn label="Y 轴下限" width="140">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <el-input-number
|
|
|
|
|
+ v-model="row.axisMin"
|
|
|
|
|
+ class="!w-full"
|
|
|
|
|
+ :controls="false"
|
|
|
|
|
+ :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </ZmTableColumn>
|
|
|
|
|
+ <ZmTableColumn label="Y 轴上限" width="140">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <el-input-number
|
|
|
|
|
+ v-model="row.axisMax"
|
|
|
|
|
+ class="!w-full"
|
|
|
|
|
+ :controls="false"
|
|
|
:placeholder="formatChartValue(getSeriesRange(row.name).labelMax)" />
|
|
:placeholder="formatChartValue(getSeriesRange(row.name).labelMax)" />
|
|
|
</template>
|
|
</template>
|
|
|
</ZmTableColumn>
|
|
</ZmTableColumn>
|
|
|
- <ZmTableColumn label="当前使用范围" width="170">
|
|
|
|
|
|
|
+ <ZmTableColumn label="当前 Y 轴范围" width="150">
|
|
|
<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) }}
|
|
@@ -1090,6 +1258,23 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
align-items: center;
|
|
align-items: center;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+.chart-header__alarm-count {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ gap: 4px;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ height: 24px;
|
|
|
|
|
+ padding: 0 8px;
|
|
|
|
|
+ margin-left: 12px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ font-weight: 800;
|
|
|
|
|
+ color: #fecaca;
|
|
|
|
|
+ background: rgb(220 38 38 / 22%);
|
|
|
|
|
+ border: 1px solid rgb(248 113 113 / 70%);
|
|
|
|
|
+ border-radius: 999px;
|
|
|
|
|
+ box-shadow: 0 0 14px rgb(239 68 68 / 28%);
|
|
|
|
|
+ animation: alarm-pulse 1.4s ease-in-out infinite;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
.title-icon {
|
|
.title-icon {
|
|
|
width: 4px;
|
|
width: 4px;
|
|
|
height: 16px;
|
|
height: 16px;
|
|
@@ -1182,6 +1367,39 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
color: var(--theme-color);
|
|
color: var(--theme-color);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+.chart-legend__item.is-alarm {
|
|
|
|
|
+ color: #fecaca;
|
|
|
|
|
+ background: rgb(220 38 38 / 24%);
|
|
|
|
|
+ border-color: rgb(248 113 113 / 85%);
|
|
|
|
|
+ opacity: 1;
|
|
|
|
|
+ box-shadow:
|
|
|
|
|
+ 0 0 0 1px rgb(239 68 68 / 20%),
|
|
|
|
|
+ 0 0 18px rgb(239 68 68 / 38%);
|
|
|
|
|
+ animation: alarm-pulse 1.4s ease-in-out infinite;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.chart-legend__item.is-alarm::after {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 3px;
|
|
|
|
|
+ right: 3px;
|
|
|
|
|
+ width: 7px;
|
|
|
|
|
+ height: 7px;
|
|
|
|
|
+ background: #ef4444;
|
|
|
|
|
+ border: 1px solid #fecaca;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+ content: '';
|
|
|
|
|
+ box-shadow: 0 0 8px #ef4444;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.chart-legend__item.is-alarm .chart-legend__line {
|
|
|
|
|
+ background: #ef4444;
|
|
|
|
|
+ opacity: 1;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.chart-legend__item.is-alarm .chart-legend__range {
|
|
|
|
|
+ color: #fecaca;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
.chart-legend__range {
|
|
.chart-legend__range {
|
|
|
font-size: 10px;
|
|
font-size: 10px;
|
|
|
line-height: 1;
|
|
line-height: 1;
|
|
@@ -1220,6 +1438,58 @@ function clearRangeDraft(row: RangeSettingDraft) {
|
|
|
border-radius: 8px;
|
|
border-radius: 8px;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+.chart-alarm-panel {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 8px;
|
|
|
|
|
+ right: 8px;
|
|
|
|
|
+ left: 8px;
|
|
|
|
|
+ z-index: 5;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+ max-height: 76px;
|
|
|
|
|
+ padding: 7px;
|
|
|
|
|
+ overflow: auto;
|
|
|
|
|
+ background: rgb(69 10 10 / 88%);
|
|
|
|
|
+ border: 1px solid rgb(248 113 113 / 72%);
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ box-shadow: 0 8px 24px rgb(0 0 0 / 36%);
|
|
|
|
|
+ backdrop-filter: blur(6px);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.chart-alarm-panel__item {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ gap: 5px;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ padding: 3px 7px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+ line-height: 1.4;
|
|
|
|
|
+ color: #fee2e2;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ background: rgb(220 38 38 / 24%);
|
|
|
|
|
+ border: 1px solid rgb(248 113 113 / 40%);
|
|
|
|
|
+ border-radius: 999px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+@keyframes alarm-pulse {
|
|
|
|
|
+ 0%,
|
|
|
|
|
+ 100% {
|
|
|
|
|
+ filter: brightness(1);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 50% {
|
|
|
|
|
+ filter: brightness(1.35);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+@media (prefers-reduced-motion: reduce) {
|
|
|
|
|
+ .chart-header__alarm-count,
|
|
|
|
|
+ .chart-legend__item.is-alarm {
|
|
|
|
|
+ animation: none;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
.chart-canvas {
|
|
.chart-canvas {
|
|
|
width: 100%;
|
|
width: 100%;
|
|
|
height: 100%;
|
|
height: 100%;
|