Explorar o código

🦄 refactor(设备监控): 调整

Zimo hai 5 días
pai
achega
77db3a2c79

+ 2 - 11
src/api/pms/device/index.ts

@@ -44,13 +44,6 @@ export interface IotDeviceVO {
   runningWorkOrder: boolean // 当前设备是否已经有待执行的保养工单
 }
 
-let globalController = new AbortController()
-
-export const cancelAllRequests = async () => {
-  globalController.abort()
-  globalController = new AbortController()
-}
-
 // 设备台账 API
 export const IotDeviceApi = {
   getCompany: async (params: any) => {
@@ -122,15 +115,13 @@ export const IotDeviceApi = {
   },
   getIotDeviceTds: async (id: number) => {
     return await request.get({
-      url: `/rq/iot-device/get/gateway/td?id=` + id,
-      signal: globalController.signal
+      url: `/rq/iot-device/get/gateway/td?id=` + id
     })
   },
 
   getIotDeviceZHBDTds: async (id: number) => {
     return await request.get({
-      url: `/rq/iot-device/get/zhbd/td?id=` + id,
-      signal: globalController.signal
+      url: `/rq/iot-device/get/zhbd/td?id=` + id
     })
   },
   // 新增设备台账

+ 38 - 21
src/api/pms/stat/index.ts

@@ -1,5 +1,12 @@
 import request from '@/config/axios'
 
+let globalController = new AbortController()
+
+export const cancelAllRequests = async () => {
+  globalController.abort()
+  globalController = new AbortController()
+}
+
 // 设备台账 API
 export const IotStatApi = {
   getCompleteRate: async (params) => {
@@ -9,13 +16,13 @@ export const IotStatApi = {
     return await request.get({ url: `/rq/stat/main/day` })
   },
   getOrderSeven: async (params: any) => {
-    return await request.get({ url: `/rq/stat/rh/order/`+params })
+    return await request.get({ url: `/rq/stat/rh/order/` + params })
   },
   getRepairRigWork: async (params: any) => {
-    return await request.get({ url: `/rq/stat/ry/dailyReport/`+params })
+    return await request.get({ url: `/rq/stat/ry/dailyReport/` + params })
   },
   getOrderYwcb: async (params: any) => {
-    return await request.get({ url: `/rq/stat/rh/ywcb/`+params })
+    return await request.get({ url: `/rq/stat/rh/ywcb/` + params })
   },
   getRigFinished: async () => {
     return await request.get({ url: `/rq/stat/ry/dailyReport/rigFinished` })
@@ -47,11 +54,11 @@ export const IotStatApi = {
   getInspectStatus: async (params: any) => {
     return await request.get({ url: `/rq/stat/inspect/status`, params })
   },
-  getInspectStatuss: async (params: any, dept:any) => {
-    return await request.get({ url: `/rq/stat/inspect/statuss/`+dept, params })
+  getInspectStatuss: async (params: any, dept: any) => {
+    return await request.get({ url: `/rq/stat/inspect/statuss/` + dept, params })
   },
   getProject: async (params: any) => {
-    return await request.get({ url: `/rq/stat/project/`+params })
+    return await request.get({ url: `/rq/stat/project/` + params })
   },
   getInspectTodayStatus: async () => {
     return await request.get({ url: `/rq/stat/inspect/today/status` })
@@ -60,7 +67,7 @@ export const IotStatApi = {
     return await request.get({ url: `/rq/stat/inspect/device`, params })
   },
   getInspectItemStatus: async (params: any) => {
-    return await request.get({ url: `/rq/iot-inspect-order-detail/status`,params })
+    return await request.get({ url: `/rq/iot-inspect-order-detail/status`, params })
   },
   getMaintenanceDay: async () => {
     return await request.get({ url: `/rq/stat/maintenance/day` })
@@ -75,19 +82,19 @@ export const IotStatApi = {
     return await request.get({ url: `/rq/stat/maintenance/total` })
   },
   getMaintenanceStatus: async (params: any) => {
-    return await request.get({ url: `/rq/stat/maintenance/status/`+params })
+    return await request.get({ url: `/rq/stat/maintenance/status/` + params })
   },
   getRhZql: async (params: any) => {
-    return await request.get({ url: `/rq/stat/year/total/gas/`+params })
+    return await request.get({ url: `/rq/stat/year/total/gas/` + params })
   },
   getRhZqlGases: async (params: any) => {
-    return await request.get({ url: `/rq/stat/year/total/gases/`+params })
+    return await request.get({ url: `/rq/stat/year/total/gases/` + params })
   },
   getRhZqlToday: async (params: any) => {
-    return await request.get({ url: `/rq/stat/rh/zql/today/`+params })
+    return await request.get({ url: `/rq/stat/rh/zql/today/` + params })
   },
   getRhZqlDaily: async (params: any) => {
-    return await request.get({ url: `/rq/stat/rh/zql/daily/`+params })
+    return await request.get({ url: `/rq/stat/rh/zql/daily/` + params })
   },
   getMaintenanceTodayStatus: async () => {
     return await request.get({ url: `/rq/stat/maintenance/today/status` })
@@ -95,13 +102,23 @@ export const IotStatApi = {
   getMaintenanceType: async () => {
     return await request.get({ url: `/rq/stat/maintenance/type` })
   },
-  getDeviceInfoChart: async (deviceCode: any, identifier: any, begin: string, end:string) => {
-    return await request.get({ url: `/rq/stat/td/chart/`+deviceCode+'/'+identifier+'?beginTime='+begin+'&endTime='+end })
+  getDeviceInfoChart: async (deviceCode: any, identifier: any, begin: string, end: string) => {
+    return await request.get({
+      url:
+        `/rq/stat/td/chart/` +
+        deviceCode +
+        '/' +
+        identifier +
+        '?beginTime=' +
+        begin +
+        '&endTime=' +
+        end,
+      signal: globalController.signal
+    })
   },
 
-
   getDeviceCount: async (params: any) => {
-    return await request.get({ url: `/rq/stat/home/device/count/`+params })
+    return await request.get({ url: `/rq/stat/home/device/count/` + params })
   },
   getRhRate: async (params: any) => {
     return await request.get({ url: `/rq/stat/rh/device/utilizationRate`, params })
@@ -122,7 +139,7 @@ export const IotStatApi = {
     return await request.get({ url: `rq/stat/rd/device/teamUtilizationRate`, params })
   },
   getMaintainCount: async (params: any) => {
-    return await request.get({ url: `/rq/stat/home/maintain/count/`+params })
+    return await request.get({ url: `/rq/stat/home/maintain/count/` + params })
   },
   getMainWorkCount: async () => {
     return await request.get({ url: `/rq/stat/home/work/count` })
@@ -131,11 +148,11 @@ export const IotStatApi = {
     return await request.get({ url: `/rq/stat/home/inspect/count` })
   },
   getDeviceStatusCount: async (params: any) => {
-    return await request.get({ url: `/rq/stat/home/device/status/`+params })
+    return await request.get({ url: `/rq/stat/home/device/status/` + params })
   },
 
   getDeviceTypeCount: async (params: any) => {
-      return await request.get({ url: `/rq/stat/home/device/type/`+params })
+    return await request.get({ url: `/rq/stat/home/device/type/` + params })
   },
   getDeptCount: async () => {
     return await request.get({ url: `/rq/stat/home/dept` })
@@ -150,9 +167,9 @@ export const IotStatApi = {
     return await request.get({ url: `/pms/iot-outbound/materials/top` })
   },
   getDeptStatistics: async (params: any) => {
-      return await request.get({ url: `/rq/iot-opeation-fill/getCount`, params })
+    return await request.get({ url: `/rq/iot-opeation-fill/getCount`, params })
   },
   getDevSta: async (params: any) => {
     return await request.get({ url: `/rq/iot-opeation-fill/getDeviceCount`, params })
-  },
+  }
 }

+ 317 - 286
src/views/pms/device/monitor/TdDeviceInfo.vue

@@ -1,16 +1,12 @@
 <script setup lang="ts">
-import * as echarts from 'echarts'
 import { Odometer, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
-import { rangeShortcuts } from '@/utils/formatTime'
-
+import { IotDeviceApi } from '@/api/pms/device'
 import dayjs from 'dayjs'
-import quarterOfYear from 'dayjs/plugin/quarterOfYear'
-import { IotDeviceApi, cancelAllRequests } from '@/api/pms/device'
-import { IotStatApi } from '@/api/pms/stat'
-
-defineOptions({ name: 'TdDeviceDetail' })
+import { rangeShortcuts } from '@/utils/formatTime'
+import { IotStatApi, cancelAllRequests } from '@/api/pms/stat'
 
-dayjs.extend(quarterOfYear)
+import * as echarts from 'echarts'
+import { colors } from './color'
 
 const { query } = useRoute()
 
@@ -24,236 +20,100 @@ const data = ref({
   carOnline: query.carOnline || ''
 })
 
-const disabledIdentifier = ref<string[]>(['online', 'vehicle_name', 'touchScreenDataAccumulate'])
-
-const specs = ref<any[]>([])
-const gatewayspecs = ref<any[]>([])
-const zhbdspecs = ref<any[]>([])
-const selectSpec = ref<Record<string, boolean>>({})
-const chartMap = ref<Record<string, { name: string; value: any[] }>>({})
-
-const specsLoading = ref(false)
-
-const lastTsMap = ref<Record<string, number>>({})
-
-// 每 10s 刷新定时器
-const timer = ref<NodeJS.Timeout | null>(null)
-
-const defaultDate = rangeShortcuts[0].value()
-const date = ref([
-  dayjs(defaultDate[0]).format('YYYY-MM-DD HH:mm:ss'),
-  dayjs(defaultDate[1]).format('YYYY-MM-DD HH:mm:ss')
-])
-
-const reset = () => {
-  cancelAllRequests().then(() => {
-    const def = rangeShortcuts[0].value()
-
-    date.value = [
-      dayjs(def[0]).format('YYYY-MM-DD HH:mm:ss'),
-      dayjs(def[1]).format('YYYY-MM-DD HH:mm:ss')
-    ]
-    stopAutoFetch()
-    if (chart) chart.clear()
-    render()
-    initLoad()
-  })
+interface Dimensions {
+  identifier: string
+  name: string
+  value: string
+  color?: string
 }
 
-const handleDateChange = () => {
-  cancelAllRequests().then(() => {
-    stopAutoFetch()
-    if (chart) chart.clear()
-    render()
-    initLoad(false)
-  })
-}
-
-const handleClickSpec = (modelName: string) => {
-  selectSpec.value[modelName] = !selectSpec.value[modelName]
-  chart?.setOption({
-    legend: {
-      selected: selectSpec.value
-    }
-  })
-  // getIntervalArr()
-}
-
-const chartRef = ref<HTMLDivElement | null>(null)
-let chart: echarts.ECharts | null = null
-
-const exportChart = () => {
-  if (!chart) return
-  let img = new Image()
-  img.src = chart.getDataURL({
-    type: 'png',
-    pixelRatio: 1,
-    backgroundColor: '#fff'
-  })
-
-  img.onload = function () {
-    let canvas = document.createElement('canvas')
-    canvas.width = img.width
-    canvas.height = img.height
-    let ctx = canvas.getContext('2d')
-    ctx?.drawImage(img, 0, 0)
-    let dataURL = canvas.toDataURL('image/png')
-
-    let a = document.createElement('a')
+const dimensions = ref<Dimensions[]>([])
+const gatewayDimensions = ref<Dimensions[]>([])
+const carDimensions = ref<Dimensions[]>([])
 
-    let event = new MouseEvent('click')
+const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
 
-    a.href = dataURL
-    a.download = `${data.value.deviceName}-设备监控-${dayjs().format('YYYY-MM-DD HH:mm:ss')}.png`
-    a.dispatchEvent(event)
-  }
+interface SelectedDimension {
+  [key: Dimensions['name']]: boolean
 }
 
-const chartInit = () => {
-  if (!chart) return
+const selectedDimension = ref<SelectedDimension>({})
 
-  chart.on('legendselectchanged', (params: any) => {
-    selectSpec.value = params.selected
-  })
+const dimensionLoading = ref(false)
 
-  window.addEventListener('resize', () => {
-    if (chart) chart.resize()
-  })
-}
-
-// 映射区间相关
-let intervalArr = ref<number[]>([])
-let maxInterval = ref(0)
-let minInterval = ref(0)
-
-// 1. 加载 specs
-const loadSpecs = async () => {
+async function loadDimensions() {
   if (!query.id) return
-  specsLoading.value = true
-  const res = await IotDeviceApi.getIotDeviceTds(Number(query.id))
-  const zhbdres = await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))
-
-  zhbdspecs.value = zhbdres.sort((a, b) => b.modelOrder - a.modelOrder)
-  gatewayspecs.value = res.sort((a, b) => b.modelOrder - a.modelOrder)
-
-  specs.value = [
-    ...JSON.parse(JSON.stringify(gatewayspecs.value)),
-    ...JSON.parse(JSON.stringify(zhbdspecs.value))
-  ]
-
-  selectSpec.value = specs.value.reduce(
-    (acc, spec, index: number) => {
-      acc[spec.modelName] = index === 0
-      return acc
-    },
-    {} as Record<string, boolean>
-  )
 
-  specsLoading.value = false
+  dimensionLoading.value = true
 
-  chartMap.value = specs.value
-    .filter((spec) => !disabledIdentifier.value.includes(spec.identifier))
-    .reduce(
-      (acc, spec) => {
-        acc[spec.identifier] = { name: spec.modelName, value: [] }
-        return acc
-      },
-      {} as Record<string, { name: string; value: any[] }>
-    )
-}
-
-const chartLoading = ref(false)
-
-const initLoad = async (real_time: boolean = true) => {
-  if (!specs.value.length) return
-
-  Object.keys(chartMap.value).forEach((identifier) => {
-    chartMap.value[identifier].value = []
-    lastTsMap.value[identifier] = 0
-  })
-
-  chartLoading.value = true
-
-  for (const identifier of Object.keys(chartMap.value)) {
-    const res = await IotStatApi.getDeviceInfoChart(
-      data.value.deviceCode,
-      identifier,
-      date.value[0],
-      date.value[1]
-    )
-
-    const sorted = res
-      .sort((a, b) => a.ts - b.ts)
-      .map((item) => ({
-        ts: item.ts,
-        value: item.value
-      }))
-    chartMap.value[identifier].value = sorted
-    lastTsMap.value[identifier] = sorted.at(-1)?.ts ?? 0
+  const gateway = (((await IotDeviceApi.getIotDeviceTds(Number(query.id))) as any[]) ?? [])
+    .sort((a, b) => b.modelOrder - a.modelOrder)
+    .map((item) => ({
+      identifier: item.identifier,
+      name: item.modelName,
+      value: item.value
+    }))
+  const car = (((await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))) as any[]) ?? [])
+    .sort((a, b) => b.modelOrder - a.modelOrder)
+    .map((item) => ({
+      identifier: item.identifier,
+      name: item.modelName,
+      value: item.value
+    }))
 
-    updateSingleSeries(identifier)
+  dimensions.value = [...gateway, ...car]
+    .filter((item) => !disabledDimensions.value.includes(item.identifier))
+    .map((item, index) => ({
+      ...item,
+      color: colors[index]
+    }))
 
-    // if (selectSpec.value[chartMap.value[identifier].name]) {
-    //   getIntervalArr()
-    // }
+  gatewayDimensions.value = gateway
+  carDimensions.value = car
 
-    chartLoading.value = false
-  }
+  selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
 
-  console.log('chartMap.value :>> ', JSON.stringify(Object.values(chartMap.value), null, 2))
+  selectedDimension.value[dimensions.value[0].name] = true
 
-  if (real_time) startAutoFetch()
+  dimensionLoading.value = false
 }
 
-const startAutoFetch = () => {
-  timer.value = setInterval(fetchIncrementData, 10000)
-}
+const selectedDate = ref<string[]>([
+  ...rangeShortcuts[3].value().map((v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'))
+])
 
-const stopAutoFetch = () => {
-  if (timer.value) clearInterval(timer.value)
-  timer.value = null
+interface ChartData {
+  [key: Dimensions['name']]: { ts: number; value: number }[]
 }
 
-const fetchIncrementData = () => {
-  for (const identifier of Object.keys(chartMap.value)) {
-    const lastTs = lastTsMap.value[identifier]
-    if (!lastTs) continue
-
-    IotStatApi.getDeviceInfoChart(
-      data.value.deviceCode,
-      identifier,
-      dayjs(lastTs).format('YYYY-MM-DD HH:mm:ss'),
-      dayjs().format('YYYY-MM-DD HH:mm:ss')
-    ).then((res) => {
-      if (!res.length) return
+const chartData = ref<ChartData>({})
 
-      const sorted = res.sort((a, b) => a.ts - b.ts)
+let intervalArr = ref<number[]>([])
+let maxInterval = ref(0)
+let minInterval = ref(0)
 
-      // push 到本地
-      chartMap.value[identifier].value.push(...sorted)
-      // 更新 lastTs
-      lastTsMap.value[identifier] = sorted.at(-1).ts
+const chartRef = ref<HTMLDivElement | null>(null)
+let chart: echarts.ECharts | null = null
 
-      appendToSeries(identifier, chartMap.value[identifier].value)
-    })
-  }
-}
+// const genderIntervalArrDebounce = useDebounceFn(
+//   (init: boolean = false) => genderIntervalArr(init),
+//   300
+// )
 
-const getIntervalArr = (init: boolean = false) => {
+function genderIntervalArr(init: boolean = false) {
   const values: number[] = []
 
-  for (const [key, value] of Object.entries(selectSpec.value)) {
+  for (const [key, value] of Object.entries(selectedDimension.value)) {
     if (value) {
-      const identifier = specs.value.find((spec) => spec.modelName === key)?.identifier
-      values.push(...(chartMap.value[identifier]?.value?.map((item) => item.value) ?? []))
+      values.push(...(chartData.value[key]?.map((item) => item.value) ?? []))
     }
   }
 
   const maxVal = values.length === 0 ? 10000 : Math.max(...values)
-  const minVal = Math.min(...values, -100)
+  const minVal = values.length === 0 ? 0 : Math.min(...values)
 
   const maxDigits = (Math.floor(maxVal) + '').length
-  const minDigits = (Math.floor(Math.abs(minVal)) + '').length - 2
+  const minDigits = (Math.floor(Math.abs(minVal)) + '').length
   const interval = Math.max(maxDigits, minDigits)
 
   maxInterval.value = interval
@@ -274,18 +134,39 @@ const getIntervalArr = (init: boolean = false) => {
   }
 }
 
-const render = () => {
+function chartInit() {
+  if (!chart) return
+
+  chart.on('legendselectchanged', (params: any) => {
+    selectedDimension.value = params.selected
+  })
+
+  window.addEventListener('resize', () => {
+    if (chart) chart.resize()
+  })
+}
+
+function render() {
   if (!chartRef.value) return
 
-  if (!chart) chart = echarts.init(chartRef.value)
+  if (!chart) chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
 
   chartInit()
 
-  getIntervalArr(true)
+  genderIntervalArr(true)
 
   chart.setOption({
+    grid: {
+      left: '8%',
+      top: '0%',
+      right: '8%',
+      bottom: '12%'
+    },
     tooltip: {
       trigger: 'axis',
+      axisPointer: {
+        type: 'line'
+      },
       formatter: (params) => {
         let d = `${params[0].axisValueLabel}<br>`
         const exist: string[] = []
@@ -295,25 +176,30 @@ const render = () => {
           return true
         })
         let item = params.map(
-          (el) => `${el.marker} ${el.seriesName}: ${el.value[2].toFixed(2)}<br>`
+          (el) => `<div class="flex items-center justify-between mt-1">
+            <span>${el.marker} ${el.seriesName}</span>
+            <span>${el.value[2].toFixed(2)}</span>
+          </div>`
         )
+
         return d + item.join('')
       }
     },
-    dataZoom: [
-      { type: 'inside', xAxisIndex: 0 },
-      { type: 'slider', xAxisIndex: 0 }
-    ],
     xAxis: {
       type: 'time',
       axisLabel: {
-        formatter: (v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
-        rotate: 20
+        formatter: (v) => dayjs(v).format('YYYY-MM-DD\nHH:mm:ss'),
+        rotate: 0,
+        align: 'left'
       }
     },
+    dataZoom: [
+      { type: 'inside', xAxisIndex: 0 },
+      { type: 'slider', xAxisIndex: 0 }
+    ],
     yAxis: {
       type: 'value',
-      min: -minInterval.value,
+      min: minInterval.value,
       max: maxInterval.value,
       interval: 1,
       axisLabel: {
@@ -322,80 +208,209 @@ const render = () => {
 
           return num.toLocaleString()
         }
-      }
+      },
+      show: false
     },
     legend: {
-      data: Object.values(chartMap.value).map((i) => i.name),
-      selected: selectSpec.value,
+      data: dimensions.value.map((item) => item.name),
+      selected: selectedDimension.value,
       show: false
     },
-    series: Object.keys(chartMap.value).map((identifier) => ({
-      name: chartMap.value[identifier].name,
+    series: dimensions.value.map((item) => ({
+      name: item.name,
       type: 'line',
       smooth: true,
       showSymbol: false,
+      color: item.color,
       data: [] // 占位数组
     }))
   })
 }
 
-const updateSingleSeries = (identifier: string) => {
+function mapData({ value, ts }) {
+  if (value === 0) return [ts, 0, 0]
+
+  const isPositive = value > 0
+  const absItem = Math.abs(value)
+
+  const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
+  const min_index = intervalArr.value.findIndex((v) => v === min_value)
+
+  const new_value =
+    (absItem - min_value) / (intervalArr.value[min_index + 1] - intervalArr.value[min_index]) +
+    min_index
+
+  return [ts, isPositive ? new_value : -new_value, value]
+}
+
+function updateSingleSeries(name: string) {
   if (!chart) render()
   if (!chart) return
 
-  const idx = Object.keys(chartMap.value).indexOf(identifier)
+  const idx = dimensions.value.findIndex((item) => item.name === name)
   if (idx === -1) return
 
-  const data = chartMap.value[identifier].value.map((v) => mapData(v))
+  const data = chartData.value[name].map((v) => mapData(v))
 
   chart.setOption({
-    series: [
-      {
-        name: chartMap.value[identifier].name,
-        data
-      }
-    ]
+    series: [{ name, data }]
   })
 }
 
-const appendToSeries = (identifier, list) => {
-  if (!chart) return
+const lastTsMap = ref<Record<Dimensions['name'], number>>({})
 
-  const idx = Object.keys(chartMap.value).indexOf(identifier)
-  if (idx === -1) return
+async function fetchIncrementData() {
+  for (const { identifier, name } of dimensions.value) {
+    const lastTs = lastTsMap.value[name]
+    if (!lastTs) continue
 
-  const data = list.map(mapData)
+    IotStatApi.getDeviceInfoChart(
+      data.value.deviceCode,
+      identifier,
+      dayjs(lastTs).format('YYYY-MM-DD HH:mm:ss'),
+      dayjs().format('YYYY-MM-DD HH:mm:ss')
+    ).then((res) => {
+      if (!res.length) return
 
-  chart.setOption({
-    series: [
-      {
-        name: chartMap.value[identifier].name,
-        data
-      }
-    ]
-  })
+      const sorted = res.sort((a, b) => a.ts - b.ts)
+
+      // push 到本地
+      chartData.value[name].push(...sorted)
+      // 更新 lastTs
+      lastTsMap.value[identifier] = sorted.at(-1).ts
+
+      // 更新图表
+      updateSingleSeries(name)
+    })
+  }
 }
 
-const mapData = ({ value, ts }) => {
-  if (value === 0) return [ts, 0, 0]
+const timer = ref<NodeJS.Timeout | null>(null)
 
-  const isPositive = value > 0
-  const absItem = Math.abs(value)
+function startAutoFetch() {
+  timer.value = setInterval(fetchIncrementData, 10000)
+}
 
-  const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
-  const min_index = intervalArr.value.findIndex((v) => v === min_value)
+function stopAutoFetch() {
+  if (timer.value) clearInterval(timer.value)
+  timer.value = null
+}
 
-  const new_value =
-    (absItem - min_value) / (intervalArr.value[min_index + 1] - intervalArr.value[min_index]) +
-    min_index
+const chartLoading = ref(false)
 
-  return [ts, isPositive ? new_value : -new_value, value]
+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
+
+  for (const { identifier, name } of dimensions.value) {
+    const res = await IotStatApi.getDeviceInfoChart(
+      data.value.deviceCode,
+      identifier,
+      selectedDate.value[0],
+      selectedDate.value[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
+
+    updateSingleSeries(name)
+
+    chartLoading.value = false
+
+    if (selectedDimension.value[name]) {
+      genderIntervalArr()
+    }
+  }
+
+  if (real_time) startAutoFetch()
 }
 
-onMounted(async () => {
-  await loadSpecs()
+async function initfn(load: boolean = true, real_time: boolean = true) {
+  if (load) await loadDimensions()
   render()
-  initLoad()
+  initLoadChartData(real_time)
+}
+
+onMounted(() => {
+  initfn()
+})
+
+function reset() {
+  cancelAllRequests().then(() => {
+    selectedDate.value = rangeShortcuts[0]
+      .value()
+      .map((v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'))
+
+    stopAutoFetch()
+    if (chart) chart.clear()
+    initfn(false)
+  })
+}
+
+function handleDateChange() {
+  cancelAllRequests().then(() => {
+    stopAutoFetch()
+    if (chart) chart.clear()
+    initfn(false, false)
+  })
+}
+
+function handleClickSpec(modelName: string) {
+  selectedDimension.value[modelName] = !selectedDimension.value[modelName]
+  chart?.setOption({
+    legend: {
+      selected: selectedDimension.value
+    }
+  })
+  chart?.resize()
+  // genderIntervalArrDebounce()
+}
+
+const exportChart = () => {
+  if (!chart) return
+  let img = new Image()
+  img.src = chart.getDataURL({
+    type: 'png',
+    pixelRatio: 1,
+    backgroundColor: '#fff'
+  })
+
+  img.onload = function () {
+    let canvas = document.createElement('canvas')
+    canvas.width = img.width
+    canvas.height = img.height
+    let ctx = canvas.getContext('2d')
+    ctx?.drawImage(img, 0, 0)
+    let dataURL = canvas.toDataURL('image/png')
+
+    let a = document.createElement('a')
+
+    let event = new MouseEvent('click')
+
+    a.href = dataURL
+    a.download = `${data.value.deviceName}-设备监控-${dayjs().format('YYYY-MM-DD HH:mm:ss')}.png`
+    a.dispatchEvent(event)
+  }
+}
+
+const maxmin = computed(() => {
+  if (!dimensions.value.length) return []
+  return dimensions.value
+    .filter((v) => selectedDimension.value[v.name])
+    .map((v) => ({
+      name: v.name,
+      color: v.color,
+      max: Math.max(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2),
+      min: Math.min(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2)
+    }))
 })
 
 onUnmounted(() => {
@@ -463,43 +478,41 @@ onUnmounted(() => {
   <div class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow min-h-50">
     <header class="font-medium text-center w-full">网关数采</header>
     <div
-      v-loading="specsLoading"
+      v-loading="dimensionLoading"
       element-loading-background="transparent"
       class="w-full mt-4 grid grid-cols-4 gap-4 min-h-30"
       id="dimension"
     >
-      <div
-        v-for="item in gatewayspecs"
-        :key="item.productId"
-        class="h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-6 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
-        :class="{
-          'bg-blue-200': selectSpec[item.modelName]
-        }"
-        @click="!disabledIdentifier.includes(item.identifier) && handleClickSpec(item.modelName)"
+      <button
+        v-for="item in gatewayDimensions"
+        :key="item.identifier"
+        class="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-6 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
+        :class="{ 'bg-blue-200': selectedDimension[item.name] }"
+        :disabled="disabledDimensions.includes(item.identifier)"
+        @click="handleClickSpec(item.name)"
       >
-        <span class="text-sm text-[var(--el-text-color-regular)]">{{ item.modelName }}</span>
+        <span class="text-sm text-[var(--el-text-color-regular)]">{{ item.name }}</span>
         <span class="text-lg font-medium ms-a">{{ item.value }}</span>
-      </div>
+      </button>
     </div>
   </div>
   <div
-    v-if="zhbdspecs.length"
+    v-if="carDimensions.length"
     class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow"
   >
     <header class="font-medium text-center w-full">中航北斗</header>
     <div class="w-full mt-4 grid grid-cols-4 gap-4" id="dimension">
-      <div
-        v-for="item in zhbdspecs"
-        :key="item.productId"
-        class="h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-6 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
-        :class="{
-          'bg-blue-200': selectSpec[item.modelName]
-        }"
-        @click="!disabledIdentifier.includes(item.identifier) && handleClickSpec(item.modelName)"
+      <button
+        v-for="item in carDimensions"
+        :key="item.identifier"
+        class="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-6 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
+        :class="{ 'bg-blue-200': selectedDimension[item.name] }"
+        :disabled="disabledDimensions.includes(item.identifier)"
+        @click="handleClickSpec(item.name)"
       >
-        <span class="text-sm text-[var(--el-text-color-regular)]">{{ item.modelName }}</span>
+        <span class="text-sm text-[var(--el-text-color-regular)]">{{ item.name }}</span>
         <span class="text-lg font-medium ms-a">{{ item.value }}</span>
-      </div>
+      </button>
     </div>
   </div>
   <div class="w-full mt-4 bg-gradient-to-r from-blue-100 to-white min-h-175 rounded-lg p-6 shadow">
@@ -508,12 +521,11 @@ onUnmounted(() => {
         <div class="i-material-symbols:area-chart-outline-rounded text-sky size-6" text-sky></div>
         数据趋势
       </h3>
-
       <div class="flex gap-4">
         <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
         <el-button size="default" @click="reset">重置</el-button>
         <el-date-picker
-          v-model="date"
+          v-model="selectedDate"
           value-format="YYYY-MM-DD HH:mm:ss"
           type="datetimerange"
           unlink-panels
@@ -527,12 +539,31 @@ onUnmounted(() => {
         />
       </div>
     </header>
-    <div
-      v-loading="specsLoading"
-      element-loading-background="transparent"
-      ref="chartRef"
-      class="w-full h-158 mt-4 mb-4"
-    ></div>
+    <div class="flex h-160 mt-4">
+      <div class="flex gap-1">
+        <button
+          v-for="item of maxmin"
+          :key="item.name"
+          class="w-8 h-full flex flex-col items-center justify-between py-2 gap-1 rounded bg-transparent border-none"
+          @click="handleClickSpec(item.name)"
+        >
+          <span class="[writing-mode:sideways-lr]">{{ item.max }}</span>
+          <div class="flex-1 w-1" :style="{ backgroundColor: item.color }"></div>
+          <span class="[writing-mode:sideways-lr]">{{ item.name }}</span>
+          <div class="flex-1 w-1" :style="{ backgroundColor: item.color }"></div>
+          <span class="[writing-mode:sideways-lr]">{{ item.min }}</span>
+        </button>
+      </div>
+      <div class="flex flex-1">
+        <div
+          v-loading="chartLoading"
+          element-loading-background="transparent"
+          ref="chartRef"
+          class="flex-1 h-full"
+        >
+        </div>
+      </div>
+    </div>
   </div>
 </template>
 

+ 564 - 0
src/views/pms/device/monitor/TdDeviceInfo1.vue

@@ -0,0 +1,564 @@
+<script setup lang="ts">
+import * as echarts from 'echarts'
+import { Odometer, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
+import { rangeShortcuts } from '@/utils/formatTime'
+
+import dayjs from 'dayjs'
+import quarterOfYear from 'dayjs/plugin/quarterOfYear'
+import { IotDeviceApi, cancelAllRequests } from '@/api/pms/device'
+import { IotStatApi } from '@/api/pms/stat'
+
+defineOptions({ name: 'TdDeviceDetail' })
+
+dayjs.extend(quarterOfYear)
+
+const { query } = useRoute()
+
+const data = ref({
+  deviceCode: query.code || '',
+  deviceName: query.name || '',
+  lastInlineTime: query.time || '',
+  ifInline: query.ifInline || '',
+  dept: query.dept || '',
+  vehicle: query.vehicle || '',
+  carOnline: query.carOnline || ''
+})
+
+const disabledIdentifier = ref<string[]>(['online', 'vehicle_name', 'touchScreenDataAccumulate'])
+
+const specs = ref<any[]>([])
+const gatewayspecs = ref<any[]>([])
+const zhbdspecs = ref<any[]>([])
+const selectSpec = ref<Record<string, boolean>>({})
+const chartMap = ref<Record<string, { name: string; value: any[] }>>({})
+
+const specsLoading = ref(false)
+
+const lastTsMap = ref<Record<string, number>>({})
+
+// 每 10s 刷新定时器
+const timer = ref<NodeJS.Timeout | null>(null)
+
+const defaultDate = rangeShortcuts[0].value()
+const date = ref([
+  dayjs(defaultDate[0]).format('YYYY-MM-DD HH:mm:ss'),
+  dayjs(defaultDate[1]).format('YYYY-MM-DD HH:mm:ss')
+])
+
+const reset = () => {
+  cancelAllRequests().then(() => {
+    const def = rangeShortcuts[0].value()
+
+    date.value = [
+      dayjs(def[0]).format('YYYY-MM-DD HH:mm:ss'),
+      dayjs(def[1]).format('YYYY-MM-DD HH:mm:ss')
+    ]
+    stopAutoFetch()
+    if (chart) chart.clear()
+    render()
+    initLoad()
+  })
+}
+
+const handleDateChange = () => {
+  cancelAllRequests().then(() => {
+    stopAutoFetch()
+    if (chart) chart.clear()
+    render()
+    initLoad(false)
+  })
+}
+
+const handleClickSpec = (modelName: string) => {
+  selectSpec.value[modelName] = !selectSpec.value[modelName]
+  chart?.setOption({
+    legend: {
+      selected: selectSpec.value
+    }
+  })
+  // getIntervalArr()
+}
+
+const chartRef = ref<HTMLDivElement | null>(null)
+let chart: echarts.ECharts | null = null
+
+const exportChart = () => {
+  if (!chart) return
+  let img = new Image()
+  img.src = chart.getDataURL({
+    type: 'png',
+    pixelRatio: 1,
+    backgroundColor: '#fff'
+  })
+
+  img.onload = function () {
+    let canvas = document.createElement('canvas')
+    canvas.width = img.width
+    canvas.height = img.height
+    let ctx = canvas.getContext('2d')
+    ctx?.drawImage(img, 0, 0)
+    let dataURL = canvas.toDataURL('image/png')
+
+    let a = document.createElement('a')
+
+    let event = new MouseEvent('click')
+
+    a.href = dataURL
+    a.download = `${data.value.deviceName}-设备监控-${dayjs().format('YYYY-MM-DD HH:mm:ss')}.png`
+    a.dispatchEvent(event)
+  }
+}
+
+const chartInit = () => {
+  if (!chart) return
+
+  chart.on('legendselectchanged', (params: any) => {
+    selectSpec.value = params.selected
+  })
+
+  window.addEventListener('resize', () => {
+    if (chart) chart.resize()
+  })
+}
+
+// 映射区间相关
+let intervalArr = ref<number[]>([])
+let maxInterval = ref(0)
+let minInterval = ref(0)
+
+// 1. 加载 specs
+const loadSpecs = async () => {
+  if (!query.id) return
+  specsLoading.value = true
+  const res = await IotDeviceApi.getIotDeviceTds(Number(query.id))
+  const zhbdres = await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))
+
+  zhbdspecs.value = zhbdres.sort((a, b) => b.modelOrder - a.modelOrder)
+  gatewayspecs.value = res.sort((a, b) => b.modelOrder - a.modelOrder)
+
+  specs.value = [
+    ...JSON.parse(JSON.stringify(gatewayspecs.value)),
+    ...JSON.parse(JSON.stringify(zhbdspecs.value))
+  ]
+
+  selectSpec.value = specs.value.reduce(
+    (acc, spec, index: number) => {
+      acc[spec.modelName] = index === 0
+      return acc
+    },
+    {} as Record<string, boolean>
+  )
+
+  specsLoading.value = false
+
+  chartMap.value = specs.value
+    .filter((spec) => !disabledIdentifier.value.includes(spec.identifier))
+    .reduce(
+      (acc, spec) => {
+        acc[spec.identifier] = { name: spec.modelName, value: [] }
+        return acc
+      },
+      {} as Record<string, { name: string; value: any[] }>
+    )
+}
+
+const chartLoading = ref(false)
+
+const initLoad = async (real_time: boolean = true) => {
+  if (!specs.value.length) return
+
+  Object.keys(chartMap.value).forEach((identifier) => {
+    chartMap.value[identifier].value = []
+    lastTsMap.value[identifier] = 0
+  })
+
+  chartLoading.value = true
+
+  for (const identifier of Object.keys(chartMap.value)) {
+    const res = await IotStatApi.getDeviceInfoChart(
+      data.value.deviceCode,
+      identifier,
+      date.value[0],
+      date.value[1]
+    )
+
+    const sorted = res
+      .sort((a, b) => a.ts - b.ts)
+      .map((item) => ({
+        ts: item.ts,
+        value: item.value
+      }))
+    chartMap.value[identifier].value = sorted
+    lastTsMap.value[identifier] = sorted.at(-1)?.ts ?? 0
+
+    updateSingleSeries(identifier)
+
+    // if (selectSpec.value[chartMap.value[identifier].name]) {
+    //   getIntervalArr()
+    // }
+
+    chartLoading.value = false
+  }
+
+  console.log('chartMap.value :>> ', JSON.stringify(Object.values(chartMap.value), null, 2))
+
+  if (real_time) startAutoFetch()
+}
+
+const startAutoFetch = () => {
+  timer.value = setInterval(fetchIncrementData, 10000)
+}
+
+const stopAutoFetch = () => {
+  if (timer.value) clearInterval(timer.value)
+  timer.value = null
+}
+
+const fetchIncrementData = () => {
+  for (const identifier of Object.keys(chartMap.value)) {
+    const lastTs = lastTsMap.value[identifier]
+    if (!lastTs) continue
+
+    IotStatApi.getDeviceInfoChart(
+      data.value.deviceCode,
+      identifier,
+      dayjs(lastTs).format('YYYY-MM-DD HH:mm:ss'),
+      dayjs().format('YYYY-MM-DD HH:mm:ss')
+    ).then((res) => {
+      if (!res.length) return
+
+      const sorted = res.sort((a, b) => a.ts - b.ts)
+
+      // push 到本地
+      chartMap.value[identifier].value.push(...sorted)
+      // 更新 lastTs
+      lastTsMap.value[identifier] = sorted.at(-1).ts
+
+      appendToSeries(identifier, chartMap.value[identifier].value)
+    })
+  }
+}
+
+const getIntervalArr = (init: boolean = false) => {
+  const values: number[] = []
+
+  for (const [key, value] of Object.entries(selectSpec.value)) {
+    if (value) {
+      const identifier = specs.value.find((spec) => spec.modelName === key)?.identifier
+      values.push(...(chartMap.value[identifier]?.value?.map((item) => item.value) ?? []))
+    }
+  }
+
+  const maxVal = values.length === 0 ? 10000 : Math.max(...values)
+  const minVal = Math.min(...values, -100)
+
+  const maxDigits = (Math.floor(maxVal) + '').length
+  const minDigits = (Math.floor(Math.abs(minVal)) + '').length - 2
+  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
+      }
+    })
+  }
+}
+
+const render = () => {
+  if (!chartRef.value) return
+
+  if (!chart) chart = echarts.init(chartRef.value)
+
+  chartInit()
+
+  getIntervalArr(true)
+
+  chart.setOption({
+    tooltip: {
+      trigger: 'axis',
+      formatter: (params) => {
+        let d = `${params[0].axisValueLabel}<br>`
+        const exist: string[] = []
+        params = params.filter((el) => {
+          if (exist.includes(el.seriesName)) return false
+          exist.push(el.seriesName)
+          return true
+        })
+        let item = params.map(
+          (el) => `${el.marker} ${el.seriesName}: ${el.value[2].toFixed(2)}<br>`
+        )
+        return d + item.join('')
+      }
+    },
+    dataZoom: [
+      { type: 'inside', xAxisIndex: 0 },
+      { type: 'slider', xAxisIndex: 0 }
+    ],
+    xAxis: {
+      type: 'time',
+      axisLabel: {
+        formatter: (v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
+        rotate: 20
+      }
+    },
+    yAxis: {
+      type: 'value',
+      min: -minInterval.value,
+      max: maxInterval.value,
+      interval: 1,
+      axisLabel: {
+        formatter: (v) => {
+          const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
+
+          return num.toLocaleString()
+        }
+      }
+    },
+    legend: {
+      data: Object.values(chartMap.value).map((i) => i.name),
+      selected: selectSpec.value,
+      show: false
+    },
+    series: Object.keys(chartMap.value).map((identifier) => ({
+      name: chartMap.value[identifier].name,
+      type: 'line',
+      smooth: true,
+      showSymbol: false,
+      data: [] // 占位数组
+    }))
+  })
+}
+
+const updateSingleSeries = (identifier: string) => {
+  if (!chart) render()
+  if (!chart) return
+
+  const idx = Object.keys(chartMap.value).indexOf(identifier)
+  if (idx === -1) return
+
+  const data = chartMap.value[identifier].value.map((v) => mapData(v))
+
+  chart.setOption({
+    series: [
+      {
+        name: chartMap.value[identifier].name,
+        data
+      }
+    ]
+  })
+}
+
+const appendToSeries = (identifier, list) => {
+  if (!chart) return
+
+  const idx = Object.keys(chartMap.value).indexOf(identifier)
+  if (idx === -1) return
+
+  const data = list.map(mapData)
+
+  chart.setOption({
+    series: [
+      {
+        name: chartMap.value[identifier].name,
+        data
+      }
+    ]
+  })
+}
+
+const mapData = ({ value, ts }) => {
+  if (value === 0) return [ts, 0, 0]
+
+  const isPositive = value > 0
+  const absItem = Math.abs(value)
+
+  const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
+  const min_index = intervalArr.value.findIndex((v) => v === min_value)
+
+  const new_value =
+    (absItem - min_value) / (intervalArr.value[min_index + 1] - intervalArr.value[min_index]) +
+    min_index
+
+  return [ts, isPositive ? new_value : -new_value, value]
+}
+
+onMounted(async () => {
+  await loadSpecs()
+  render()
+  initLoad()
+})
+
+onUnmounted(() => {
+  stopAutoFetch()
+
+  window.removeEventListener('resize', () => {
+    if (chart) chart.resize()
+  })
+})
+</script>
+
+<template>
+  <div
+    class="w-full bg-gradient-to-r from-blue-100 to-white rounded-lg p-6 shadow"
+    id="td-device-info"
+  >
+    <h2 class="flex items-center gap-2">
+      <el-icon class="text-sky!"><Odometer /></el-icon> 设备基础信息
+    </h2>
+    <el-form size="large" label-position="top" class="mt-4 grid grid-cols-3 gap-4">
+      <el-form-item label="资产编码"> {{ data.deviceCode }} </el-form-item>
+      <el-form-item label="设备类别"> {{ data.deviceName }} </el-form-item>
+      <el-form-item label="所在部门"> {{ data.dept }} </el-form-item>
+      <el-form-item label="网关状态" class="online" type="plain">
+        <el-tag
+          v-if="data.ifInline === '3'"
+          type="success"
+          size="default"
+          class="flex items-center"
+        >
+          <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
+          在线
+        </el-tag>
+
+        <el-tag v-if="data.ifInline === '4'" type="danger" size="default" class="flex items-center">
+          <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
+          离线
+        </el-tag>
+      </el-form-item>
+      <el-form-item v-if="data.carOnline" label="中航北斗" class="online" type="plain">
+        <el-tag
+          v-if="data.carOnline === 'true'"
+          type="success"
+          size="default"
+          class="flex items-center"
+        >
+          <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
+          在线
+        </el-tag>
+
+        <el-tag
+          v-if="data.carOnline === 'false'"
+          type="danger"
+          size="default"
+          class="flex items-center"
+        >
+          <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
+          离线
+        </el-tag>
+      </el-form-item>
+      <el-form-item label="最后数据时间"> {{ data.lastInlineTime }} </el-form-item>
+      <el-form-item v-if="data.vehicle" label="车牌号码"> {{ data.vehicle }} </el-form-item>
+    </el-form>
+  </div>
+  <div class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow min-h-50">
+    <header class="font-medium text-center w-full">网关数采</header>
+    <div
+      v-loading="specsLoading"
+      element-loading-background="transparent"
+      class="w-full mt-4 grid grid-cols-4 gap-4 min-h-30"
+      id="dimension"
+    >
+      <div
+        v-for="item in gatewayspecs"
+        :key="item.productId"
+        class="h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-6 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
+        :class="{
+          'bg-blue-200': selectSpec[item.modelName]
+        }"
+        @click="!disabledIdentifier.includes(item.identifier) && handleClickSpec(item.modelName)"
+      >
+        <span class="text-sm text-[var(--el-text-color-regular)]">{{ item.modelName }}</span>
+        <span class="text-lg font-medium ms-a">{{ item.value }}</span>
+      </div>
+    </div>
+  </div>
+  <div
+    v-if="zhbdspecs.length"
+    class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow"
+  >
+    <header class="font-medium text-center w-full">中航北斗</header>
+    <div class="w-full mt-4 grid grid-cols-4 gap-4" id="dimension">
+      <div
+        v-for="item in zhbdspecs"
+        :key="item.productId"
+        class="h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-6 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
+        :class="{
+          'bg-blue-200': selectSpec[item.modelName]
+        }"
+        @click="!disabledIdentifier.includes(item.identifier) && handleClickSpec(item.modelName)"
+      >
+        <span class="text-sm text-[var(--el-text-color-regular)]">{{ item.modelName }}</span>
+        <span class="text-lg font-medium ms-a">{{ item.value }}</span>
+      </div>
+    </div>
+  </div>
+  <div class="w-full mt-4 bg-gradient-to-r from-blue-100 to-white min-h-175 rounded-lg p-6 shadow">
+    <header class="flex items-center justify-between">
+      <h3 class="flex items-center gap-2">
+        <div class="i-material-symbols:area-chart-outline-rounded text-sky size-6" text-sky></div>
+        数据趋势
+      </h3>
+
+      <div class="flex gap-4">
+        <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
+        <el-button size="default" @click="reset">重置</el-button>
+        <el-date-picker
+          v-model="date"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="datetimerange"
+          unlink-panels
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :shortcuts="rangeShortcuts"
+          size="default"
+          class="w-100!"
+          placement="bottom-end"
+          @change="handleDateChange"
+        />
+      </div>
+    </header>
+    <div
+      v-loading="specsLoading"
+      element-loading-background="transparent"
+      ref="chartRef"
+      class="w-full h-158 mt-4 mb-4"
+    ></div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+:deep(.el-form-item) {
+  margin-bottom: 0;
+
+  .el-form-item__label {
+    margin-bottom: 0;
+  }
+
+  .el-form-item__content {
+    font-size: 1rem;
+    font-weight: 500;
+  }
+
+  &.online {
+    .el-form-item__content {
+      height: 2.5rem;
+
+      .el-tag__content {
+        display: flex;
+        align-items: center;
+        gap: 2px;
+      }
+    }
+  }
+}
+</style>

+ 0 - 275
src/views/pms/device/monitor/TdDeviceInfo2.vue

@@ -1,275 +0,0 @@
-<script setup lang="ts">
-import { Odometer, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
-import { rangeShortcuts } from '@/utils/formatTime'
-import { IotDeviceApi } from '@/api/pms/device'
-import dayjs from 'dayjs'
-import { IotStatApi } from '@/api/pms/stat'
-
-import { use } from 'echarts/core'
-import { LineChart } from 'echarts/charts'
-import {
-  TitleComponent,
-  TooltipComponent,
-  LegendComponent,
-  GridComponent
-} from 'echarts/components'
-import { CanvasRenderer } from 'echarts/renderers'
-
-use([TitleComponent, TooltipComponent, LegendComponent, GridComponent, LineChart, CanvasRenderer])
-
-defineOptions({ name: 'TdDeviceDetail' })
-
-const { query } = useRoute()
-
-const data = ref({
-  deviceCode: query.code || '',
-  deviceName: query.name || '',
-  lastInlineTime: query.time || '',
-  ifInline: query.ifInline || '',
-  dept: query.dept || '',
-  vehicle: query.vehicle || '',
-  carOnline: query.carOnline || ''
-})
-
-// 首先加载 维度 分为两种一种 网关维度 一种中航北斗维度
-interface Dimension {
-  name: string
-  identifier: string
-  value: string
-}
-
-const dimensions = ref<Dimension[]>([])
-const gatewayDimensions = ref<Dimension[]>([])
-const carDimensions = ref<Dimension[]>([])
-
-// 一部分值不能展示在图表上
-const disabledDimensions = ['车牌号码', '是否在线']
-
-// 选中的维度
-const selectedDimensions = ref<{ [key: Dimension['name']]: boolean }>({})
-
-const dimensionLoading = ref(false)
-
-const loadDimensions = async () => {
-  if (!query.id) return
-  dimensionLoading.value = true
-
-  // 网关维度
-  const gateway = (((await IotDeviceApi.getIotDeviceTds(Number(query.id))) as any[]) ?? [])
-    .sort((a, b) => b.modelOrder - a.modelOrder)
-    .map((v) => ({ name: v.modelName, identifier: v.identifier, value: v.value }))
-
-  // 中航北斗维度
-  const car = (((await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))) as any[]) ?? [])
-    .sort((a, b) => b.modelOrder - a.modelOrder)
-    .map((v) => ({ name: v.modelName, identifier: v.identifier, value: v.value }))
-
-  // 合并维度 过滤不可展示维度
-  dimensions.value = [...gateway, ...car].filter((v) => !disabledDimensions.includes(v.name))
-  gatewayDimensions.value = gateway
-  carDimensions.value = car
-
-  // 生成 chart legend selected
-  selectedDimensions.value = Object.fromEntries(dimensions.value.map((v) => [v.name, false]))
-
-  // 默认选中第一个
-  selectedDimensions.value[dimensions.value[0].name] = true
-
-  dimensionLoading.value = false
-
-  chartData.value = Object.fromEntries(dimensions.value.map((v) => [v.name, []]))
-}
-
-// 时间范围
-const dateRange = ref<string[]>([
-  ...rangeShortcuts[3].value().map((v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'))
-])
-
-// 获取图表数据
-const chartData = ref<{ [key: Dimension['name']]: number[][] }>({})
-
-const loadChartData = async () => {
-  if (!dimensions.value.length) return
-
-  chartData.value = Object.fromEntries(dimensions.value.map((v) => [v.name, []]))
-
-  for (const { name, identifier } of dimensions.value) {
-    const res = await IotStatApi.getDeviceInfoChart(
-      data.value.deviceCode,
-      identifier,
-      dateRange.value[0],
-      dateRange.value[1]
-    )
-
-    const sorted = res.sort((a, b) => a.ts - b.ts)
-    const values = sorted.map((v) => v.value)
-
-    // --- 归一化参数 ---
-    const min = Math.min(...values)
-    const max = Math.max(...values)
-    const range = max - min || 1 // 防止除 0
-
-    const seriesData = sorted.map((v) => {
-      const norm = (v.value - min) / range
-      return [v.ts, norm, v.value] // ts, 归一化值, 原始值
-    })
-
-    chartData.value[name] = seriesData
-  }
-
-  console.log('chartData :>> ', Object.values(chartData.value))
-}
-
-onMounted(() => {
-  loadDimensions().then(() => {
-    loadChartData()
-  })
-})
-</script>
-
-<template>
-  <div
-    class="w-full bg-gradient-to-r from-blue-100 to-white rounded-lg p-6 shadow"
-    id="td-device-info"
-  >
-    <h2 class="flex items-center gap-2">
-      <el-icon class="text-sky!"><Odometer /></el-icon> 设备基础信息
-    </h2>
-    <el-form size="large" label-position="top" class="mt-4 grid grid-cols-3 gap-4">
-      <el-form-item label="资产编码"> {{ data.deviceCode }} </el-form-item>
-      <el-form-item label="设备类别"> {{ data.deviceName }} </el-form-item>
-      <el-form-item label="所在部门"> {{ data.dept }} </el-form-item>
-      <el-form-item label="网关状态" class="online" type="plain">
-        <el-tag
-          v-if="data.ifInline === '3'"
-          type="success"
-          size="default"
-          class="flex items-center"
-        >
-          <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
-          在线
-        </el-tag>
-
-        <el-tag v-if="data.ifInline === '4'" type="danger" size="default" class="flex items-center">
-          <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
-          离线
-        </el-tag>
-      </el-form-item>
-      <el-form-item v-if="data.carOnline" label="中航北斗" class="online" type="plain">
-        <el-tag
-          v-if="data.carOnline === 'true'"
-          type="success"
-          size="default"
-          class="flex items-center"
-        >
-          <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
-          在线
-        </el-tag>
-
-        <el-tag
-          v-if="data.carOnline === 'false'"
-          type="danger"
-          size="default"
-          class="flex items-center"
-        >
-          <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
-          离线
-        </el-tag>
-      </el-form-item>
-      <el-form-item label="最后数据时间"> {{ data.lastInlineTime }} </el-form-item>
-      <el-form-item v-if="data.vehicle" label="车牌号码"> {{ data.vehicle }} </el-form-item>
-    </el-form>
-  </div>
-  <div class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow min-h-50">
-    <header class="font-medium text-center w-full">网关数采</header>
-    <div
-      v-loading="dimensionLoading"
-      element-loading-background="transparent"
-      class="w-full mt-4 grid grid-cols-4 gap-4 min-h-30"
-      id="dimension"
-    >
-      <button
-        v-for="item in gatewayDimensions"
-        :key="item.identifier"
-        class="outline-none border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-6 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
-        :class="{ 'bg-blue-200': selectedDimensions[item.name] }"
-        :disabled="disabledDimensions.includes(item.name)"
-      >
-        <span class="text-sm text-[var(--el-text-color-regular)]">{{ item.name }}</span>
-        <span class="text-lg font-medium ms-a">{{ item.value }}</span>
-      </button>
-    </div>
-  </div>
-  <div class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow min-h-50">
-    <header class="font-medium text-center w-full">中航北斗</header>
-    <div
-      v-loading="dimensionLoading"
-      element-loading-background="transparent"
-      class="w-full mt-4 grid grid-cols-4 gap-4 min-h-30"
-      id="dimension"
-    >
-      <button
-        v-for="item in carDimensions"
-        :key="item.identifier"
-        class="outline-none border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-6 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
-        :class="{ 'bg-blue-200': selectedDimensions[item.name] }"
-        :disabled="disabledDimensions.includes(item.name)"
-      >
-        <span class="text-sm text-[var(--el-text-color-regular)]">{{ item.name }}</span>
-        <span class="text-lg font-medium ms-a">{{ item.value }}</span>
-      </button>
-    </div>
-  </div>
-  <div class="w-full mt-4 bg-gradient-to-r from-blue-100 to-white min-h-175 rounded-lg p-6 shadow">
-    <header class="flex items-center justify-between">
-      <h3 class="flex items-center gap-2">
-        <div class="i-material-symbols:area-chart-outline-rounded text-sky size-6" text-sky></div>
-        数据趋势
-      </h3>
-
-      <div class="flex gap-4">
-        <!-- <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
-        <el-button size="default" @click="reset">重置</el-button> -->
-        <el-date-picker
-          v-model="dateRange"
-          value-format="YYYY-MM-DD HH:mm:ss"
-          type="datetimerange"
-          unlink-panels
-          start-placeholder="开始日期"
-          end-placeholder="结束日期"
-          :shortcuts="rangeShortcuts"
-          size="default"
-          class="w-100!"
-          placement="bottom-end"
-        />
-      </div>
-    </header>
-  </div>
-</template>
-
-<style lang="scss" scoped>
-:deep(.el-form-item) {
-  margin-bottom: 0;
-
-  .el-form-item__label {
-    margin-bottom: 0;
-  }
-
-  .el-form-item__content {
-    font-size: 1rem;
-    font-weight: 500;
-  }
-
-  &.online {
-    .el-form-item__content {
-      height: 2.5rem;
-
-      .el-tag__content {
-        display: flex;
-        align-items: center;
-        gap: 2px;
-      }
-    }
-  }
-}
-</style>

+ 52 - 0
src/views/pms/device/monitor/color.ts

@@ -0,0 +1,52 @@
+export const colors = [
+  '#5470C6',
+  '#91CC75',
+  '#FAC858',
+  '#EE6666',
+  '#73C0DE',
+  '#3BA272',
+  '#FC8452',
+  '#9A60B4',
+  '#EA7CCC',
+  '#2E91E5',
+  '#1CA71C',
+  '#FB0D0D',
+  '#DA16FF',
+  '#222A2A',
+  '#B68100',
+  '#750D86',
+  '#EB663B',
+  '#0D2A63',
+  '#87BC45',
+  '#F58518',
+  '#8C564B',
+  '#7F7F7F',
+  '#BCBD22',
+  '#17BECF',
+  '#4C72B0',
+  '#55A868',
+  '#C44E52',
+  '#8172B2',
+  '#CCB974',
+  '#64B5CD',
+  '#4E79A7',
+  '#F28E2B',
+  '#E15759',
+  '#76B7B2',
+  '#59A14F',
+  '#EDC948',
+  '#B07AA1',
+  '#FF9DA7',
+  '#9C755F',
+  '#BAB0AC',
+  '#1F77B4',
+  '#AEC7E8',
+  '#FF7F0E',
+  '#FFBB78',
+  '#2CA02C',
+  '#98DF8A',
+  '#D62728',
+  '#FF9896',
+  '#9467BD',
+  '#C5B0D5'
+]