Jelajahi Sumber

Merge branch 'master' into videoCenter

Zimo 19 jam lalu
induk
melakukan
9e816d5d08

+ 1 - 0
src/api/pms/iotopeationfill/index.ts

@@ -3,6 +3,7 @@ import request from '@/config/axios'
 // 运行记录填报 VO
 export interface IotOpeationFillVO {
   id: any // 主键id
+  wellName: string // 井号
   deviceCode: string // 资产编号
   deviceCategoryId: number
   deviceName: string // 设备名称

+ 115 - 0
src/utils/td-color.ts

@@ -239,3 +239,118 @@ export const colors = [
   '#FFD700', // 金色
   '#FF00FF' // 洋红
 ]
+
+export const neonColors = [
+  '#5470C6',
+  '#FAC858',
+  '#EE6666',
+  '#3BA272',
+  '#9A60B4',
+  '#2E91E5',
+  '#DA16FF',
+  '#17BECF',
+  '#FF006E',
+  '#3A86FF',
+  '#8338EC',
+  '#FFBE0B',
+  '#FB5607',
+  '#06D6A0',
+  '#EF476F',
+  '#118AB2',
+  '#00FFCC',
+  '#F72585',
+  '#43AA8B',
+  '#F94144',
+  '#9D4EDD',
+  '#4CC9F0',
+  '#72EFDD',
+  '#F9C74F',
+  '#FF1744',
+  '#651FFF',
+  '#2979FF',
+  '#00E676',
+  '#FFAB00',
+  '#D50000',
+  '#3D5AFE',
+  '#2962FF',
+  '#00C853',
+  '#FF6D00',
+  '#FF4081',
+  '#6200EA',
+  '#00B0FF',
+  '#69F0AE',
+  '#FF3D00',
+  '#AA00FF',
+  '#0091EA',
+  '#76FF03',
+  '#DD2C00',
+  '#F50057',
+  '#D500F9',
+  '#00E5FF',
+  '#64DD17',
+  '#FF9100',
+  '#C51162',
+  '#E040FB',
+  '#00B8D4',
+  '#C6FF00',
+  '#FF6E40',
+  '#FF80AB',
+  '#7C4DFF',
+  '#18FFFF',
+  '#FF5252',
+  '#B388FF',
+  '#84FFFF',
+  '#AEEA00',
+  '#FF2D55',
+  '#8C9EFF',
+  '#009688',
+  '#AED581',
+  '#FFAB40',
+  '#E91E63',
+  '#536DFE',
+  '#26A69A',
+  '#7CB342',
+  '#FFD740',
+  '#EC407A',
+  '#304FFE',
+  '#00897B',
+  '#33691E',
+  '#FFC400',
+  '#AD1457',
+  '#7B1FA2',
+  '#0284C7',
+  '#2E7D32',
+  '#FFA000',
+  '#FF0055',
+  '#9C27B0',
+  '#0369A1',
+  '#43A047',
+  '#FF6F00',
+  '#DB2777',
+  '#AB47BC',
+  '#0EA5E9',
+  '#10B981',
+  '#F57C00',
+  '#BE185D',
+  '#8E24AA',
+  '#0284C7',
+  '#059669',
+  '#F59E0B',
+  '#9F1239',
+  '#6A1B9A',
+  '#38BDF8',
+  '#34D399',
+  '#D97706',
+  '#FB7185',
+  '#4A148C',
+  '#06B6D4',
+  '#84CC16',
+  '#EA580C',
+  '#F43F5E',
+  '#701A75',
+  '#65A30D',
+  '#C2410C',
+  '#E11D48',
+  '#4D7C0F',
+  '#FB923C'
+]

+ 778 - 0
src/views/oli-connection/monitoring/detail copy.vue

@@ -0,0 +1,778 @@
+<script setup lang="ts">
+import { Odometer, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
+import { IotDeviceApi } from '@/api/pms/device'
+import dayjs from 'dayjs'
+import { rangeShortcuts } from '@/utils/formatTime'
+import { IotStatApi, cancelAllRequests } from '@/api/pms/stat'
+
+import * as echarts from 'echarts'
+import { colors } from '@/utils/td-color'
+import { useSocketBus } from '@/utils/useSocketBus'
+
+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 { open: connect, onAny, close } = useSocketBus(data.value.deviceCode as string)
+
+onAny((msg) => {
+  if (!Array.isArray(msg) || msg.length === 0) return
+
+  const valueMap = new Map<string, number>()
+
+  for (const item of msg) {
+    const { identity, modelName, readTime, logValue } = item
+
+    const value = logValue ? Number(logValue) : 0
+
+    if (identity) {
+      valueMap.set(identity, value)
+    }
+
+    if (modelName && chartData.value[modelName]) {
+      chartData.value[modelName].push({
+        ts: dayjs(readTime).valueOf(),
+        value
+      })
+
+      updateSingleSeries(modelName)
+    }
+  }
+
+  const updateDimensions = (list) => {
+    list.forEach((item) => {
+      const v = valueMap.get(item.identifier)
+      if (v !== undefined) {
+        item.value = v
+      }
+    })
+  }
+
+  updateDimensions(dimensions.value)
+  updateDimensions(gatewayDimensions.value)
+  updateDimensions(carDimensions.value)
+
+  // 3️⃣ 统一一次调用
+  genderIntervalArr()
+})
+
+interface Dimensions {
+  identifier: string
+  name: string
+  value: string
+  color?: string
+  response?: boolean
+}
+
+const dimensions = ref<Dimensions[]>([])
+const gatewayDimensions = ref<Dimensions[]>([])
+const carDimensions = ref<Dimensions[]>([])
+
+const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
+
+interface SelectedDimension {
+  [key: Dimensions['name']]: boolean
+}
+
+const selectedDimension = ref<SelectedDimension>({})
+
+const dimensionLoading = ref(false)
+
+const disabledDimension = computed(() => (identifier: string) => {
+  const response = dimensions.value.find((item) => item.identifier === identifier)?.response
+
+  return { disabled: disabledDimensions.value.includes(identifier) || response, loading: response }
+})
+
+async function loadDimensions() {
+  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((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
+    }))
+
+  dimensions.value = [...gateway, ...car]
+    .filter((item) => !disabledDimensions.value.includes(item.identifier))
+    .map((item, index) => ({
+      ...item,
+      color: colors[index]
+    }))
+
+  gatewayDimensions.value = gateway
+  carDimensions.value = car
+
+  selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
+
+  selectedDimension.value[dimensions.value[0].name] = true
+
+  dimensionLoading.value = false
+}
+
+// async function updateDimensionValues() {
+//   if (!query.id) return
+
+//   try {
+//     // 1. 并行获取最新数据
+//     const [gatewayRes, carRes] = await Promise.all([
+//       IotDeviceApi.getIotDeviceTds(Number(query.id)),
+//       IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))
+//     ])
+
+//     // 2. 创建一个 Map 用于快速查找 (Identifier -> Value)
+//     // 这样可以将复杂度从 O(N*M) 降低到 O(N)
+//     const newValueMap = new Map<string, any>()
+
+//     const addToMap = (data: any[]) => {
+//       if (!data) return
+//       data.forEach((item) => {
+//         if (item.identifier) {
+//           newValueMap.set(item.identifier, item.value)
+//         }
+//       })
+//     }
+
+//     addToMap(gatewayRes as any[])
+//     addToMap(carRes as any[])
+
+//     // 3. 更新 dimensions.value (保留了之前的 color 和其他属性)
+//     dimensions.value.forEach((item) => {
+//       if (newValueMap.has(item.identifier)) {
+//         item.value = newValueMap.get(item.identifier)
+//       }
+//     })
+
+//     // 4. 如果还需要同步更新 gatewayDimensions 和 carDimensions
+//     // (假设这些是引用类型,如果它们引用的是同一个对象,上面更新 dimensions 时可能已经同步了。
+//     // 如果它们是独立的对象数组,则需要显式更新)
+
+//     // 更新 Gateway 原始列表
+//     gatewayDimensions.value.forEach((item) => {
+//       if (newValueMap.has(item.identifier)) {
+//         item.value = newValueMap.get(item.identifier)
+//       }
+//     })
+
+//     // 更新 Car 原始列表
+//     carDimensions.value.forEach((item) => {
+//       if (newValueMap.has(item.identifier)) {
+//         item.value = newValueMap.get(item.identifier)
+//       }
+//     })
+//   } catch (error) {
+//     console.error('Failed to update dimension values:', error)
+//   }
+// }
+
+const selectedDate = ref<string[]>([
+  dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
+  dayjs().format('YYYY-MM-DD HH:mm:ss')
+])
+
+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
+
+// const genderIntervalArrDebounce = useDebounceFn(
+//   (init: boolean = false) => genderIntervalArr(init),
+//   300
+// )
+
+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) => {
+    selectedDimension.value = params.selected
+  })
+
+  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({
+    animation: true,
+    animationDuration: 200,
+    animationEasing: 'linear',
+    animationDurationUpdate: 200,
+    animationEasingUpdate: 'linear',
+    grid: {
+      left: '6%',
+      top: '5%',
+      right: '6%',
+      bottom: '12%'
+    },
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'line'
+      },
+      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) => `<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('')
+      }
+    },
+    xAxis: {
+      type: 'time',
+      axisLabel: {
+        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,
+      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()
+        }
+      },
+      show: false
+    },
+    legend: {
+      data: dimensions.value.map((item) => item.name),
+      selected: selectedDimension.value,
+      show: false
+    },
+    // series: dimensions.value.map((item) => ({
+    //   name: item.name,
+    //   type: 'line',
+    //   smooth: true,
+    //   showSymbol: false,
+    //   color: item.color,
+    //   data: [] // 占位数组
+    // }))
+    series: dimensions.value.map((item) => ({
+      name: item.name,
+      type: 'line',
+
+      smooth: 0.2,
+
+      showSymbol: false,
+
+      endLabel: {
+        show: true,
+        formatter: (params) => params.value[2]?.toFixed(2),
+        offset: [6, 0],
+        color: item.color,
+        fontSize: 12
+      },
+
+      emphasis: {
+        focus: 'series'
+      },
+
+      lineStyle: {
+        width: 2
+      },
+
+      color: item.color,
+      data: [] // 占位数组
+    }))
+  })
+}
+
+function mapData({ value, ts }) {
+  if (!value) 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 = 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>>({})
+
+// async function fetchIncrementData() {
+//   for (const item of dimensions.value) {
+//     const { identifier, name } = item
+
+//     const lastTs = lastTsMap.value[name]
+//     if (!lastTs) continue
+
+//     item.response = true
+
+//     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)
+//           .map((item) => ({ ts: item.ts, value: item.value }))
+//         // push 到本地
+//         chartData.value[name].push(...sorted)
+//         // 更新 lastTs
+//         lastTsMap.value[identifier] = sorted.at(-1).ts
+
+//         // 更新图表
+//         updateSingleSeries(name)
+//       })
+//       .finally(() => {
+//         item.response = false
+//       })
+//   }
+// }
+
+// const timer = ref<NodeJS.Timeout | null>(null)
+
+// function startAutoFetch() {
+//   timer.value = setInterval(() => {
+//     updateDimensionValues()
+//     fetchIncrementData()
+//   }, 10000)
+// }
+
+// function stopAutoFetch() {
+//   cancelAllRequests()
+//   if (timer.value) clearInterval(timer.value)
+//   timer.value = null
+// }
+
+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(
+        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()
+      }
+    } finally {
+      item.response = false
+    }
+  }
+
+  if (real_time) {
+    // startAutoFetch()
+    connect()
+  }
+}
+
+async function initfn(load: boolean = true, real_time: boolean = true) {
+  if (load) await loadDimensions()
+  render()
+  initLoadChartData(real_time)
+}
+
+onMounted(() => {
+  initfn()
+})
+
+function reset() {
+  cancelAllRequests().then(() => {
+    selectedDate.value = [
+      dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
+      dayjs().format('YYYY-MM-DD HH:mm:ss')
+    ]
+
+    close()
+    // stopAutoFetch()
+    if (chart) chart.clear()
+    initfn(false)
+  })
+}
+
+function handleDateChange() {
+  cancelAllRequests().then(() => {
+    close()
+    // 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()
+  genderIntervalArr()
+}
+
+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(() => {
+  // stopAutoFetch()
+  close()
+
+  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-4 shadow"
+    id="td-device-info"
+  >
+    <h2 class="flex items-center gap-2">
+      <el-icon class="text-sky!"><Odometer /></el-icon> 设备基础信息
+    </h2>
+    <el-form size="default" label-position="top" class="mt-4 grid grid-cols-4 gap-2">
+      <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-5 gap-2 min-h-30"
+      id="dimension"
+    >
+      <button
+        v-for="item in gatewayDimensions"
+        :key="item.identifier"
+        class="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-8 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
+        :class="{ 'bg-blue-200': selectedDimension[item.name] }"
+        :disabled="disabledDimension(item.identifier).disabled"
+        @click="handleClickSpec(item.name)"
+      >
+        <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2 relative">
+          <!-- <i
+            v-show="disabledDimension(item.identifier).loading"
+            class="i-line-md:loading-loop size-5 absolute -left-6"
+          ></i> -->
+          {{ item.name }}
+        </span>
+        <!-- <span class="text-lg font-medium ms-a">{{ item.value }}</span> -->
+        <animated-count-to :value="item.value" will-change class="text-lg font-medium ms-a" />
+      </button>
+    </div>
+  </div>
+  <div
+    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-5 gap-2 min-h-30" id="dimension">
+      <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="disabledDimension(item.identifier).disabled"
+        @click="handleClickSpec(item.name)"
+      >
+        <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2">
+          <!-- <i
+            v-show="disabledDimension(item.identifier).loading"
+            class="i-line-md:loading-loop size-5"
+          ></i> -->
+          {{ 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="selectedDate"
+          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 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>
+
+<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>

+ 356 - 225
src/views/oli-connection/monitoring/detail.vue

@@ -1,13 +1,21 @@
-<script setup lang="ts">
-import { Odometer, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import { useRoute } from 'vue-router'
 import { IotDeviceApi } from '@/api/pms/device'
+import {
+  Odometer,
+  CircleCheckFilled,
+  CircleCloseFilled,
+  DataLine,
+  TrendCharts
+} from '@element-plus/icons-vue'
+import { AnimatedCountTo } from '@/components/AnimatedCountTo'
+import { neonColors } from '@/utils/td-color'
 import dayjs from 'dayjs'
-import { rangeShortcuts } from '@/utils/formatTime'
-import { IotStatApi, cancelAllRequests } from '@/api/pms/stat'
-
 import * as echarts from 'echarts'
-import { colors } from '@/utils/td-color'
+import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
 import { useSocketBus } from '@/utils/useSocketBus'
+import { rangeShortcuts } from '@/utils/formatTime'
 
 const { query } = useRoute()
 
@@ -15,10 +23,10 @@ const data = ref({
   deviceCode: query.code || '',
   deviceName: query.name || '',
   lastInlineTime: query.time || '',
-  ifInline: query.ifInline || '',
+  ifInline: query.ifInline === '3',
   dept: query.dept || '',
   vehicle: query.vehicle || '',
-  carOnline: query.carOnline || ''
+  carOnline: query.carOnline === 'true'
 })
 
 const { open: connect, onAny, close } = useSocketBus(data.value.deviceCode as string)
@@ -28,7 +36,6 @@ onAny((msg) => {
 
   const valueMap = new Map<string, number>()
 
-  // 1️⃣ 一次遍历:建 Map + 推图表数据
   for (const item of msg) {
     const { identity, modelName, readTime, logValue } = item
 
@@ -48,7 +55,6 @@ onAny((msg) => {
     }
   }
 
-  // 2️⃣ 批量更新 dimensions
   const updateDimensions = (list) => {
     list.forEach((item) => {
       const v = valueMap.get(item.identifier)
@@ -66,11 +72,40 @@ onAny((msg) => {
   genderIntervalArr()
 })
 
+function hexToRgba(hex: string, alpha: number) {
+  const r = parseInt(hex.slice(1, 3), 16)
+  const g = parseInt(hex.slice(3, 5), 16)
+  const b = parseInt(hex.slice(5, 7), 16)
+  return `rgba(${r}, ${g}, ${b}, ${alpha})`
+}
+
+interface HeaderItem {
+  label: string
+  key: keyof typeof data.value
+  judgment?: boolean
+}
+
+const headerCenterContent: HeaderItem[] = [
+  { label: '设备名称', key: 'deviceName' },
+  { label: '所属部门', key: 'dept' },
+  { label: '车牌号码', key: 'vehicle', judgment: true },
+  { label: '最后上报时间', key: 'lastInlineTime' }
+]
+
+const tagProps = { size: 'default', round: true } as const
+
+const headerTagContent: HeaderItem[] = [
+  { label: '网关', key: 'ifInline' },
+  { label: '北斗', key: 'carOnline', judgment: true }
+]
+
 interface Dimensions {
   identifier: string
   name: string
-  value: string
-  color?: string
+  value: string | number
+  color: string
+  bgHover: string
+  bgActive: string
   response?: boolean
 }
 
@@ -78,57 +113,80 @@ const dimensions = ref<Dimensions[]>([])
 const gatewayDimensions = ref<Dimensions[]>([])
 const carDimensions = ref<Dimensions[]>([])
 
-const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
-
-interface SelectedDimension {
-  [key: Dimensions['name']]: boolean
-}
-
-const selectedDimension = ref<SelectedDimension>({})
+const dimensionsContent = computed(() => [
+  {
+    label: '网关数采',
+    icon: DataLine,
+    value: gatewayDimensions.value,
+    countColor: 'text-blue-600',
+    countBg: 'bg-blue-50'
+  },
+  {
+    label: '中航北斗',
+    icon: TrendCharts,
+    value: carDimensions.value,
+    countColor: 'text-indigo-600',
+    countBg: 'bg-indigo-50',
+    judgment: true
+  }
+])
 
+const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
+const selectedDimension = ref<Record<string, boolean>>({})
 const dimensionLoading = ref(false)
 
-const disabledDimension = computed(() => (identifier: string) => {
-  const response = dimensions.value.find((item) => item.identifier === identifier)?.response
-
-  return { disabled: disabledDimensions.value.includes(identifier) || response, loading: response }
-})
-
 async function loadDimensions() {
   if (!query.id) return
-
   dimensionLoading.value = true
+  try {
+    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,
+        response: false
+      }))
+
+    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,
+        response: false
+      }))
+
+    // 合并并分配霓虹色
+    dimensions.value = [...gateway, ...car]
+      .filter((item) => !disabledDimensions.value.includes(item.identifier))
+      .map((item, index) => {
+        const color = neonColors[index]
+
+        return {
+          ...item,
+          color: color,
+          bgHover: hexToRgba(color, 0.08),
+          bgActive: hexToRgba(color, 0.12)
+        }
+      })
 
-  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
-    }))
-
-  dimensions.value = [...gateway, ...car]
-    .filter((item) => !disabledDimensions.value.includes(item.identifier))
-    .map((item, index) => ({
-      ...item,
-      color: colors[index]
-    }))
-
-  gatewayDimensions.value = gateway
-  carDimensions.value = car
-
-  selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
-
-  selectedDimension.value[dimensions.value[0].name] = true
+    gatewayDimensions.value = dimensions.value.filter((d) =>
+      gateway.some((g) => g.identifier === d.identifier)
+    )
+    carDimensions.value = dimensions.value.filter((d) =>
+      car.some((c) => c.identifier === d.identifier)
+    )
 
-  dimensionLoading.value = false
+    selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
+    if (dimensions.value.length > 0) {
+      selectedDimension.value[dimensions.value[0].name] = true
+    }
+  } catch (e) {
+    console.error(e)
+  } finally {
+    dimensionLoading.value = false
+  }
 }
 
 // async function updateDimensionValues() {
@@ -370,17 +428,24 @@ function render() {
 }
 
 function mapData({ value, ts }) {
-  if (!value) return [ts, 0, 0]
+  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)
 
-  const new_value =
-    (absItem - min_value) / (intervalArr.value[min_index + 1] - intervalArr.value[min_index]) +
-    min_index
+  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]
 }
@@ -483,13 +548,15 @@ async function initLoadChartData(real_time: boolean = true) {
 
       lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
 
+      genderIntervalArr(true)
+
       updateSingleSeries(name)
 
       chartLoading.value = false
 
-      if (selectedDimension.value[name]) {
-        genderIntervalArr()
-      }
+      // if (selectedDimension.value[name]) {
+      //   genderIntervalArr()
+      // }
     } finally {
       item.response = false
     }
@@ -541,8 +608,16 @@ function handleClickSpec(modelName: string) {
       selected: selectedDimension.value
     }
   })
-  chart?.resize()
+
   genderIntervalArr()
+
+  if (selectedDimension.value[modelName]) {
+    updateSingleSeries(modelName)
+  }
+
+  nextTick(() => {
+    chart?.resize()
+  })
 }
 
 const exportChart = () => {
@@ -579,6 +654,7 @@ const maxmin = computed(() => {
     .map((v) => ({
       name: v.name,
       color: v.color,
+      bgHover: v.bgHover,
       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)
     }))
@@ -596,185 +672,240 @@ onUnmounted(() => {
 
 <template>
   <div
-    class="w-full bg-gradient-to-r from-blue-100 to-white rounded-lg p-4 shadow"
-    id="td-device-info"
+    class="grid grid-cols-[260px_1fr] grid-rows-[80px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
   >
-    <h2 class="flex items-center gap-2">
-      <el-icon class="text-sky!"><Odometer /></el-icon> 设备基础信息
-    </h2>
-    <el-form size="default" label-position="top" class="mt-4 grid grid-cols-4 gap-2">
-      <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-5 gap-2 min-h-30"
-      id="dimension"
+      class="grid-col-span-2 bg-white rounded-xl shadow-sm border border-gray-100 border-solid px-6 flex items-center justify-between shrink-0"
     >
-      <button
-        v-for="item in gatewayDimensions"
-        :key="item.identifier"
-        class="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-8 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
-        :class="{ 'bg-blue-200': selectedDimension[item.name] }"
-        :disabled="disabledDimension(item.identifier).disabled"
-        @click="handleClickSpec(item.name)"
-      >
-        <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2 relative">
-          <!-- <i
-            v-show="disabledDimension(item.identifier).loading"
-            class="i-line-md:loading-loop size-5 absolute -left-6"
-          ></i> -->
-          {{ item.name }}
-        </span>
-        <!-- <span class="text-lg font-medium ms-a">{{ item.value }}</span> -->
-        <animated-count-to :value="item.value" will-change class="text-lg font-medium ms-a" />
-      </button>
-    </div>
-  </div>
-  <div
-    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-5 gap-2 min-h-30" id="dimension">
-      <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="disabledDimension(item.identifier).disabled"
-        @click="handleClickSpec(item.name)"
-      >
-        <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2">
-          <!-- <i
-            v-show="disabledDimension(item.identifier).loading"
-            class="i-line-md:loading-loop size-5"
-          ></i> -->
-          {{ 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="selectedDate"
-          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 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)"
+      <div class="flex items-center gap-4">
+        <div
+          class="size-12 rounded-lg bg-blue-50 text-blue-600 flex items-center justify-center shadow-inner"
         >
-          <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>
+          <el-icon :size="24"><Odometer /></el-icon>
+        </div>
+        <div>
+          <div class="text-xs text-gray-400 font-medium tracking-wider">资产编码</div>
+          <div class="text-xl font-bold font-mono text-gray-800">{{ data.deviceCode }}</div>
+        </div>
+      </div>
+      <div class="flex-1 flex justify-center divide-x divide-gray-100">
+        <template v-for="item in headerCenterContent" :key="item.key">
+          <div
+            class="px-8 flex flex-col items-center"
+            v-if="item.judgment ? Boolean(query[item.key]) : true"
+          >
+            <span class="text-xs text-gray-400 mb-1">{{ item.label }}</span>
+            <span class="font-semibold text-gray-700">{{ data[item.key] }}</span>
+          </div>
+        </template>
       </div>
+      <div class="flex items-center gap-6">
+        <template v-for="item in headerTagContent" :key="item.key">
+          <div class="text-center" v-if="item.judgment ? Boolean(query[item.key]) : true">
+            <div class="text-xs text-gray-400 mb-1">{{ item.label }}</div>
+            <el-tag v-if="data[item.key]" type="success" v-bind="tagProps">
+              <el-icon class="mr-1"><CircleCheckFilled /></el-icon>在线
+            </el-tag>
+            <el-tag v-else type="danger" v-bind="tagProps">
+              <el-icon class="mr-1"><CircleCloseFilled /></el-icon>离线
+            </el-tag>
+          </div>
+        </template>
+      </div>
+    </div>
+
+    <el-scrollbar
+      class="bg-white rounded-xl shadow-sm border border-gray-100 border-solid overflow-hidden"
+      view-class="flex flex-col min-h-full"
+      v-loading="dimensionLoading"
+    >
+      <template v-for="citem in dimensionsContent" :key="citem.label">
+        <template v-if="citem.judgment ? Boolean(citem.value.length) : true">
+          <div
+            class="sticky-title z-88 bg-white/95 flex justify-between items-center py-3 px-4 border-0 border-solid border-b border-gray-50"
+          >
+            <span class="font-bold text-sm text-gray-700! flex items-center gap-2">
+              <el-icon><component :is="citem.icon" /></el-icon>
+              {{ citem.label }}
+            </span>
+            <span
+              class="text-xs px-2 py-0.5 rounded-full font-mono"
+              :class="[citem.countBg, citem.countColor]"
+            >
+              {{ citem.value.length }}
+            </span>
+          </div>
+
+          <div class="px-3 pb-4 pt-2 space-y-3">
+            <div
+              v-for="item in citem.value"
+              :key="item.identifier"
+              @click="handleClickSpec(item.name)"
+              class="dimension-card group relative p-3 rounded-lg border border-solid bg-white border-gray-200 transition-all duration-300 cursor-pointer select-none"
+              :class="{ 'is-active': selectedDimension[item.name] }"
+              :style="{
+                '--theme-color': item.color,
+                '--theme-bg-hover': item.bgHover,
+                '--theme-bg-active': item.bgActive
+              }"
+            >
+              <div class="flex justify-between items-center mb-1">
+                <span
+                  class="text-xs font-medium text-gray-500 transition-colors truncate pr-2 group-hover:text-[var(--theme-color)]"
+                  :class="{ 'text-[var(--theme-color)]!': selectedDimension[item.name] }"
+                >
+                  {{ item.name }}
+                </span>
+                <div
+                  class="size-2 rounded-full transition-all duration-300 shadow-sm"
+                  :class="selectedDimension[item.name] ? 'scale-100' : 'scale-0'"
+                  :style="{ backgroundColor: item.color, boxShadow: `0 0 6px ${item.color}` }"
+                ></div>
+              </div>
+
+              <div class="flex items-baseline justify-between relative z-10">
+                <animated-count-to
+                  :value="Number(item.value)"
+                  :duration="500"
+                  class="text-lg font-bold font-mono tracking-tight text-slate-800"
+                />
+              </div>
+              <div
+                class="absolute left-0 top-3 bottom-3 w-1 rounded-r transition-all duration-300"
+                :class="
+                  selectedDimension[item.name]
+                    ? 'opacity-100 shadow-[0_0_8px_currentColor]'
+                    : 'opacity-0'
+                "
+                :style="{ backgroundColor: item.color, color: item.color }"
+              >
+              </div>
+            </div>
+          </div>
+        </template>
+      </template>
+    </el-scrollbar>
+
+    <div
+      class="bg-white rounded-xl shadow-sm border border-gray-100 border-solid p-4 flex flex-col"
+    >
+      <header class="flex items-center justify-between mb-4">
+        <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="selectedDate"
+            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 class="flex flex-1">
+        <div class="flex gap-1 select-none">
+          <div
+            v-for="item of maxmin"
+            :key="item.name"
+            :style="{
+              '--theme-bg-hover': item.bgHover
+            }"
+            class="w-8 h-full flex flex-col items-center justify-between py-2 gap-1 rounded-full group relative bg-gray-50 border border-solid border-transparent transition-all duration-300 hover:bg-[var(--theme-bg-hover)] hover-border-gray-200 hover:shadow-md cursor-pointer active:scale-95"
+            @click="handleClickSpec(item.name)"
+          >
+            <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.max }}</span>
+            <div
+              class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
+              :style="{ backgroundColor: item.color }"
+            ></div>
+            <span
+              class="[writing-mode:sideways-lr] text-sm font-bold tracking-widest"
+              :style="{ color: item.color }"
+            >
+              {{ item.name }}
+            </span>
+            <div
+              class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
+              :style="{ backgroundColor: item.color }"
+            ></div>
+            <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.min }}</span>
+          </div>
+        </div>
         <div
-          v-loading="chartLoading"
-          element-loading-background="transparent"
-          ref="chartRef"
-          class="flex-1 h-full"
+          class="flex flex-1 min-w-0 bg-gray-50/30 rounded-lg border border-dashed border-gray-200 ml-2 relative overflow-hidden"
         >
+          <div
+            v-loading="chartLoading"
+            element-loading-background="transparent"
+            ref="chartRef"
+            class="w-full h-full"
+          >
+          </div>
         </div>
       </div>
     </div>
   </div>
 </template>
 
-<style lang="scss" scoped>
-:deep(.el-form-item) {
-  margin-bottom: 0;
+<style scoped>
+/* Icon Fix */
+:deep(.el-tag__content) {
+  display: flex;
+  align-items: center;
+  gap: 2px;
+}
 
-  .el-form-item__label {
-    margin-bottom: 0;
-  }
+/* Sticky Header */
+.sticky-title {
+  position: sticky;
+  top: 0;
+}
 
-  .el-form-item__content {
-    font-size: 1rem;
-    font-weight: 500;
-  }
+/*
+  核心样式:霓虹卡片效果
+  使用 CSS 变量实现动态颜色
+*/
 
-  &.online {
-    .el-form-item__content {
-      height: 2.5rem;
+/* Hover 状态:背景微亮,边框变色 */
+.dimension-card:hover {
+  background-color: var(--theme-bg-hover);
+  border-color: var(--theme-bg-active);
+  box-shadow: 0 4px 12px -2px rgb(0 0 0 / 5%);
+}
 
-      .el-tag__content {
-        display: flex;
-        align-items: center;
-        gap: 2px;
-      }
-    }
-  }
+/* Active 状态:背景更亮,边框为主题色,带轻微发光投影 */
+.dimension-card.is-active {
+  background-color: var(--theme-bg-active);
+  border-color: var(--theme-color);
+  box-shadow:
+    0 0 0 1px var(--theme-bg-active),
+    0 4px 12px -2px var(--theme-bg-active);
+}
+
+/* 滚动条美化 */
+:deep(.el-scrollbar__bar.is-vertical) {
+  right: 2px;
+  width: 4px;
+}
+
+:deep(.el-scrollbar__thumb) {
+  background-color: #cbd5e1;
+  opacity: 0.6;
+}
+
+:deep(.el-scrollbar__thumb:hover) {
+  background-color: #94a3b8;
+  opacity: 1;
 }
 </style>

+ 693 - 0
src/views/pms/device/monitor/TdDeviceInfo copy.vue

@@ -0,0 +1,693 @@
+<script setup lang="ts">
+import { Odometer, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
+import { IotDeviceApi } from '@/api/pms/device'
+import dayjs from 'dayjs'
+import { rangeShortcuts } from '@/utils/formatTime'
+import { IotStatApi, cancelAllRequests } from '@/api/pms/stat'
+
+import * as echarts from 'echarts'
+import { colors } from '@/utils/td-color'
+
+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 Dimensions {
+  identifier: string
+  name: string
+  value: string
+  color?: string
+  response?: boolean
+}
+
+const dimensions = ref<Dimensions[]>([])
+const gatewayDimensions = ref<Dimensions[]>([])
+const carDimensions = ref<Dimensions[]>([])
+
+const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
+
+interface SelectedDimension {
+  [key: Dimensions['name']]: boolean
+}
+
+const selectedDimension = ref<SelectedDimension>({})
+
+const dimensionLoading = ref(false)
+
+const disabledDimension = computed(() => (identifier: string) => {
+  const response = dimensions.value.find((item) => item.identifier === identifier)?.response
+
+  return { disabled: disabledDimensions.value.includes(identifier) || response, loading: response }
+})
+
+async function loadDimensions() {
+  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((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
+    }))
+
+  dimensions.value = [...gateway, ...car]
+    .filter((item) => !disabledDimensions.value.includes(item.identifier))
+    .map((item, index) => ({
+      ...item,
+      color: colors[index]
+    }))
+
+  gatewayDimensions.value = gateway
+  carDimensions.value = car
+
+  selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
+
+  selectedDimension.value[dimensions.value[0].name] = true
+
+  dimensionLoading.value = false
+}
+
+async function updateDimensionValues() {
+  if (!query.id) return
+
+  try {
+    // 1. 并行获取最新数据
+    const [gatewayRes, carRes] = await Promise.all([
+      IotDeviceApi.getIotDeviceTds(Number(query.id)),
+      IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))
+    ])
+
+    // 2. 创建一个 Map 用于快速查找 (Identifier -> Value)
+    // 这样可以将复杂度从 O(N*M) 降低到 O(N)
+    const newValueMap = new Map<string, any>()
+
+    const addToMap = (data: any[]) => {
+      if (!data) return
+      data.forEach((item) => {
+        if (item.identifier) {
+          newValueMap.set(item.identifier, item.value)
+        }
+      })
+    }
+
+    addToMap(gatewayRes as any[])
+    addToMap(carRes as any[])
+
+    // 3. 更新 dimensions.value (保留了之前的 color 和其他属性)
+    dimensions.value.forEach((item) => {
+      if (newValueMap.has(item.identifier)) {
+        item.value = newValueMap.get(item.identifier)
+      }
+    })
+
+    // 4. 如果还需要同步更新 gatewayDimensions 和 carDimensions
+    // (假设这些是引用类型,如果它们引用的是同一个对象,上面更新 dimensions 时可能已经同步了。
+    // 如果它们是独立的对象数组,则需要显式更新)
+
+    // 更新 Gateway 原始列表
+    gatewayDimensions.value.forEach((item) => {
+      if (newValueMap.has(item.identifier)) {
+        item.value = newValueMap.get(item.identifier)
+      }
+    })
+
+    // 更新 Car 原始列表
+    carDimensions.value.forEach((item) => {
+      if (newValueMap.has(item.identifier)) {
+        item.value = newValueMap.get(item.identifier)
+      }
+    })
+  } catch (error) {
+    console.error('Failed to update dimension values:', error)
+  }
+}
+
+const selectedDate = ref<string[]>([
+  ...rangeShortcuts[0].value().map((v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'))
+])
+
+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
+
+// const genderIntervalArrDebounce = useDebounceFn(
+//   (init: boolean = false) => genderIntervalArr(init),
+//   300
+// )
+
+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) => {
+    selectedDimension.value = params.selected
+  })
+
+  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({
+    grid: {
+      left: '6%',
+      top: '5%',
+      right: '6%',
+      bottom: '12%'
+    },
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'line'
+      },
+      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) => `<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('')
+      }
+    },
+    xAxis: {
+      type: 'time',
+      axisLabel: {
+        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,
+      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()
+        }
+      },
+      show: false
+    },
+    legend: {
+      data: dimensions.value.map((item) => item.name),
+      selected: selectedDimension.value,
+      show: false
+    },
+    series: dimensions.value.map((item) => ({
+      name: item.name,
+      type: 'line',
+      smooth: true,
+      showSymbol: false,
+      color: item.color,
+      data: [] // 占位数组
+    }))
+  })
+}
+
+function mapData({ value, ts }) {
+  if (!value) 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 = 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>>({})
+
+async function fetchIncrementData() {
+  for (const item of dimensions.value) {
+    const { identifier, name } = item
+
+    const lastTs = lastTsMap.value[name]
+    if (!lastTs) continue
+
+    item.response = true
+
+    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)
+          .map((item) => ({ ts: item.ts, value: item.value }))
+        // push 到本地
+        chartData.value[name].push(...sorted)
+        // 更新 lastTs
+        lastTsMap.value[identifier] = sorted.at(-1).ts
+
+        // 更新图表
+        updateSingleSeries(name)
+      })
+      .finally(() => {
+        item.response = false
+      })
+  }
+}
+
+const timer = ref<NodeJS.Timeout | null>(null)
+
+function startAutoFetch() {
+  timer.value = setInterval(() => {
+    updateDimensionValues()
+    fetchIncrementData()
+  }, 10000)
+}
+
+function stopAutoFetch() {
+  cancelAllRequests()
+  if (timer.value) clearInterval(timer.value)
+  timer.value = null
+}
+
+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(
+        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()
+      }
+    } finally {
+      item.response = false
+    }
+  }
+
+  if (real_time) startAutoFetch()
+}
+
+async function initfn(load: boolean = true, real_time: boolean = true) {
+  if (load) await loadDimensions()
+  render()
+  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()
+  genderIntervalArr()
+}
+
+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(() => {
+  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="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="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-8 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
+        :class="{ 'bg-blue-200': selectedDimension[item.name] }"
+        :disabled="disabledDimension(item.identifier).disabled"
+        @click="handleClickSpec(item.name)"
+      >
+        <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2 relative">
+          <!-- <i
+            v-show="disabledDimension(item.identifier).loading"
+            class="i-line-md:loading-loop size-5 absolute -left-6"
+          ></i> -->
+          {{ item.name }}
+        </span>
+        <span class="text-lg font-medium ms-a">{{ item.value }}</span>
+      </button>
+    </div>
+  </div>
+  <div
+    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">
+      <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="disabledDimension(item.identifier).disabled"
+        @click="handleClickSpec(item.name)"
+      >
+        <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2">
+          <!-- <i
+            v-show="disabledDimension(item.identifier).loading"
+            class="i-line-md:loading-loop size-5"
+          ></i> -->
+          {{ 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="selectedDate"
+          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 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>
+
+<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>

+ 381 - 225
src/views/pms/device/monitor/TdDeviceInfo.vue

@@ -1,12 +1,20 @@
-<script setup lang="ts">
-import { Odometer, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import { useRoute } from 'vue-router'
 import { IotDeviceApi } from '@/api/pms/device'
+import {
+  Odometer,
+  CircleCheckFilled,
+  CircleCloseFilled,
+  DataLine,
+  TrendCharts
+} from '@element-plus/icons-vue'
+import { AnimatedCountTo } from '@/components/AnimatedCountTo'
+import { neonColors } from '@/utils/td-color'
 import dayjs from 'dayjs'
-import { rangeShortcuts } from '@/utils/formatTime'
-import { IotStatApi, cancelAllRequests } from '@/api/pms/stat'
-
 import * as echarts from 'echarts'
-import { colors } from '@/utils/td-color'
+import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
+import { rangeShortcuts } from '@/utils/formatTime'
 
 const { query } = useRoute()
 
@@ -14,17 +22,46 @@ const data = ref({
   deviceCode: query.code || '',
   deviceName: query.name || '',
   lastInlineTime: query.time || '',
-  ifInline: query.ifInline || '',
+  ifInline: query.ifInline === '3',
   dept: query.dept || '',
   vehicle: query.vehicle || '',
-  carOnline: query.carOnline || ''
+  carOnline: query.carOnline === 'true'
 })
 
+function hexToRgba(hex: string, alpha: number) {
+  const r = parseInt(hex.slice(1, 3), 16)
+  const g = parseInt(hex.slice(3, 5), 16)
+  const b = parseInt(hex.slice(5, 7), 16)
+  return `rgba(${r}, ${g}, ${b}, ${alpha})`
+}
+
+interface HeaderItem {
+  label: string
+  key: keyof typeof data.value
+  judgment?: boolean
+}
+
+const headerCenterContent: HeaderItem[] = [
+  { label: '设备名称', key: 'deviceName' },
+  { label: '所属部门', key: 'dept' },
+  { label: '车牌号码', key: 'vehicle', judgment: true },
+  { label: '最后上报时间', key: 'lastInlineTime' }
+]
+
+const tagProps = { size: 'default', round: true } as const
+
+const headerTagContent: HeaderItem[] = [
+  { label: '网关', key: 'ifInline' },
+  { label: '北斗', key: 'carOnline', judgment: true }
+]
+
 interface Dimensions {
   identifier: string
   name: string
-  value: string
-  color?: string
+  value: string | number
+  color: string
+  bgHover: string
+  bgActive: string
   response?: boolean
 }
 
@@ -32,57 +69,80 @@ const dimensions = ref<Dimensions[]>([])
 const gatewayDimensions = ref<Dimensions[]>([])
 const carDimensions = ref<Dimensions[]>([])
 
-const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
-
-interface SelectedDimension {
-  [key: Dimensions['name']]: boolean
-}
-
-const selectedDimension = ref<SelectedDimension>({})
+const dimensionsContent = computed(() => [
+  {
+    label: '网关数采',
+    icon: DataLine,
+    value: gatewayDimensions.value,
+    countColor: 'text-blue-600',
+    countBg: 'bg-blue-50'
+  },
+  {
+    label: '中航北斗',
+    icon: TrendCharts,
+    value: carDimensions.value,
+    countColor: 'text-indigo-600',
+    countBg: 'bg-indigo-50',
+    judgment: true
+  }
+])
 
+const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
+const selectedDimension = ref<Record<string, boolean>>({})
 const dimensionLoading = ref(false)
 
-const disabledDimension = computed(() => (identifier: string) => {
-  const response = dimensions.value.find((item) => item.identifier === identifier)?.response
-
-  return { disabled: disabledDimensions.value.includes(identifier) || response, loading: response }
-})
-
 async function loadDimensions() {
   if (!query.id) return
-
   dimensionLoading.value = true
+  try {
+    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,
+        response: false
+      }))
+
+    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,
+        response: false
+      }))
+
+    // 合并并分配霓虹色
+    dimensions.value = [...gateway, ...car]
+      .filter((item) => !disabledDimensions.value.includes(item.identifier))
+      .map((item, index) => {
+        const color = neonColors[index]
+
+        return {
+          ...item,
+          color: color,
+          bgHover: hexToRgba(color, 0.08),
+          bgActive: hexToRgba(color, 0.12)
+        }
+      })
 
-  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
-    }))
-
-  dimensions.value = [...gateway, ...car]
-    .filter((item) => !disabledDimensions.value.includes(item.identifier))
-    .map((item, index) => ({
-      ...item,
-      color: colors[index]
-    }))
-
-  gatewayDimensions.value = gateway
-  carDimensions.value = car
-
-  selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
-
-  selectedDimension.value[dimensions.value[0].name] = true
+    gatewayDimensions.value = dimensions.value.filter((d) =>
+      gateway.some((g) => g.identifier === d.identifier)
+    )
+    carDimensions.value = dimensions.value.filter((d) =>
+      car.some((c) => c.identifier === d.identifier)
+    )
 
