|
|
@@ -0,0 +1,701 @@
|
|
|
+<script lang="ts" setup>
|
|
|
+import { IotDeviceApi } from '@/api/pms/device'
|
|
|
+import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
|
|
|
+import { useMqtt } from '@/utils/useMqtt'
|
|
|
+import { Dimensions, formatIotValue } from '@/utils/useSocketBus'
|
|
|
+import dayjs from 'dayjs'
|
|
|
+import * as echarts from 'echarts'
|
|
|
+import { neonColors } from '@/utils/td-color'
|
|
|
+
|
|
|
+const props = defineProps({
|
|
|
+ id: {
|
|
|
+ type: Number,
|
|
|
+ required: true
|
|
|
+ },
|
|
|
+ deviceCode: {
|
|
|
+ type: String,
|
|
|
+ required: true
|
|
|
+ },
|
|
|
+ deviceName: {
|
|
|
+ type: String,
|
|
|
+ required: true
|
|
|
+ },
|
|
|
+ mqttUrl: {
|
|
|
+ type: String,
|
|
|
+ required: true
|
|
|
+ },
|
|
|
+ // isRealTime: {
|
|
|
+ // type: Boolean,
|
|
|
+ // default: true
|
|
|
+ // },
|
|
|
+ date: {
|
|
|
+ type: Array as PropType<Array<string>>,
|
|
|
+ required: true
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+const dimensions = ref<Omit<Dimensions, 'color' | 'bgHover' | 'bgActive'>[]>([])
|
|
|
+const selectedDimension = ref<Record<string, boolean>>({})
|
|
|
+
|
|
|
+const { connect, destroy, isConnected, subscribe } = useMqtt()
|
|
|
+
|
|
|
+const handleMessageUpdate = (_topic: string, data: any) => {
|
|
|
+ const valueMap = new Map<string, number>()
|
|
|
+
|
|
|
+ for (const item of data) {
|
|
|
+ const { id: identity, value: logValue, remark } = item
|
|
|
+
|
|
|
+ const value = logValue ? Number(logValue) : 0
|
|
|
+
|
|
|
+ if (identity) {
|
|
|
+ valueMap.set(identity, value)
|
|
|
+ }
|
|
|
+
|
|
|
+ const modelName = dimensions.value.find((item) => item.identifier === identity)?.name
|
|
|
+
|
|
|
+ if (modelName && chartData.value[modelName]) {
|
|
|
+ chartData.value[modelName].push({
|
|
|
+ ts: dayjs.unix(remark).valueOf(),
|
|
|
+ value
|
|
|
+ })
|
|
|
+
|
|
|
+ updateSingleSeries(modelName)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+watch(isConnected, (newVal) => {
|
|
|
+ if (newVal) {
|
|
|
+ // subscribe(`/636/${props.deviceCode}/property/post`)
|
|
|
+
|
|
|
+ switch (props.deviceCode) {
|
|
|
+ case 'YF1539':
|
|
|
+ subscribe(`/656/${props.deviceCode}/property/post`)
|
|
|
+ case 'YF325':
|
|
|
+ case 'YF288':
|
|
|
+ case 'YF671':
|
|
|
+ case 'YF459':
|
|
|
+ subscribe(`/635/${props.deviceCode}/property/post`)
|
|
|
+ case 'YF649':
|
|
|
+ subscribe(`/636/${props.deviceCode}/property/post`)
|
|
|
+ default:
|
|
|
+ subscribe(`/636/${props.deviceCode}/property/post`)
|
|
|
+ }
|
|
|
+
|
|
|
+ // subscribe(props.mqttUrl)
|
|
|
+ // subscribe('/636/YF649/property/post')
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+async function loadDimensions() {
|
|
|
+ if (!props.id) return
|
|
|
+ try {
|
|
|
+ dimensions.value = (((await IotDeviceApi.getIotDeviceTds(Number(props.id))) as any[]) ?? [])
|
|
|
+ .sort((a, b) => b.modelOrder - a.modelOrder)
|
|
|
+ .map((item) => {
|
|
|
+ const { value, suffix, isText } = formatIotValue(item.value)
|
|
|
+ return {
|
|
|
+ identifier: item.identifier,
|
|
|
+ name: item.modelName,
|
|
|
+ value: value,
|
|
|
+ suffix: suffix,
|
|
|
+ isText: isText,
|
|
|
+ response: false
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .filter((item) => item.isText === false)
|
|
|
+
|
|
|
+ selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, true]))
|
|
|
+ } catch (error) {
|
|
|
+ console.error(error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+interface ChartData {
|
|
|
+ [key: Dimensions['name']]: { ts: number; value: number }[]
|
|
|
+}
|
|
|
+
|
|
|
+const chartData = ref<ChartData>({})
|
|
|
+
|
|
|
+let intervalArr = ref<number[]>([])
|
|
|
+let maxInterval = ref(0)
|
|
|
+let minInterval = ref(0)
|
|
|
+
|
|
|
+const chartRef = ref<HTMLDivElement | null>(null)
|
|
|
+let chart: echarts.ECharts | null = null
|
|
|
+
|
|
|
+function genderIntervalArr(init: boolean = false) {
|
|
|
+ // 1. 使用正负无穷大初始化,避免先把所有数存入数组
|
|
|
+ let maxVal = -Infinity
|
|
|
+ let minVal = Infinity
|
|
|
+ let hasData = false
|
|
|
+
|
|
|
+ // 2. 直接遍历数据查找最值 (不使用 spread ...)
|
|
|
+ for (const [key, value] of Object.entries(selectedDimension.value)) {
|
|
|
+ if (value) {
|
|
|
+ const dataset = chartData.value[key]
|
|
|
+ if (dataset && dataset.length > 0) {
|
|
|
+ hasData = true
|
|
|
+ // 使用循环代替 ...spread
|
|
|
+ for (const item of dataset) {
|
|
|
+ const val = item.value
|
|
|
+ if (val > maxVal) maxVal = val
|
|
|
+ if (val < minVal) minVal = val
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 处理无数据的默认情况
|
|
|
+ if (!hasData) {
|
|
|
+ maxVal = 10000
|
|
|
+ minVal = 0
|
|
|
+ } else {
|
|
|
+ // 保持你原有的逻辑:如果最小值大于0,则归零
|
|
|
+ minVal = minVal > 0 ? 0 : minVal
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4. 计算位数逻辑 (保持不变)
|
|
|
+ const maxDigits = (Math.floor(maxVal) + '').length
|
|
|
+ const minDigits = minVal === 0 ? 0 : (Math.floor(Math.abs(minVal)) + '').length
|
|
|
+
|
|
|
+ const interval = Math.max(maxDigits, minDigits)
|
|
|
+
|
|
|
+ maxInterval.value = interval
|
|
|
+ minInterval.value = minDigits
|
|
|
+
|
|
|
+ intervalArr.value = [0]
|
|
|
+ for (let i = 1; i <= interval; i++) {
|
|
|
+ intervalArr.value.push(Math.pow(10, i))
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!init) {
|
|
|
+ chart?.setOption({
|
|
|
+ yAxis: {
|
|
|
+ min: -minInterval.value,
|
|
|
+ max: maxInterval.value
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// function genderIntervalArr(init: boolean = false) {
|
|
|
+// const values: number[] = []
|
|
|
+
|
|
|
+// for (const [key, value] of Object.entries(selectedDimension.value)) {
|
|
|
+// if (value) {
|
|
|
+// values.push(...(chartData.value[key]?.map((item) => item.value) ?? []))
|
|
|
+// }
|
|
|
+// }
|
|
|
+
|
|
|
+// const maxVal = values.length === 0 ? 10000 : Math.max(...values)
|
|
|
+// const minVal = values.length === 0 ? 0 : Math.min(...values) > 0 ? 0 : Math.min(...values)
|
|
|
+
|
|
|
+// const maxDigits = (Math.floor(maxVal) + '').length
|
|
|
+// const minDigits = minVal === 0 ? 0 : (Math.floor(Math.abs(minVal)) + '').length
|
|
|
+
|
|
|
+// const interval = Math.max(maxDigits, minDigits)
|
|
|
+
|
|
|
+// maxInterval.value = interval
|
|
|
+// minInterval.value = minDigits
|
|
|
+
|
|
|
+// intervalArr.value = [0]
|
|
|
+// for (let i = 1; i <= interval; i++) {
|
|
|
+// intervalArr.value.push(Math.pow(10, i))
|
|
|
+// }
|
|
|
+
|
|
|
+// if (!init) {
|
|
|
+// chart?.setOption({
|
|
|
+// yAxis: {
|
|
|
+// min: -minInterval.value,
|
|
|
+// max: maxInterval.value
|
|
|
+// }
|
|
|
+// })
|
|
|
+// }
|
|
|
+// }
|
|
|
+
|
|
|
+function chartInit() {
|
|
|
+ if (!chart) return
|
|
|
+
|
|
|
+ chart.on('legendselectchanged', (params: any) => {
|
|
|
+ // 1. 同步选中状态
|
|
|
+ selectedDimension.value = params.selected
|
|
|
+ const clickedModelName = params.name
|
|
|
+ const isSelected = params.selected[clickedModelName]
|
|
|
+
|
|
|
+ const oldMax = maxInterval.value
|
|
|
+ const oldMin = minInterval.value
|
|
|
+
|
|
|
+ genderIntervalArr()
|
|
|
+
|
|
|
+ const isScaleChanged = oldMax !== maxInterval.value || oldMin !== minInterval.value
|
|
|
+
|
|
|
+ if (isScaleChanged) {
|
|
|
+ Object.keys(selectedDimension.value).forEach((name) => {
|
|
|
+ if (selectedDimension.value[name]) {
|
|
|
+ updateSingleSeries(name)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ if (isSelected) {
|
|
|
+ updateSingleSeries(clickedModelName)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ window.addEventListener('resize', () => {
|
|
|
+ if (chart) chart.resize()
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function render() {
|
|
|
+ if (!chartRef.value) return
|
|
|
+
|
|
|
+ if (!chart) chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
|
|
|
+
|
|
|
+ chartInit()
|
|
|
+
|
|
|
+ genderIntervalArr(true)
|
|
|
+
|
|
|
+ chart.setOption({
|
|
|
+ color: neonColors,
|
|
|
+ animation: true,
|
|
|
+ animationDuration: 200,
|
|
|
+ animationEasing: 'linear',
|
|
|
+ animationDurationUpdate: 200,
|
|
|
+ animationEasingUpdate: 'linear',
|
|
|
+ grid: {
|
|
|
+ left: '3%',
|
|
|
+ top: '60px',
|
|
|
+ right: '6%',
|
|
|
+ bottom: '10%',
|
|
|
+ containLabel: true,
|
|
|
+ show: false
|
|
|
+ },
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ confine: true,
|
|
|
+ enterable: true,
|
|
|
+ className: 'echarts-tooltip-scroll',
|
|
|
+ extraCssText:
|
|
|
+ 'max-height: 300px; overflow-y: auto; pointer-events: auto; border-radius: 4px;',
|
|
|
+ backgroundColor: 'rgba(11, 17, 33, 0.95)',
|
|
|
+ borderColor: '#22d3ee',
|
|
|
+ borderWidth: 1,
|
|
|
+ textStyle: {
|
|
|
+ color: '#e2e8f0'
|
|
|
+ },
|
|
|
+ axisPointer: {
|
|
|
+ type: 'cross',
|
|
|
+ label: { backgroundColor: '#22d3ee', color: '#000' },
|
|
|
+ lineStyle: { color: 'rgba(255,255,255,0.3)', type: 'dashed' }
|
|
|
+ },
|
|
|
+ formatter: (params: any) => {
|
|
|
+ let d = `<div style="font-weight:bold; border-bottom:1px solid rgba(255,255,255,0.1); padding-bottom:5px; margin-bottom:5px;">${params[0].axisValueLabel}</div>`
|
|
|
+ const exist: string[] = []
|
|
|
+ params = params.filter((el: any) => {
|
|
|
+ if (exist.includes(el.seriesName)) return false
|
|
|
+ exist.push(el.seriesName)
|
|
|
+ return true
|
|
|
+ })
|
|
|
+
|
|
|
+ // 优化列表显示,圆点使用原本的颜色
|
|
|
+ let item = params.map(
|
|
|
+ (
|
|
|
+ el: any
|
|
|
+ ) => `<div class="flex items-center justify-between mt-1" style="font-size:12px; min-width: 180px;">
|
|
|
+ <span style="display:flex; align-items:center;">
|
|
|
+ <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background-color:${el.color};margin-right:6px;"></span>
|
|
|
+ <span style="color:#cbd5e1">${el.seriesName}</span>
|
|
|
+ </span>
|
|
|
+ <span style="color:#fff; font-weight:bold; margin-left:10px;">${el.value[2]?.toFixed(2)}</span>
|
|
|
+ </div>`
|
|
|
+ )
|
|
|
+
|
|
|
+ return d + item.join('')
|
|
|
+ }
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ type: 'time',
|
|
|
+ boundaryGap: ['0%', '25%'],
|
|
|
+ axisLabel: {
|
|
|
+ formatter: (v) => dayjs(v).format('YYYY-MM-DD\nHH:mm:ss'),
|
|
|
+ rotate: 0,
|
|
|
+ align: 'center',
|
|
|
+ color: '#94a3b8',
|
|
|
+ fontSize: 11
|
|
|
+ },
|
|
|
+ splitLine: {
|
|
|
+ show: false,
|
|
|
+ lineStyle: { color: 'rgba(255,255,255,0.5)', type: 'dashed' }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ dataZoom: [
|
|
|
+ { type: 'inside', xAxisIndex: 0 },
|
|
|
+ {
|
|
|
+ type: 'slider',
|
|
|
+ xAxisIndex: 0,
|
|
|
+ height: 20,
|
|
|
+ bottom: 10,
|
|
|
+ borderColor: 'transparent',
|
|
|
+ backgroundColor: 'rgba(255,255,255,0.05)',
|
|
|
+ fillerColor: 'rgba(34,211,238,0.2)',
|
|
|
+ handleStyle: {
|
|
|
+ color: '#22d3ee',
|
|
|
+ borderColor: '#22d3ee'
|
|
|
+ },
|
|
|
+ labelFormatter: (value: any) => {
|
|
|
+ return dayjs(value).format('YYYY-MM-DD\nHH:mm:ss')
|
|
|
+ },
|
|
|
+ textStyle: {
|
|
|
+ color: '#94a3b8',
|
|
|
+ fontSize: 10,
|
|
|
+ lineHeight: 12
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ min: -minInterval.value,
|
|
|
+ max: maxInterval.value,
|
|
|
+ interval: 1,
|
|
|
+ axisLabel: {
|
|
|
+ color: '#94a3b8',
|
|
|
+ formatter: (v) => {
|
|
|
+ const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
|
|
|
+ if (Math.abs(num) >= 10000) return (num / 10000).toFixed(0) + 'w'
|
|
|
+ if (Math.abs(num) >= 1000) return (num / 1000).toFixed(0) + 'k'
|
|
|
+ return num.toLocaleString()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ show: true,
|
|
|
+ splitLine: {
|
|
|
+ show: true,
|
|
|
+ lineStyle: {
|
|
|
+ color: 'rgba(255,255,255,0.05)',
|
|
|
+ type: 'dashed'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ axisPointer: {
|
|
|
+ show: true,
|
|
|
+ snap: false, // 必须设为 false,才能平滑显示小数部分的真实值
|
|
|
+ label: {
|
|
|
+ show: true,
|
|
|
+ backgroundColor: '#22d3ee', // 青色背景
|
|
|
+ color: '#000', // 黑色文字
|
|
|
+ fontWeight: 'bold',
|
|
|
+ precision: 2, // 保证精度
|
|
|
+
|
|
|
+ // --- 具体的实现逻辑 ---
|
|
|
+ formatter: (params: any) => {
|
|
|
+ const val = params.value // 这里拿到的是索引值,比如 4.21
|
|
|
+ if (val === 0) return '0.00'
|
|
|
+
|
|
|
+ // A. 处理正负号
|
|
|
+ const sign = val >= 0 ? 1 : -1
|
|
|
+ const absVal = Math.abs(val)
|
|
|
+
|
|
|
+ // B. 分离 整数部分(区间下标) 和 小数部分(区间内百分比)
|
|
|
+ const idx = Math.floor(absVal)
|
|
|
+
|
|
|
+ const percent = absVal - idx
|
|
|
+
|
|
|
+ // C. 安全检查:如果 intervalArr 还没生成,直接返回指数值
|
|
|
+ if (!intervalArr.value || intervalArr.value.length === 0) {
|
|
|
+ return (sign * Math.pow(10, absVal)).toFixed(2)
|
|
|
+ }
|
|
|
+
|
|
|
+ // D. 获取该区间的真实数值范围
|
|
|
+ // 例如 idx=2, 对应 intervalArr[2]=100, intervalArr[3]=1000
|
|
|
+ const min = intervalArr.value[idx]
|
|
|
+ // 如果到了最后一个区间,或者越界,就默认下一级是当前的10倍(防止报错)
|
|
|
+ const max =
|
|
|
+ intervalArr.value[idx + 1] !== undefined ? intervalArr.value[idx + 1] : min * 10
|
|
|
+
|
|
|
+ // E. 反向线性插值公式
|
|
|
+ // 真实值 = 下界 + (上下界之差 * 百分比)
|
|
|
+ const realVal = min + (max - min) * percent
|
|
|
+
|
|
|
+ // F. 加上符号并格式化
|
|
|
+ return (realVal * sign).toFixed(2)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ legend: {
|
|
|
+ type: 'scroll', // 开启滚动,防止遮挡
|
|
|
+ top: 10,
|
|
|
+
|
|
|
+ left: 'center',
|
|
|
+ width: '90%',
|
|
|
+
|
|
|
+ textStyle: {
|
|
|
+ color: '#e2e8f0', // 亮白色
|
|
|
+ fontSize: 12
|
|
|
+ },
|
|
|
+ pageIconColor: '#22d3ee',
|
|
|
+ pageIconInactiveColor: '#475569',
|
|
|
+ pageTextStyle: { color: '#fff' },
|
|
|
+ data: dimensions.value.map((item) => item.name),
|
|
|
+ selected: selectedDimension.value,
|
|
|
+ show: true
|
|
|
+ },
|
|
|
+ // legend: {
|
|
|
+ // data: dimensions.value.map((item) => item.name),
|
|
|
+ // selected: selectedDimension.value,
|
|
|
+ // show: true
|
|
|
+ // },
|
|
|
+ series: dimensions.value.map((item) => ({
|
|
|
+ name: item.name,
|
|
|
+ type: 'line',
|
|
|
+ smooth: 0.3,
|
|
|
+ showSymbol: false,
|
|
|
+
|
|
|
+ endLabel: {
|
|
|
+ show: true,
|
|
|
+ formatter: (params) => params.value[2]?.toFixed(2),
|
|
|
+ offset: [4, 0],
|
|
|
+ color: '#fff',
|
|
|
+ backgroundColor: 'auto',
|
|
|
+ padding: [2, 6],
|
|
|
+ borderRadius: 4,
|
|
|
+ fontSize: 11,
|
|
|
+ fontWeight: 'bold'
|
|
|
+ },
|
|
|
+
|
|
|
+ emphasis: {
|
|
|
+ focus: 'series',
|
|
|
+ lineStyle: { width: 4 }
|
|
|
+ },
|
|
|
+
|
|
|
+ lineStyle: {
|
|
|
+ width: 3,
|
|
|
+ shadowColor: 'rgba(0, 0, 0, 0.5)',
|
|
|
+ shadowBlur: 10,
|
|
|
+ shadowOffsetY: 5
|
|
|
+ },
|
|
|
+
|
|
|
+ data: [] // 占位数组
|
|
|
+ }))
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function mapData({ value, ts }) {
|
|
|
+ if (value === null || value === undefined || value === 0) return [ts, 0, 0]
|
|
|
+
|
|
|
+ const isPositive = value > 0
|
|
|
+ const absItem = Math.abs(value)
|
|
|
+
|
|
|
+ if (!intervalArr.value.length) return [ts, 0, value]
|
|
|
+
|
|
|
+ const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
|
|
|
+ const min_index = intervalArr.value.findIndex((v) => v === min_value)
|
|
|
+
|
|
|
+ let denominator = 1
|
|
|
+ if (min_index < intervalArr.value.length - 1) {
|
|
|
+ denominator = intervalArr.value[min_index + 1] - intervalArr.value[min_index]
|
|
|
+ } else {
|
|
|
+ denominator = intervalArr.value[min_index] || 1
|
|
|
+ }
|
|
|
+
|
|
|
+ const new_value = (absItem - min_value) / denominator + min_index
|
|
|
+
|
|
|
+ return [ts, isPositive ? new_value : -new_value, value]
|
|
|
+}
|
|
|
+
|
|
|
+function updateSingleSeries(name: string) {
|
|
|
+ if (!chart) render()
|
|
|
+ if (!chart) return
|
|
|
+
|
|
|
+ const idx = dimensions.value.findIndex((item) => item.name === name)
|
|
|
+ if (idx === -1) return
|
|
|
+
|
|
|
+ const data = chartData.value[name].map((v) => mapData(v))
|
|
|
+
|
|
|
+ chart.setOption({
|
|
|
+ series: [{ name, data }]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const lastTsMap = ref<Record<Dimensions['name'], number>>({})
|
|
|
+
|
|
|
+const chartLoading = ref(false)
|
|
|
+
|
|
|
+async function initLoadChartData(real_time: boolean = true) {
|
|
|
+ if (!dimensions.value.length) return
|
|
|
+
|
|
|
+ chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
|
|
|
+
|
|
|
+ chartLoading.value = true
|
|
|
+
|
|
|
+ dimensions.value = dimensions.value.map((item) => {
|
|
|
+ item.response = true
|
|
|
+ return item
|
|
|
+ })
|
|
|
+
|
|
|
+ for (const item of dimensions.value) {
|
|
|
+ const { identifier, name } = item
|
|
|
+ try {
|
|
|
+ const res = await IotStatApi.getDeviceInfoChart(
|
|
|
+ props.deviceCode,
|
|
|
+ identifier,
|
|
|
+ props.date[0],
|
|
|
+ props.date[1]
|
|
|
+ )
|
|
|
+
|
|
|
+ const sorted = res
|
|
|
+ .sort((a, b) => a.ts - b.ts)
|
|
|
+ .map((item) => ({ ts: item.ts, value: item.value }))
|
|
|
+
|
|
|
+ chartData.value[name] = sorted
|
|
|
+
|
|
|
+ lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
|
|
|
+
|
|
|
+ genderIntervalArr(false)
|
|
|
+
|
|
|
+ updateSingleSeries(name)
|
|
|
+
|
|
|
+ chartLoading.value = false
|
|
|
+ } finally {
|
|
|
+ item.response = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (real_time) {
|
|
|
+ connect('ws://172.21.10.65:8083/mqtt', {}, handleMessageUpdate)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function initfn(load: boolean = true, real_time: boolean = true) {
|
|
|
+ if (load) await loadDimensions()
|
|
|
+ render()
|
|
|
+ initLoadChartData(real_time)
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ initfn()
|
|
|
+})
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => props.date,
|
|
|
+ async (newDate, oldDate) => {
|
|
|
+ if (!newDate || newDate.length !== 2) return
|
|
|
+
|
|
|
+ if (oldDate && newDate[0] === oldDate[0] && newDate[1] === oldDate[1]) return
|
|
|
+
|
|
|
+ await cancelAllRequests()
|
|
|
+
|
|
|
+ destroy()
|
|
|
+
|
|
|
+ const endTime = dayjs(newDate[1])
|
|
|
+ const now = dayjs()
|
|
|
+ const isRealTime = endTime.isAfter(now.subtract(1, 'minute'))
|
|
|
+
|
|
|
+ if (chart) chart.clear()
|
|
|
+
|
|
|
+ if (isRealTime) initfn(false)
|
|
|
+ else initfn(false, false)
|
|
|
+ }
|
|
|
+)
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ destroy()
|
|
|
+
|
|
|
+ window.removeEventListener('resize', () => {
|
|
|
+ if (chart) chart.resize()
|
|
|
+ })
|
|
|
+})
|
|
|
+</script>
|
|
|
+<template>
|
|
|
+ <div class="h-100 rounded-lg chart-container flex flex-col">
|
|
|
+ <header class="chart-header">
|
|
|
+ <div class="title-icon"></div>
|
|
|
+ <div>{{ `${props.deviceCode}-${props.deviceName}` }}</div>
|
|
|
+ </header>
|
|
|
+ <main
|
|
|
+ class="flex-1 chart-main"
|
|
|
+ ref="chartRef"
|
|
|
+ v-loading="chartLoading"
|
|
|
+ element-loading-background="transparent"
|
|
|
+ ></main>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+<style scoped>
|
|
|
+.chart-container {
|
|
|
+ position: relative;
|
|
|
+ overflow: hidden;
|
|
|
+ background-color: rgb(11 17 33 / 90%);
|
|
|
+ border: 2px solid rgb(34 211 238 / 30%);
|
|
|
+ box-shadow:
|
|
|
+ 0 0 20px rgb(0 0 0 / 80%),
|
|
|
+ inset 0 0 15px rgb(34 211 238 / 10%);
|
|
|
+ transition:
|
|
|
+ border-color 0.3s ease,
|
|
|
+ transform 0.3s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.chart-container::before {
|
|
|
+ position: absolute;
|
|
|
+ pointer-events: none;
|
|
|
+ background: radial-gradient(
|
|
|
+ 400px circle at var(--mouse-x, 50%) var(--mouse-y, 50%),
|
|
|
+ rgb(34 211 238 / 5%),
|
|
|
+ transparent 40%
|
|
|
+ );
|
|
|
+ content: '';
|
|
|
+ inset: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.chart-container:hover {
|
|
|
+ border-color: rgb(34 211 238 / 60%);
|
|
|
+ transform: scale(1.005);
|
|
|
+}
|
|
|
+
|
|
|
+.chart-header {
|
|
|
+ display: flex;
|
|
|
+ padding: 12px 16px;
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 600;
|
|
|
+ letter-spacing: 1px;
|
|
|
+ color: #e2e8f0;
|
|
|
+ background: rgb(255 255 255 / 3%);
|
|
|
+ border-bottom: 1px solid transparent;
|
|
|
+ border-image: linear-gradient(to right, rgb(34 211 238 / 50%), transparent) 1;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.title-icon {
|
|
|
+ width: 4px;
|
|
|
+ height: 16px;
|
|
|
+ margin-right: 10px;
|
|
|
+ background: #22d3ee;
|
|
|
+ box-shadow: 0 0 8px #22d3ee;
|
|
|
+}
|
|
|
+
|
|
|
+.chart-main {
|
|
|
+ padding-top: 12px;
|
|
|
+ background-image: radial-gradient(circle at 50% 50%, rgb(34 211 238 / 10%) 0%, transparent 80%),
|
|
|
+ linear-gradient(to right, rgb(34 211 238 / 15%) 1px, transparent 1px),
|
|
|
+ linear-gradient(to bottom, rgb(34 211 238 / 15%) 1px, transparent 1px),
|
|
|
+ linear-gradient(135deg, rgb(11 17 33 / 90%) 0%, rgb(6 9 18 / 95%) 100%);
|
|
|
+ background-size:
|
|
|
+ 100% 100%,
|
|
|
+ 40px 40px,
|
|
|
+ 40px 40px,
|
|
|
+ 100% 100%;
|
|
|
+}
|
|
|
+
|
|
|
+/* 针对 ECharts tooltip 的滚动条美化 */
|
|
|
+.echarts-tooltip-scroll::-webkit-scrollbar {
|
|
|
+ width: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.echarts-tooltip-scroll::-webkit-scrollbar-thumb {
|
|
|
+ background: #22d3ee; /* 青色滑块 */
|
|
|
+ border-radius: 3px;
|
|
|
+}
|
|
|
+
|
|
|
+.echarts-tooltip-scroll::-webkit-scrollbar-track {
|
|
|
+ background: rgb(255 255 255 / 10%); /* 深色轨道 */
|
|
|
+}
|
|
|
+</style>
|