-  dimensionLoading.value = false
+    selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
+    if (dimensions.value.length > 0) {
+      selectedDimension.value[dimensions.value[0].name] = true
+    }
+  } catch (e) {
+    console.error(e)
+  } finally {
+    dimensionLoading.value = false
+  }
 }
 
 async function updateDimensionValues() {
@@ -219,6 +279,11 @@ function render() {
   genderIntervalArr(true)
 
   chart.setOption({
+    animation: true,
+    animationDuration: 200,
+    animationEasing: 'linear',
+    animationDurationUpdate: 200,
+    animationEasingUpdate: 'linear',
     grid: {
       left: '6%',
       top: '5%',
@@ -282,8 +347,24 @@ function render() {
     series: dimensions.value.map((item) => ({
       name: item.name,
       type: 'line',
-      smooth: true,
+      smooth: 0.2,
       showSymbol: false,
+      endLabel: {
+        show: true,
+        formatter: (params) => params.value[2]?.toFixed(2),
+        offset: [6, 0],
+        color: item.color,
+        fontSize: 12
+      },
+
+      emphasis: {
+        focus: 'series'
+      },
+
+      lineStyle: {
+        width: 2
+      },
+
       color: item.color,
       data: [] // 占位数组
     }))
@@ -291,17 +372,24 @@ function render() {
 }
 
 function mapData({ value, ts }) {
-  if (!value) return [ts, 0, 0]
+  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)
 
-  const new_value =
-    (absItem - min_value) / (intervalArr.value[min_index + 1] - intervalArr.value[min_index]) +
-    min_index
+  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]
 }
@@ -404,19 +492,23 @@ async function initLoadChartData(real_time: boolean = true) {
 
       lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
 
+      genderIntervalArr(true)
+
       updateSingleSeries(name)
 
       chartLoading.value = false
 
-      if (selectedDimension.value[name]) {
-        genderIntervalArr()
-      }
+      // if (selectedDimension.value[name]) {
+      //   genderIntervalArr()
+      // }
     } finally {
       item.response = false
     }
   }
 
-  if (real_time) startAutoFetch()
+  if (real_time) {
+    startAutoFetch()
+  }
 }
 
 async function initfn(load: boolean = true, real_time: boolean = true) {
@@ -456,10 +548,17 @@ function handleClickSpec(modelName: string) {
       selected: selectedDimension.value
     }
   })
-  chart?.resize()
+
   genderIntervalArr()
-}
 
+  if (selectedDimension.value[modelName]) {
+    updateSingleSeries(modelName)
+  }
+
+  nextTick(() => {
+    chart?.resize()
+  })
+}
 const exportChart = () => {
   if (!chart) return
   let img = new Image()
@@ -494,6 +593,7 @@ const maxmin = computed(() => {
     .map((v) => ({
       name: v.name,
       color: v.color,
+      bgHover: v.bgHover,
       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)
     }))
@@ -510,184 +610,240 @@ onUnmounted(() => {
 
 <template>
   <div
-    class="w-full bg-gradient-to-r from-blue-100 to-white rounded-lg p-6 shadow"
-    id="td-device-info"
+    class="grid grid-cols-[260px_1fr] grid-rows-[80px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
   >
-    <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"
+      class="grid-col-span-2 bg-white rounded-xl shadow-sm border border-gray-100 border-solid px-6 flex items-center justify-between shrink-0"
     >
-      <button
-        v-for="item in gatewayDimensions"
-        :key="item.identifier"
-        class="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-8 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
-        :class="{ 'bg-blue-200': selectedDimension[item.name] }"
-        :disabled="disabledDimension(item.identifier).disabled"
-        @click="handleClickSpec(item.name)"
-      >
-        <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2 relative">
-          <!-- <i
-            v-show="disabledDimension(item.identifier).loading"
-            class="i-line-md:loading-loop size-5 absolute -left-6"
-          ></i> -->
-          {{ item.name }}
-        </span>
-        <span class="text-lg font-medium ms-a">{{ item.value }}</span>
-      </button>
-    </div>
-  </div>
-  <div
-    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">
-      <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="disabledDimension(item.identifier).disabled"
-        @click="handleClickSpec(item.name)"
-      >
-        <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2">
-          <!-- <i
-            v-show="disabledDimension(item.identifier).loading"
-            class="i-line-md:loading-loop size-5"
-          ></i> -->
-          {{ 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="selectedDate"
-          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 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)"
+      <div class="flex items-center gap-4">
+        <div
+          class="size-12 rounded-lg bg-blue-50 text-blue-600 flex items-center justify-center shadow-inner"
         >
-          <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>
+          <el-icon :size="24"><Odometer /></el-icon>
+        </div>
+        <div>
+          <div class="text-xs text-gray-400 font-medium tracking-wider">资产编码</div>
+          <div class="text-xl font-bold font-mono text-gray-800">{{ data.deviceCode }}</div>
+        </div>
       </div>
+      <div class="flex-1 flex justify-center divide-x divide-gray-100">
+        <template v-for="item in headerCenterContent" :key="item.key">
+          <div
+            class="px-8 flex flex-col items-center"
+            v-if="item.judgment ? Boolean(query[item.key]) : true"
+          >
+            <span class="text-xs text-gray-400 mb-1">{{ item.label }}</span>
+            <span class="font-semibold text-gray-700">{{ data[item.key] }}</span>
+          </div>
+        </template>
+      </div>
+      <div class="flex items-center gap-6">
+        <template v-for="item in headerTagContent" :key="item.key">
+          <div class="text-center" v-if="item.judgment ? Boolean(query[item.key]) : true">
+            <div class="text-xs text-gray-400 mb-1">{{ item.label }}</div>
+            <el-tag v-if="data[item.key]" type="success" v-bind="tagProps">
+              <el-icon class="mr-1"><CircleCheckFilled /></el-icon>在线
+            </el-tag>
+            <el-tag v-else type="danger" v-bind="tagProps">
+              <el-icon class="mr-1"><CircleCloseFilled /></el-icon>离线
+            </el-tag>
+          </div>
+        </template>
+      </div>
+    </div>
+
+    <el-scrollbar
+      class="bg-white rounded-xl shadow-sm border border-gray-100 border-solid overflow-hidden"
+      view-class="flex flex-col min-h-full"
+      v-loading="dimensionLoading"
+    >
+      <template v-for="citem in dimensionsContent" :key="citem.label">
+        <template v-if="citem.judgment ? Boolean(citem.value.length) : true">
+          <div
+            class="sticky-title z-88 bg-white/95 flex justify-between items-center py-3 px-4 border-0 border-solid border-b border-gray-50"
+          >
+            <span class="font-bold text-sm text-gray-700! flex items-center gap-2">
+              <el-icon><component :is="citem.icon" /></el-icon>
+              {{ citem.label }}
+            </span>
+            <span
+              class="text-xs px-2 py-0.5 rounded-full font-mono"
+              :class="[citem.countBg, citem.countColor]"
+            >
+              {{ citem.value.length }}
+            </span>
+          </div>
+
+          <div class="px-3 pb-4 pt-2 space-y-3">
+            <div
+              v-for="item in citem.value"
+              :key="item.identifier"
+              @click="handleClickSpec(item.name)"
+              class="dimension-card group relative p-3 rounded-lg border border-solid bg-white border-gray-200 transition-all duration-300 cursor-pointer select-none"
+              :class="{ 'is-active': selectedDimension[item.name] }"
+              :style="{
+                '--theme-color': item.color,
+                '--theme-bg-hover': item.bgHover,
+                '--theme-bg-active': item.bgActive
+              }"
+            >
+              <div class="flex justify-between items-center mb-1">
+                <span
+                  class="text-xs font-medium text-gray-500 transition-colors truncate pr-2 group-hover:text-[var(--theme-color)]"
+                  :class="{ 'text-[var(--theme-color)]!': selectedDimension[item.name] }"
+                >
+                  {{ item.name }}
+                </span>
+                <div
+                  class="size-2 rounded-full transition-all duration-300 shadow-sm"
+                  :class="selectedDimension[item.name] ? 'scale-100' : 'scale-0'"
+                  :style="{ backgroundColor: item.color, boxShadow: `0 0 6px ${item.color}` }"
+                ></div>
+              </div>
+
+              <div class="flex items-baseline justify-between relative z-10">
+                <animated-count-to
+                  :value="Number(item.value)"
+                  :duration="500"
+                  class="text-lg font-bold font-mono tracking-tight text-slate-800"
+                />
+              </div>
+              <div
+                class="absolute left-0 top-3 bottom-3 w-1 rounded-r transition-all duration-300"
+                :class="
+                  selectedDimension[item.name]
+                    ? 'opacity-100 shadow-[0_0_8px_currentColor]'
+                    : 'opacity-0'
+                "
+                :style="{ backgroundColor: item.color, color: item.color }"
+              >
+              </div>
+            </div>
+          </div>
+        </template>
+      </template>
+    </el-scrollbar>
+
+    <div
+      class="bg-white rounded-xl shadow-sm border border-gray-100 border-solid p-4 flex flex-col"
+    >
+      <header class="flex items-center justify-between mb-4">
+        <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="selectedDate"
+            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 class="flex flex-1">
+        <div class="flex gap-1 select-none">
+          <div
+            v-for="item of maxmin"
+            :key="item.name"
+            :style="{
+              '--theme-bg-hover': item.bgHover
+            }"
+            class="w-8 h-full flex flex-col items-center justify-between py-2 gap-1 rounded-full group relative bg-gray-50 border border-solid border-transparent transition-all duration-300 hover:bg-[var(--theme-bg-hover)] hover-border-gray-200 hover:shadow-md cursor-pointer active:scale-95"
+            @click="handleClickSpec(item.name)"
+          >
+            <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.max }}</span>
+            <div
+              class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
+              :style="{ backgroundColor: item.color }"
+            ></div>
+            <span
+              class="[writing-mode:sideways-lr] text-sm font-bold tracking-widest"
+              :style="{ color: item.color }"
+            >
+              {{ item.name }}
+            </span>
+            <div
+              class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
+              :style="{ backgroundColor: item.color }"
+            ></div>
+            <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.min }}</span>
+          </div>
+        </div>
         <div
-          v-loading="chartLoading"
-          element-loading-background="transparent"
-          ref="chartRef"
-          class="flex-1 h-full"
+          class="flex flex-1 min-w-0 bg-gray-50/30 rounded-lg border border-dashed border-gray-200 ml-2 relative overflow-hidden"
         >
+          <div
+            v-loading="chartLoading"
+            element-loading-background="transparent"
+            ref="chartRef"
+            class="w-full h-full"
+          >
+          </div>
         </div>
       </div>
     </div>
   </div>
 </template>
 
-<style lang="scss" scoped>
-:deep(.el-form-item) {
-  margin-bottom: 0;
+<style scoped>
+/* Icon Fix */
+:deep(.el-tag__content) {
+  display: flex;
+  align-items: center;
+  gap: 2px;
+}
 
-  .el-form-item__label {
-    margin-bottom: 0;
-  }
+/* Sticky Header */
+.sticky-title {
+  position: sticky;
+  top: 0;
+}
 
-  .el-form-item__content {
-    font-size: 1rem;
-    font-weight: 500;
-  }
+/*
+  核心样式:霓虹卡片效果
+  使用 CSS 变量实现动态颜色
+*/
 
-  &.online {
-    .el-form-item__content {
-      height: 2.5rem;
+/* Hover 状态:背景微亮,边框变色 */
+.dimension-card:hover {
+  background-color: var(--theme-bg-hover);
+  border-color: var(--theme-bg-active);
+  box-shadow: 0 4px 12px -2px rgb(0 0 0 / 5%);
+}
 
-      .el-tag__content {
-        display: flex;
-        align-items: center;
-        gap: 2px;
-      }
-    }
-  }
+/* Active 状态:背景更亮,边框为主题色,带轻微发光投影 */
+.dimension-card.is-active {
+  background-color: var(--theme-bg-active);
+  border-color: var(--theme-color);
+  box-shadow:
+    0 0 0 1px var(--theme-bg-active),
+    0 4px 12px -2px var(--theme-bg-active);
+}
+
+/* 滚动条美化 */
+:deep(.el-scrollbar__bar.is-vertical) {
+  right: 2px;
+  width: 4px;
+}
+
+:deep(.el-scrollbar__thumb) {
+  background-color: #cbd5e1;
+  opacity: 0.6;
+}
+
+:deep(.el-scrollbar__thumb:hover) {
+  background-color: #94a3b8;
+  opacity: 1;
 }
 </style>

+ 43 - 13
src/views/pms/iotopeationfill/index1.vue

@@ -40,6 +40,7 @@
         </template>
         <div class="form-wrapper h-full">
           <el-form
+            ref="formRef"
             size="default"
             label-width="120px"
             class="scrollable-form"
@@ -57,6 +58,15 @@
                     {{ deviceItem.orgName }}
                   </span>
                 </el-form-item>
+                <el-form-item
+                  v-if="deviceItem.deviceName === '生产日报'"
+                  label="井号"
+                  class="custom-label1"
+                >
+                  <span style="text-decoration: underline">
+                    {{ deviceItem.wellName }}
+                  </span>
+                </el-form-item>
                 <el-row :gutter="20">
                   <el-col
                     v-for="(summaryItem, summaryIndex) in attrList1"
@@ -224,7 +234,7 @@
 
 <script setup lang="ts">
 import { IotOpeationFillApi, IotOpeationFillVO } from '@/api/pms/iotopeationfill'
-import { ElMessage, FormRules } from 'element-plus'
+import { ElMessage, FormInstance, FormRules } from 'element-plus'
 import moment from 'moment'
 import { getIntDictOptions, getStrDictOptions } from '@/utils/dict'
 import { useRoute } from 'vue-router'
@@ -321,7 +331,6 @@ const sumNonProdTimes = () => {
 const rhValidateTotalTime =
   (isNon: boolean = false) =>
   (_rule: any, _value: any, callback: any) => {
-    console.log('11 :>> ', 11)
     const gasTime =
       attrList.value.find((item) => item.description === 'dailyInjectGasTime')?.fillContent || 0
     const nonProdSum = sumNonProdTimes()
@@ -399,14 +408,14 @@ const validateOtherReason = (_rule: any, value: any, callback: any) => {
 
 const rules = reactive<FormRules>({
   dailyInjectGasTime: [
-    { required: true, message: '请输入当日运转时间', trigger: 'blur' },
-    { validator: rhValidateTotalTime(), trigger: 'blur' }
+    { required: true, message: '请输入当日运转时间' },
+    { validator: rhValidateTotalTime() }
   ],
   drillingWorkingTime: [
-    { required: true, message: '请输入进尺工作时间', trigger: 'blur' },
-    { validator: ryValidateTotalTime(), trigger: 'blur' }
+    { required: true, message: '请输入进尺工作时间' },
+    { validator: ryValidateTotalTime() }
   ],
-  otherProductionTime: [{ validator: ryValidateTotalTime(), trigger: 'blur' }],
+  otherProductionTime: [{ validator: ryValidateTotalTime() }],
   // ratedProductionTime: [
   //   { required: true, message: '请输入额定生产时间', trigger: 'blur' },
   //   { validator: ryXjValidateTotalTime(), trigger: 'blur' }
@@ -415,7 +424,7 @@ const rules = reactive<FormRules>({
   //   { required: true, message: '请输入生产时间', trigger: 'blur' },
   //   { validator: ryXjValidateTotalTime(), trigger: 'blur' }
   // ],
-  otherNptReason: [{ validator: validateOtherReason, trigger: 'blur' }]
+  otherNptReason: [{ validator: validateOtherReason }]
 })
 
 const totalValidatorComputed = computed(() => {
@@ -434,7 +443,7 @@ nextTick(() => {
   const validator = totalValidatorComputed.value
   if (!validator) return
   NON_KEYS.forEach((field) => {
-    rules[field] = [{ validator: validator(true), trigger: 'blur' }]
+    rules[field] = [{ validator: validator(true) }]
   })
 })
 
@@ -605,17 +614,31 @@ const getAttrList = async () => {
 
     // 为非累计数据添加最大值限制
     attrList.value.forEach(function (item) {
-      if (item.fillContent !== '' && item.fillContent !== null) {
-        const num = Number(item.fillContent)
+      let strVal = String(item.fillContent || '').trim()
+
+      if (strVal !== '') {
+        const num = Number(strVal)
+
         if (!isNaN(num)) {
-          if (item.fillContent.includes('.')) {
+          if (strVal.includes('.')) {
             item.fillContent = Number(num.toFixed(2))
           } else {
-            item.fillContent = Math.floor(num)
+            item.fillContent = num
           }
         }
       }
 
+      // if (item.fillContent !== '' && item.fillContent !== null) {
+      //   const num = Number(item.fillContent)
+      //   if (!isNaN(num)) {
+      //     if (item.fillContent.includes('.')) {
+      //       item.fillContent = Number(num.toFixed(2))
+      //     } else {
+      //       item.fillContent = Math.floor(num)
+      //     }
+      //   }
+      // }
+
       if (companyName.value === 'rd') {
         // 添加最大值限制逻辑
         const coreName = item.name.replace(/填报/g, '')
@@ -655,9 +678,16 @@ const getAttrList = async () => {
     loading.value = false
   }
 }
+const formRef = ref<FormInstance[] | null>(null)
+
 /** 获取填写信息保存到后台*/
 const getFillInfo = async () => {
+  if (!formRef.value) return
+
   try {
+    const validations = formRef.value.map((form) => form.validate())
+    await Promise.all(validations)
+
     const company = await IotOpeationFillApi.getOrgName(route.params.id.toString().split(',')[0])
 
     if (devName != '生产日报') {

+ 2 - 2
src/views/pms/iotrddailyreport/FillDailyReportForm.vue

@@ -458,7 +458,7 @@
               v-model="formData[field.key]"
               :controls="false"
               align="left"
-              :disabled="isReadonlyMode && query.istime !== 'true'"
+              :disabled="query.istime !== 'true'"
             />
           </el-form-item>
         </div>
@@ -469,7 +469,7 @@
               <el-input
                 v-model="formData.otherNptReason"
                 placeholder="请输入其他非生产原因"
-                :disabled="isReadonlyMode && query.istime !== 'true'"
+                :disabled="query.istime !== 'true'"
               />
             </el-form-item>
           </el-col>

+ 3 - 3
src/views/pms/iotrddailyreport/fillDailyReport.vue

@@ -100,7 +100,7 @@
               <el-button
                 link
                 type="warning"
-                @click="openForm('fill', scope.row.id)"
+                @click="openForm('fill', scope.row.id, 'true')"
                 v-hasPermi="['pms:iot-rd-daily-report:non-productive']"
                 v-if="scope.row.auditStatus === 20"
               >
@@ -381,8 +381,8 @@ const resetQuery = () => {
 
 /** 添加/修改操作 */
 const formRef = ref()
-const openForm = (type: string, id?: number) => {
-  push({ name: 'FillDailyReportForm', params: { id: id, mode: 'fill' }, query: { istime: 'true' } })
+const openForm = (type: string, id?: number, istime: string = 'false') => {
+  push({ name: 'FillDailyReportForm', params: { id: id, mode: 'fill' }, query: { istime: istime } })
 }
 
 /** 删除按钮操作 */