yanghao 22 órája
szülő
commit
58957f4e6d

+ 2 - 1
src/App.vue

@@ -17,7 +17,8 @@ const whiteList = [
   '/register',
   '/oauthLogin/gitee',
   '/dingding',
-  '/deepoil'
+  '/deepoil',
+  '/oli-connection/monitoring-board'
 ]
 
 watch(

+ 13 - 0
src/api/pms/device/index.ts

@@ -50,6 +50,15 @@ export interface IotDeviceVO {
 
 // 设备台账 API
 export const IotDeviceApi = {
+  saveMaxMin: async (data: any) => {
+    return await request.post({ url: `rq/iot-alarm-setting/save`, data })
+  },
+  getToken: async () => {
+    return await request.get({ url: `/rq/iot-device/yf/token` })
+  },
+  deleteMaxMin: async (params: any) => {
+    return await request.delete({ url: `rq/iot-alarm-setting/delete`, params })
+  },
   getCompany: async (params: any) => {
     return await request.get({ url: `/rq/iot-device/company?id=` + params })
   },
@@ -183,6 +192,10 @@ export const IotDeviceApi = {
     return await request.get({ url: `/rq/iot-device/td/ly/page`, params })
   },
 
+  getBoardDevice: async (params: any) => {
+    return await request.get({ url: `/rq/iot-device/td/ly/screen`, params })
+  },
+
   getMonitoringQuery: async (params: any) => {
     return await request.get({ url: `rq/stat/td/ly/chart`, params })
   },

+ 3 - 0
src/api/pms/report/index.ts

@@ -14,6 +14,9 @@ export const IotReportApi = {
   getOrderPage: async (params: any) => {
     return await request.get({ url: `/rq/report/order/page`, params })
   },
+  exportOrderPage: async (params: any) => {
+    return await request.download({ url: `/rq/report/order/export-excel`, params })
+  },
   exportCosts: async (params) => {
     return await request.download({ url: `/rq/report/repair/report/export-excel`, params })
   }

+ 123 - 0
src/utils/useMqtt.ts

@@ -0,0 +1,123 @@
+// useMqtt.ts
+import { ref, onUnmounted } from 'vue'
+import mqtt, { MqttClient, IClientOptions } from 'mqtt'
+import { ElMessage } from 'element-plus'
+
+type MessageCallback = (topic: string, payload: any) => void
+
+// 基础配置 (建议移至环境变量 import.meta.env.VITE_MQTT_HOST)
+const BASE_OPTIONS: IClientOptions = {
+  clean: true,
+  reconnectPeriod: 5000,
+  connectTimeout: 10000,
+  username: 'yanfan',
+  password:
+    'eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjY0YmM2NjJlLWZhMjQtNGY1Ny1hOTk1LWZiMGM2YjNhYzI4OCJ9.9nxoDUNGTk1szRlZHHG0AcWZctLrzJ16UA5rsBagHNcD10PC-LIMTgAr2CK1Ppafa6cW5XPdn7RqBF6iZjHtww'
+}
+
+export function useMqtt() {
+  const client = ref<MqttClient | null>(null)
+  const isConnected = ref(false)
+  const message = ref<any>(null) // 响应式消息数据
+
+  let customMessageCallback: MessageCallback | null = null
+
+  // 初始化连接
+  const connect = (host: string, options: IClientOptions = {}, onMessage?: MessageCallback) => {
+    if (client.value && client.value.connected) {
+      console.warn('当前实例已连接 MQTT,跳过初始化')
+      return
+    }
+
+    if (onMessage) {
+      customMessageCallback = onMessage
+    }
+
+    // 合并配置,生成唯一 ClientId
+    const finalOptions = {
+      ...BASE_OPTIONS,
+      clientId: `web-${Math.random().toString(16).substr(2)}`,
+      ...options
+    }
+
+    try {
+      client.value = mqtt.connect(host, finalOptions)
+
+      client.value.on('connect', () => {
+        isConnected.value = true
+        ElMessage.success('MQTT 连接成功')
+      })
+
+      client.value.on('error', (err) => {
+        console.error('MQTT Error:', err)
+        ElMessage.error(`MQTT 错误: ${err.message}`)
+        isConnected.value = false
+      })
+
+      client.value.on('close', () => {
+        isConnected.value = false
+      })
+
+      // 全局消息监听,更新响应式数据
+      client.value.on('message', (topic, payload) => {
+        let parsedData: any
+        try {
+          parsedData = JSON.parse(payload.toString())
+        } catch (e) {
+          parsedData = payload.toString()
+        }
+
+        message.value = { topic, payload: parsedData }
+
+        if (customMessageCallback) {
+          customMessageCallback(topic, parsedData)
+        }
+      })
+    } catch (err) {
+      ElMessage.error(`MQTT 初始化异常: ${(err as Error).message}`)
+      isConnected.value = false
+    }
+  }
+
+  // 订阅主题 (支持泛型)
+  const subscribe = (topic: string) => {
+    if (client.value && client.value.connected) {
+      client.value.subscribe(topic, { qos: 0 }, (err) => {
+        if (err) ElMessage.error(`订阅失败: ${err.message}`)
+        else console.log(`已订阅: ${topic}`)
+      })
+    }
+  }
+
+  // 发布消息
+  const publish = (topic: string, message: string | object) => {
+    if (client.value && client.value.connected) {
+      const payload = typeof message === 'string' ? message : JSON.stringify(message)
+      client.value.publish(topic, payload)
+    }
+  }
+
+  // 销毁连接
+  const destroy = () => {
+    if (client.value) {
+      client.value.end()
+      client.value = null
+      isConnected.value = false
+    }
+  }
+
+  // 组件卸载时自动断开 (防止内存泄漏)
+  onUnmounted(() => {
+    destroy()
+  })
+
+  return {
+    client,
+    isConnected,
+    message, // 组件内可以直接 watch 这个变量
+    connect,
+    subscribe,
+    publish,
+    destroy
+  }
+}

+ 3 - 0
src/utils/useSocketBus.ts

@@ -114,4 +114,7 @@ export interface Dimensions {
   response?: boolean
   suffix?: string
   isText?: boolean
+  minValue?: number
+  maxValue?: number
+  id?: number
 }

+ 1 - 1
src/views/Login/Login.vue

@@ -48,7 +48,7 @@
         <!-- 右边的登录界面 -->
         <Transition appear enter-active-class="animate__animated animate__bounceInRight">
           <div
-            class="m-auto h-[calc(100%-60px)] w-[100%] flex items-center at-2xl:max-w-500px at-lg:max-w-500px at-md:max-w-500px at-xl:max-w-500px"
+            class="m-auto h-[calc(100%-60px)] w-[100%] flex items-center 2xl:max-w-500px lg:max-w-500px md:max-w-500px xl:max-w-500px"
           >
             <!-- 账号登录 -->
             <LoginForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />

+ 1 - 1
src/views/bpm/processInstance/detail/ProcessInstanceOperationButton.vue

@@ -39,7 +39,7 @@
             />
           </el-card>
 
-          <el-form-item label="是否转工单" prop="ifTrans" v-if="company!='rh'">
+          <el-form-item label="是否转工单" prop="ifTrans" v-if="company!='rh' &&processInstance.name==='故障上报流程'">
             <el-select
               v-model="approveReasonForm.ifTrans"
               placeholder="请选择是否转工单"

+ 5 - 2
src/views/bpm/processInstance/detail/index.vue

@@ -214,8 +214,11 @@ const getApprovalDetail = async () => {
     // 获得用户列表
     userOptions.value = await UserApi.getSimpleUserList()
     const userId = useUserStore().getUser.id
-    deptUsers.value = await UserApi.getMaintainUsers(userId, processInstance.value.businessKey);
-    company.value = await IotDeviceApi.getCompany(processInstance.value.businessKey)
+    debugger
+    if (processInstance.value.name!=='QHSE事故上报') {
+      deptUsers.value = await UserApi.getMaintainUsers(userId, processInstance.value.businessKey);
+      company.value = await IotDeviceApi.getCompany(processInstance.value.businessKey)
+    }
     debugger
     // 设置表单信息
     if (processDefinition.value.formType === BpmModelFormType.NORMAL) {

+ 751 - 0
src/views/oli-connection/monitoring-board/chart.vue

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

+ 724 - 0
src/views/oli-connection/monitoring-board/chart1.vue

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

+ 509 - 0
src/views/oli-connection/monitoring-board/index.vue

@@ -0,0 +1,509 @@
+<script setup lang="ts">
+import { useUserStore } from '@/store/modules/user'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import { rangeShortcuts } from '@/utils/formatTime'
+import * as DeptApi from '@/api/system/dept'
+import { handleTree } from '@/utils/tree'
+import { IotDeviceApi } from '@/api/pms/device'
+import { EChartsOption } from 'echarts'
+import chart from './chart.vue'
+import dayjs from 'dayjs'
+import { useFullscreen } from '@vueuse/core'
+import { Crop, FullScreen } from '@element-plus/icons-vue'
+
+const userStore = useUserStore()
+const userDeptId = userStore.getUser.deptId
+
+interface DeviceData {
+  id: number
+  ifInline: string
+  lastInlineTime: string
+  deviceCode: string
+  deviceName: string
+  deptName: string
+  vehicleName: string
+  carOnline: string
+  mqttUrl: string
+}
+
+interface DeviceQuery {
+  deptId?: number
+  ifInline?: string
+}
+
+const originalDeviceQuery: DeviceQuery = {
+  deptId: userDeptId,
+  ifInline: '0'
+}
+
+const deviceQuery = ref<DeviceQuery>({
+  ...originalDeviceQuery
+})
+
+interface Query {
+  deviceCodes: number[]
+  time: string[]
+}
+
+const originalQuery: Query = {
+  deviceCodes: [],
+  time: [
+    dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
+    dayjs().format('YYYY-MM-DD HH:mm:ss')
+  ]
+  // time: [...rangeShortcuts[2].value().map((item) => dayjs(item).format('YYYY-MM-DD HH:mm:ss'))]
+}
+
+const query = ref<Query>({ ...originalQuery })
+
+const deptLoading = ref(false)
+const deptOptions = ref<any[]>([])
+
+async function loadDeptOptions() {
+  deptLoading.value = true
+  try {
+    function sortTreeBySort(treeNodes: Tree[]) {
+      if (!treeNodes || !Array.isArray(treeNodes)) return treeNodes
+      const sortedNodes = [...treeNodes].sort((a, b) => {
+        const sortA = a.sort != null ? a.sort : 999999
+        const sortB = b.sort != null ? b.sort : 999999
+        return sortA - sortB
+      })
+
+      sortedNodes.forEach((node) => {
+        if (node.children && Array.isArray(node.children)) {
+          node.children = sortTreeBySort(node.children)
+        }
+      })
+      return sortedNodes
+    }
+
+    const depts = await DeptApi.specifiedSimpleDepts(userDeptId)
+    deptOptions.value = sortTreeBySort(handleTree(depts))
+  } catch (error) {
+  } finally {
+    deptLoading.value = false
+  }
+}
+
+const deviceLoading = ref(false)
+const deviceOptions = ref<any[]>([])
+
+async function loadDeviceOptions() {
+  if (!deviceQuery.value.deptId) return
+
+  deviceLoading.value = true
+  try {
+    const data = await IotDeviceApi.getBoardDevice({
+      deptId: deviceQuery.value.deptId,
+      ifInline: deviceQuery.value.ifInline,
+      pageSize: 100
+    })
+    // const data = await IotDeviceApi.getIotDeviceTdPage({
+    //   deptId: deviceQuery.value.deptId,
+    //   ifInline: deviceQuery.value.ifInline,
+    //   pageSize: 100
+    // })
+    deviceOptions.value = data.list.map((item: any) => ({
+      label: item.deviceCode + '-' + item.deviceName,
+      value: item.id,
+      raw: item
+    }))
+    handleDeviceChange(deviceOptions.value.filter((i) => i.raw.ifInline === 3).map((i) => i.value))
+  } catch (error) {
+    deviceOptions.value = []
+  } finally {
+    deviceLoading.value = false
+  }
+}
+
+const deviceList = ref<DeviceData[]>([])
+const chartOption = ref<EChartsOption>({})
+
+async function handleDeviceChange(selectedIds: number[]) {
+  deviceList.value = deviceList.value.filter((d) => selectedIds.includes(d.id))
+
+  const currentIds = deviceList.value.map((d) => d.id)
+  const newIds = selectedIds.filter((id) => !currentIds.includes(id))
+
+  for (const id of newIds) {
+    const option = deviceOptions.value.find((op) => op.value === id)
+    if (option) {
+      deviceList.value.push({
+        id: id,
+        ifInline: option.raw.ifInline,
+        lastInlineTime: option.raw.lastInlineTime,
+        deptName: option.raw.deptName,
+        vehicleName: option.raw.vehicleName,
+        carOnline: option.raw.carOnline ?? '',
+        deviceCode: option.raw.deviceCode,
+        deviceName: option.raw.deviceName,
+        mqttUrl: option.raw.mqttUrl
+      })
+    }
+  }
+}
+
+onMounted(() => {
+  loadDeptOptions()
+  loadDeviceOptions()
+})
+
+function handleDeptChange() {
+  query.value = { ...originalQuery }
+  deviceList.value = []
+  chartOption.value = {}
+  loadDeviceOptions()
+}
+
+const targetArea = ref(null)
+
+const { toggle, isFullscreen } = useFullscreen(targetArea)
+
+function handleRest() {
+  deviceQuery.value = { ...originalDeviceQuery }
+  query.value = { ...originalQuery }
+  loadDeptOptions()
+  loadDeviceOptions()
+}
+
+function handleInlineChange() {
+  loadDeviceOptions()
+}
+
+const token = ref('')
+
+async function getToken() {
+  const res = await IotDeviceApi.getToken()
+
+  token.value = res
+}
+
+onMounted(() => {
+  getToken()
+})
+</script>
+
+<template>
+  <div
+    ref="targetArea"
+    class="relative w-full rounded-lg bg-[#020408] overflow-hidden min-h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
+  >
+    <header
+      class="relative w-full h-14 flex items-center justify-center select-none bg-[#0b1121] border-b border-white/5 shadow-lg"
+    >
+      <div
+        class="absolute inset-0 opacity-20"
+        style="background-image: radial-gradient(circle at 50% 50%, #083344 0%, transparent 50%)"
+      >
+      </div>
+
+      <div class="absolute bottom-0 left-0 w-full h-[2px] bg-slate-800/50 overflow-hidden z-20">
+        <div
+          class="absolute top-0 bottom-0 w-[40%] bg-gradient-to-r from-transparent via-[#22d3ee] to-transparent shadow-[0_0_20px_#22d3ee] animate-scan-line"
+        ></div>
+      </div>
+
+      <div class="absolute left-6 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-80">
+        <div
+          class="w-1 h-4 bg-cyan-400 skew-x-[-12deg] shadow-[0_0_5px_rgba(34,211,238,0.8)]"
+        ></div>
+        <div class="w-1 h-3 bg-cyan-700 skew-x-[-12deg]"></div>
+        <div class="w-1 h-2 bg-cyan-900 skew-x-[-12deg]"></div>
+      </div>
+
+      <div
+        class="absolute right-6 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-80 flex-row-reverse"
+      >
+        <div class="w-1 h-4 bg-cyan-400 skew-x-[12deg] shadow-[0_0_5px_rgba(34,211,238,0.8)]"></div>
+        <div class="w-1 h-3 bg-cyan-700 skew-x-[12deg]"></div>
+        <div class="w-1 h-2 bg-cyan-900 skew-x-[12deg]"></div>
+      </div>
+
+      <h1 class="z-10 text-2xl font-bold tracking-[0.5em] uppercase">
+        <span
+          class="text-transparent bg-clip-text bg-gradient-to-b from-white via-cyan-100 to-cyan-500 drop-shadow-[0_0_10px_rgba(34,211,238,0.8)]"
+        >
+          监控看板
+        </span>
+      </h1>
+    </header>
+    <div class="p-4">
+      <el-form size="default" class="search-container grid grid-cols-6 gap-6">
+        <div
+          class="absolute left-0 top-0 w-[2px] h-full bg-gradient-to-b from-transparent via-cyan-500 to-transparent"
+        ></div>
+        <el-form-item class="col-span-1" label="部门">
+          <el-cascader
+            v-model="deviceQuery.deptId"
+            :options="deptOptions"
+            @change="handleDeptChange"
+            popper-class="poper"
+            :teleported="false"
+            :show-all-levels="false"
+            :props="{ checkStrictly: true, label: 'name', value: 'id' }"
+            class="w-full"
+            placeholder="请选择部门"
+          />
+        </el-form-item>
+        <el-form-item class="col-span-1" label="在线状态">
+          <el-select
+            v-model="deviceQuery.ifInline"
+            placeholder="请选择状态"
+            clearable
+            :teleported="false"
+            popper-class="poper"
+            class="w-full"
+            :class="{ selected: Boolean(deviceQuery.ifInline) }"
+            @change="handleInlineChange"
+          >
+            <el-option
+              v-for="dict in getStrDictOptions(DICT_TYPE.IOT_DEVICE_STATUS)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item class="col-span-3" label="时间范围">
+          <el-date-picker
+            v-model="query.time"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            type="datetimerange"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            :shortcuts="rangeShortcuts"
+            :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+            :teleported="false"
+            popper-class="poper"
+            class="w-full"
+          />
+        </el-form-item>
+        <el-form-item class="col-span-5 col-start-1" label="设备">
+          <el-select
+            v-model="query.deviceCodes"
+            :options="deviceOptions"
+            multiple
+            placeholder="请选择设备"
+            :teleported="false"
+            popper-class="poper"
+            class="w-full"
+            tag-type="primary"
+            filterable
+            @change="handleDeviceChange"
+          />
+        </el-form-item>
+        <el-form-item class="col-span-1 flex justify-end">
+          <div class="flex gap-3 w-full justify-end">
+            <el-button
+              class="custom-btn primary-btn"
+              :type="isFullscreen ? 'info' : 'primary'"
+              :icon="isFullscreen ? Crop : FullScreen"
+              @click="toggle"
+            >
+              {{ isFullscreen ? '退出全屏' : '全屏' }}
+            </el-button>
+            <el-button @click="handleRest" class="custom-btn reset-btn">重置</el-button>
+          </div>
+        </el-form-item>
+      </el-form>
+    </div>
+
+    <div class="p-4 grid grid-cols-2 3xl:grid-cols-3 gap-6">
+      <template v-for="item in deviceList" :key="item.id">
+        <chart v-bind="item" :date="query.time" :token="token" />
+      </template>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+@keyframes scan-line {
+  0% {
+    left: -40%;
+    opacity: 0;
+  }
+
+  10% {
+    opacity: 1;
+  }
+
+  90% {
+    opacity: 1;
+  }
+
+  100% {
+    left: 100%;
+    opacity: 0;
+  }
+}
+
+/* 5. 扫描线动画细节 */
+@keyframes scan-line {
+  0% {
+    left: -40%;
+    opacity: 0;
+  }
+
+  50% {
+    opacity: 1;
+  }
+
+  100% {
+    left: 100%;
+    opacity: 0;
+  }
+}
+
+.animate-scan-line {
+  animation: scan-line 3s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
+}
+
+.search-container {
+  position: relative;
+  padding: 24px;
+  background: linear-gradient(135deg, rgb(11 17 33 / 90%) 0%, rgb(6 9 18 / 95%) 100%);
+  border: 1px solid rgb(34 211 238 / 60%);
+  border-radius: 4px;
+  box-shadow: 0 10px 40px rgb(0 0 0 / 60%);
+}
+
+:deep(.el-form-item) {
+  --background-color: rgb(2 4 8 / 80%) !important;
+  --box-shadow: 0 0 0 1px rgb(34 211 238 / 20%) inset !important;
+  --transition: all 0.3s ease;
+  --active-box-shadow: 0 0 0 1px #22d3ee inset, 0 0 12px rgb(34 211 238 / 30%) !important;
+  --bg-color-overlay: rgb(16 24 45) !important;
+  --border-color: rgb(34 211 238 / 20%) !important;
+  --text-color-regular: rgb(240 249 255 / 90%) !important;
+  --text-color-primary: rgb(92 231 247 / 90%) !important;
+
+  margin-bottom: 0;
+
+  .el-form-item__label {
+    padding-bottom: 4px;
+    font-size: 14px;
+    font-weight: 500;
+    color: rgb(165 243 252 / 90%) !important;
+  }
+
+  .el-input__wrapper,
+  .el-select__wrapper {
+    background-color: var(--background-color);
+    box-shadow: var(--box-shadow);
+    transition: var(--transition);
+  }
+
+  .el-input__wrapper.is-focus,
+  .is-focused,
+  .is-active {
+    box-shadow: var(--active-box-shadow);
+  }
+
+  .poper {
+    --el-bg-color-overlay: var(--bg-color-overlay);
+    --el-border-color-light: var(--border-color);
+    --el-fill-color-light: var(--border-color);
+    --el-text-color-regular: var(--text-color-regular);
+    --el-text-color-primary: var(--text-color-primary);
+    --el-border-color-extra-light: var(--border-color);
+  }
+
+  .el-cascader-node {
+    &.is-active {
+      box-shadow: none;
+    }
+  }
+
+  .el-time-spinner__item.is-active {
+    box-shadow: none;
+  }
+
+  .cancel {
+    color: var(--el-color-danger);
+  }
+
+  .el-picker-panel__footer {
+    .is-text {
+      color: var(--el-color-danger);
+    }
+
+    .is-plain {
+      color: var(--text-color-primary);
+      background-color: var(--border-color);
+      border-color: var(--border-color);
+    }
+  }
+
+  .el-picker__popper {
+    border-color: var(--border-color);
+
+    .el-time-panel {
+      border-color: var(--border-color);
+    }
+
+    .el-picker-panel__footer {
+      .is-text {
+        color: var(--el-color-danger);
+      }
+
+      .is-plain {
+        color: var(--text-color-primary);
+        background-color: var(--border-color);
+        border-color: var(--border-color);
+      }
+    }
+  }
+
+  .el-input {
+    --el-input-hover-border-color: var(--border-color);
+  }
+
+  .el-input__inner,
+  .el-date-editor .el-range-input {
+    color: var(--text-color-primary);
+  }
+
+  .selected {
+    .el-select__placeholder {
+      color: var(--text-color-primary);
+    }
+  }
+
+  .el-tag {
+    --el-tag-bg-color: var(--border-color);
+    --el-tag-border-color: var(--border-color);
+    --el-tag-text-color: var(--text-color-primary);
+  }
+}
+
+.custom-btn {
+  border: none;
+  border-radius: 2px;
+  transition: all 0.3s;
+}
+
+.primary-btn {
+  color: #020617;
+  background: linear-gradient(90deg, #0891b2 0%, #22d3ee 100%);
+  box-shadow: 0 0 10px rgb(34 211 238 / 30%);
+}
+
+.primary-btn:hover {
+  background: linear-gradient(90deg, #22d3ee 0%, #67e8f9 100%);
+  transform: translateY(-1px);
+  box-shadow: 0 0 20px rgb(34 211 238 / 50%);
+}
+
+.reset-btn {
+  color: #94a3b8;
+  background: rgb(255 255 255 / 5%);
+  border: 1px solid rgb(148 163 184 / 30%);
+}
+
+.reset-btn:hover {
+  color: #fff;
+  background: rgb(255 255 255 / 10%);
+  border-color: rgb(255 255 255 / 50%);
+}
+</style>

+ 44 - 1
src/views/oli-connection/monitoring-query/index.vue

@@ -17,6 +17,7 @@ interface Query {
   deptId?: number
   deviceCode?: string
   time?: string[]
+  identifier?: string
 }
 
 const query = ref<Query>({
@@ -50,7 +51,7 @@ const isConditionValid = computed(() => {
 const canGoBack = computed(() => historyStack.value.length > 0)
 const canGoNext = computed(() => list.value.length >= pageSize.value)
 
-const deviceOptions = ref<{ label: string; value: string }[]>([])
+const deviceOptions = ref<{ label: string; value: string; raw: any }[]>([])
 
 const optionsLoading = ref(false)
 
@@ -64,6 +65,7 @@ const loadOptions = useDebounceFn(async function () {
     deviceOptions.value = data.map((item: any) => {
       return {
         label: item.deviceCode + '-' + item.deviceName,
+        raw: item,
         value: item.deviceCode
       }
     })
@@ -74,6 +76,9 @@ const loadOptions = useDebounceFn(async function () {
   }
 }, 300)
 
+const attributeLoading = ref(false)
+const attributeOptions = ref<{ label: string; value: string; raw: any }[]>([])
+
 const handleNodeClick = (data: any) => {
   query.value.deptId = data.id
   loadOptions()
@@ -83,6 +88,33 @@ onMounted(() => {
   loadOptions()
 })
 
+const loadAttrOptions = useDebounceFn(async function (id: any) {
+  attributeOptions.value = []
+  query.value.identifier = undefined
+  try {
+    attributeLoading.value = true
+    const data = await IotDeviceApi.getIotDeviceTds(Number(id))
+    attributeOptions.value = data.map((item: any) => {
+      return {
+        label: item.modelName,
+        raw: item,
+        value: item.identifier
+      }
+    })
+  } catch (error) {
+    console.error(error)
+  } finally {
+    attributeLoading.value = false
+  }
+}, 300)
+
+function handleDeviceChange(value: string) {
+  const option = deviceOptions.value.find((i) => i.value === value)
+  if (option) {
+    loadAttrOptions(option.raw.id)
+  }
+}
+
 const loadList = useDebounceFn(async function () {
   if (!isConditionValid.value) {
     // list.value = []
@@ -239,6 +271,7 @@ function formatterValue(row: ListItem) {
             :options="deviceOptions"
             placeholder="请选择设备"
             class="w-60!"
+            @change="handleDeviceChange"
           />
         </el-form-item>
         <el-form-item label="时间">
@@ -253,6 +286,16 @@ function formatterValue(row: ListItem) {
             class="!w-360px"
           />
         </el-form-item>
+        <el-form-item label="属性">
+          <el-select
+            :loading="attributeLoading"
+            v-model="query.identifier"
+            :options="attributeOptions"
+            placeholder="请选择属性"
+            :no-data-text="!query.deviceCode ? '请先选择设备' : '暂无数据'"
+            class="w-60!"
+          />
+        </el-form-item>
         <el-form-item>
           <el-button type="primary" @click="handleQuery()">
             <Icon icon="ep:search" class="mr-5px" /> 搜索

+ 182 - 27
src/views/oli-connection/monitoring/detail.vue

@@ -8,7 +8,8 @@ import {
   CircleCloseFilled,
   DataLine,
   Crop,
-  FullScreen
+  FullScreen,
+  Setting
 } from '@element-plus/icons-vue'
 import { AnimatedCountTo } from '@/components/AnimatedCountTo'
 import { neonColors } from '@/utils/td-color'
@@ -19,6 +20,7 @@ import { Dimensions, formatIotValue, HeaderItem, useSocketBus } from '@/utils/us
 import { rangeShortcuts } from '@/utils/formatTime'
 import { useFullscreen } from '@vueuse/core'
 import { snapdom } from '@zumer/snapdom'
+import { ElMessage } from 'element-plus'
 
 const { query } = useRoute()
 
@@ -180,26 +182,30 @@ async function loadDimensions() {
           value: value,
           suffix: suffix,
           isText: isText,
-          response: false
+          response: false,
+          id: item.alarmSettingId,
+          maxValue: Number(item.maxValue),
+          minValue: Number(item.minValue)
         }
       })
 
-    const car = (((await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))) as any[]) ?? [])
-      .sort((a, b) => b.modelOrder - a.modelOrder)
-      .map((item) => {
-        const { value, suffix, isText } = formatIotValue(item.value)
-        console.log(`${item.modelName} :>> `, value)
-        return {
-          identifier: item.identifier,
-          name: item.modelName,
-          value: value,
-          suffix: suffix,
-          isText: isText,
-          response: false
-        }
-      })
-
-    const rawList = [...gateway, ...car]
+    // const car = (((await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))) as any[]) ?? [])
+    //   .sort((a, b) => b.modelOrder - a.modelOrder)
+    //   .map((item) => {
+    //     const { value, suffix, isText } = formatIotValue(item.value)
+    //     console.log(`${item.modelName} :>> `, value)
+    //     return {
+    //       identifier: item.identifier,
+    //       name: item.modelName,
+    //       value: value,
+    //       suffix: suffix,
+    //       isText: isText,
+    //       response: false
+    //     }
+    //   })
+
+    // const rawList = [...gateway, ...car]
+    const rawList = [...gateway]
 
     const uniqueMap = new Map()
 
@@ -224,9 +230,9 @@ async function loadDimensions() {
     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)
-    )
+    // carDimensions.value = dimensions.value.filter((d) =>
+    //   car.some((c) => c.identifier === d.identifier)
+    // )
 
     selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
     if (dimensions.value.length > 0) {
@@ -592,7 +598,7 @@ async function initLoadChartData(real_time: boolean = true) {
 
       lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
 
-      genderIntervalArr(true)
+      genderIntervalArr()
 
       updateSingleSeries(name)
 
@@ -710,6 +716,56 @@ onUnmounted(() => {
 const targetArea = ref(null)
 
 const { toggle, isFullscreen } = useFullscreen(targetArea)
+
+const message = useMessage()
+
+async function handleSave(item: Dimensions) {
+  const { minValue, maxValue } = item
+
+  // 1. 判断是否为空 (包括空字符串、null、undefined)
+  if (!minValue || !maxValue) {
+    return message.warning('最大值和最小值不能为空')
+  }
+
+  // 2. 判断是否为有效的数字
+  // Number() 处理字符串数字,isNaN 排除非数字字符
+  const min = Number(minValue)
+  const max = Number(maxValue)
+
+  if (isNaN(min) || isNaN(max)) {
+    return message.warning('请输入有效的数字')
+  }
+
+  // 3. (附加逻辑) 比较大小
+  if (min > max) {
+    return message.warning('最小值不能大于最大值')
+  }
+
+  const body = {
+    minValue: min,
+    maxValue: max,
+    deviceId: query.id,
+    propertyCode: item.identifier,
+    alarmProperty: item.name,
+    deviceName: data.value.deviceName,
+    id: item.id
+  }
+
+  const res = await IotDeviceApi.saveMaxMin(body)
+
+  if (res.id) item.id = res.id
+
+  message.success('设置成功')
+}
+async function handleReset(item: Dimensions) {
+  item.minValue = undefined
+  item.maxValue = undefined
+
+  await IotDeviceApi.deleteMaxMin({ id: item.id })
+  item.id = undefined
+
+  message.success('清除重置成功')
+}
 </script>
 
 <template>
@@ -755,7 +811,7 @@ const { toggle, isFullscreen } = useFullscreen(targetArea)
         </template>
       </div>
     </div>
-    <div ref="targetArea" class="h-full min-h-0 relative">
+    <div ref="targetArea" class="relative">
       <div class="flex flex-col gap-4 h-full">
         <template v-for="citem in dimensionsContent" :key="citem.label">
           <template v-if="citem.judgment ? Boolean(citem.value.length) : true">
@@ -797,14 +853,63 @@ const { toggle, isFullscreen } = useFullscreen(targetArea)
                     >
                       {{ item.name }}
                     </span>
-                    <div
+                    <el-popover placement="bottom" :width="280" trigger="click">
+                      <template #reference>
+                        <el-button class="group" link>
+                          <el-icon
+                            class="transition-transform duration-500 group-hover:rotate-180"
+                            :size="16"
+                          >
+                            <Setting />
+                          </el-icon>
+                        </el-button>
+                      </template>
+
+                      <div class="flex flex-col gap-3">
+                        <div class="text-sm font-bold text-gray-700 pb-1 border-b border-gray-100">
+                          设置范围
+                        </div>
+
+                        <div class="grid grid-cols-[auto_1fr] gap-y-3 gap-x-2 items-center">
+                          <span class="text-xs text-gray-500 text-right">最小值:</span>
+                          <el-input-number
+                            v-model="item.minValue"
+                            size="default"
+                            class="!w-full"
+                            placeholder="Min"
+                            :controls="false"
+                            align="left"
+                          />
+
+                          <span class="text-xs text-gray-500 text-right">最大值:</span>
+                          <el-input-number
+                            v-model="item.maxValue"
+                            size="default"
+                            class="!w-full"
+                            placeholder="Max"
+                            :controls="false"
+                            align="left"
+                          />
+                        </div>
+
+                        <div class="flex justify-end gap-2 pt-1">
+                          <el-button size="small" text bg @click="handleReset(item)">
+                            清除重置
+                          </el-button>
+                          <el-button size="small" type="primary" @click="handleSave(item)">
+                            保存
+                          </el-button>
+                        </div>
+                      </div>
+                    </el-popover>
+                    <!-- <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>
 
-                  <div class="flex items-baseline justify-between relative z-9">
+                  <!-- <div class="flex items-baseline justify-between relative z-9">
                     <animated-count-to
                       v-if="!item.isText"
                       :value="Number(item.value)"
@@ -815,6 +920,56 @@ const { toggle, isFullscreen } = useFullscreen(targetArea)
                     <span v-else class="text-lg font-bold font-mono tracking-tight text-slate-800">
                       {{ item.value }}
                     </span>
+                  </div> -->
+
+                  <div class="flex items-center justify-between relative z-9 mt-1">
+                    <div class="flex-1 mr-2">
+                      <animated-count-to
+                        v-if="!item.isText"
+                        :value="Number(item.value)"
+                        :duration="500"
+                        :suffix="item.suffix"
+                        class="text-2xl font-black font-mono tracking-tight text-slate-800 leading-none"
+                      />
+                      <span
+                        v-else
+                        class="text-2xl font-black font-mono tracking-tight text-slate-800 leading-none"
+                      >
+                        {{ item.value }}
+                      </span>
+                    </div>
+
+                    <div v-if="item.minValue || item.maxValue" class="flex gap-1.5 items-center">
+                      <div
+                        v-if="item.maxValue"
+                        class="flex items-center px-2 py-1 rounded-md bg-emerald-50/80 border border-solid border-emerald-100/80 shadow-sm transition-all duration-300 hover:bg-emerald-100 hover:border-emerald-200"
+                      >
+                        <div
+                          class="flex items-center justify-center w-4 h-4 mr-1 rounded-full bg-emerald-100 text-emerald-600 group-hover/max:bg-white group-hover/max:scale-110 transition-all"
+                        >
+                          <div class="i-material-symbols:arrow-upward-alt-rounded"></div>
+                        </div>
+                        <span class="text-[10px] font-bold text-emerald-400/80 mr-1.5">MAX</span>
+                        <span class="text-sm font-bold font-mono text-emerald-700">{{
+                          item.maxValue
+                        }}</span>
+                      </div>
+
+                      <div
+                        v-if="item.minValue"
+                        class="flex items-center px-2 py-0.5 rounded-md bg-rose-50/80 border border-solid border-rose-100/80 shadow-sm transition-all duration-300 hover:bg-rose-100 hover:border-rose-200"
+                      >
+                        <div
+                          class="flex items-center justify-center w-4 h-4 mr-1 rounded-full bg-rose-100 text-rose-600 group-hover/min:bg-white group-hover/min:scale-110 transition-all"
+                        >
+                          <div class="i-material-symbols:arrow-downward-alt-rounded"></div>
+                        </div>
+                        <span class="text-[10px] font-bold text-rose-400/80 mr-1.5">MIN</span>
+                        <span class="text-sm font-bold font-mono text-rose-700">{{
+                          item.minValue
+                        }}</span>
+                      </div>
+                    </div>
                   </div>
                   <div
                     class="absolute left-0 top-3 bottom-3 w-1 rounded-r transition-all duration-300"
@@ -832,7 +987,7 @@ const { toggle, isFullscreen } = useFullscreen(targetArea)
           </template>
         </template>
         <div
-          class="flex-1 rounded-xl shadow-sm border border-gray-100 border-solid p-4 flex flex-col bg-gradient-to-b from-blue-100 to-white"
+          class="flex-1 min-h-200 rounded-xl shadow-sm border border-gray-100 border-solid p-4 flex flex-col bg-gradient-to-b from-blue-100 to-white"
         >
           <header class="flex items-center justify-between mb-4">
             <h3 class="flex items-center gap-2">

+ 174 - 160
src/views/oli-connection/monitoring/index.vue

@@ -17,7 +17,7 @@ interface Query {
   deptId?: number
   deviceName?: string
   deviceCode?: string
-  ifInline?: number
+  ifInline?: string
   pageNo: number
   pageSize: number
 }
@@ -243,184 +243,198 @@ const openDetail = (
       <div class="flex-1 relative">
         <el-auto-resizer class="absolute">
           <template #default="{ width, height }">
-            <zm-table
-              v-if="viewMode === 'list'"
-              :data="list"
-              :loading="loading"
-              :width="width"
-              :max-height="height"
-              :height="height"
-            >
-              <zm-table-column type="index" :label="t('monitor.serial')" :width="60" />
-              <zm-table-column prop="deviceName" :label="t('monitor.deviceName')" />
-              <zm-table-column prop="deviceCode" :label="t('monitor.deviceCode')" />
-              <zm-table-column prop="assetClassName" :label="t('monitor.category')" />
-              <zm-table-column prop="deviceStatus" :label="t('monitor.status')">
-                <template #default="scope">
-                  <dict-tag :type="DICT_TYPE.PMS_DEVICE_STATUS" :value="scope.row.deviceStatus" />
-                </template>
-              </zm-table-column>
-              <zm-table-column prop="ifInline" :label="t('monitor.ifInline')">
-                <template #default="scope">
-                  <dict-tag :type="DICT_TYPE.IOT_DEVICE_STATUS" :value="scope.row.ifInline" />
-                </template>
-              </zm-table-column>
-              <zm-table-column prop="lastInlineTime" :label="t('monitor.latestDataTime')" />
-              <zm-table-column :label="t('monitor.operation')" :width="60">
-                <template #default="scope">
-                  <el-button
-                    link
-                    type="primary"
-                    @click="
-                      openDetail(
-                        scope.row.id,
-                        scope.row.ifInline,
-                        scope.row.lastInlineTime,
-                        scope.row.deviceName,
-                        scope.row.deviceCode,
-                        scope.row.deptName,
-                        scope.row.vehicleName,
-                        scope.row.carOnline ?? ''
-                      )
-                    "
-                  >
-                    {{ t('monitor.check') }}
-                  </el-button>
-                </template>
-              </zm-table-column>
-            </zm-table>
-            <el-scrollbar
-              v-else
-              :height="height"
-              :class="width"
-              view-class="grid grid-cols-4 grid-rows-3 gap-4 p-4"
-            >
-              <div
-                v-for="item in list"
-                :key="item.id"
-                class="group relative flex flex-col bg-white dark:bg-[#262727] rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 hover:shadow-[0_8px_20px_rgba(0,0,0,0.1)] hover:-translate-y-1 transition-all duration-300 overflow-hidden"
+            <template v-if="list.length > 0">
+              <zm-table
+                v-if="viewMode === 'list'"
+                :data="list"
+                :loading="loading"
+                :width="width"
+                :max-height="height"
+                :height="height"
+              >
+                <zm-table-column type="index" :label="t('monitor.serial')" :width="60" />
+                <zm-table-column prop="deviceName" :label="t('monitor.deviceName')" />
+                <zm-table-column prop="deviceCode" :label="t('monitor.deviceCode')" />
+                <zm-table-column prop="assetClassName" :label="t('monitor.category')" />
+                <zm-table-column prop="deviceStatus" :label="t('monitor.status')">
+                  <template #default="scope">
+                    <dict-tag :type="DICT_TYPE.PMS_DEVICE_STATUS" :value="scope.row.deviceStatus" />
+                  </template>
+                </zm-table-column>
+                <zm-table-column prop="ifInline" :label="t('monitor.ifInline')">
+                  <template #default="scope">
+                    <dict-tag :type="DICT_TYPE.IOT_DEVICE_STATUS" :value="scope.row.ifInline" />
+                  </template>
+                </zm-table-column>
+                <zm-table-column prop="lastInlineTime" :label="t('monitor.latestDataTime')" />
+                <zm-table-column :label="t('monitor.operation')" :width="60">
+                  <template #default="scope">
+                    <el-button
+                      link
+                      type="primary"
+                      @click="
+                        openDetail(
+                          scope.row.id,
+                          scope.row.ifInline,
+                          scope.row.lastInlineTime,
+                          scope.row.deviceName,
+                          scope.row.deviceCode,
+                          scope.row.deptName,
+                          scope.row.vehicleName,
+                          scope.row.carOnline ?? ''
+                        )
+                      "
+                    >
+                      {{ t('monitor.check') }}
+                    </el-button>
+                  </template>
+                </zm-table-column>
+              </zm-table>
+              <el-scrollbar
+                v-else
+                :height="height"
+                :class="width"
+                view-class="grid grid-cols-4 grid-rows-3 gap-4 p-4"
               >
                 <div
-                  class="h-[80px] px-4 flex items-center justify-between overflow-hidden"
-                  :class="getStatusConfig(item.ifInline).bg"
+                  v-for="item in list"
+                  :key="item.id"
+                  class="group relative flex flex-col bg-white dark:bg-[#262727] rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 hover:shadow-[0_8px_20px_rgba(0,0,0,0.1)] hover:-translate-y-1 transition-all duration-300 overflow-hidden"
                 >
-                  <div class="flex items-center gap-3 z-10 max-w-[80%]">
-                    <div class="bg-white/20 p-2 rounded-lg backdrop-blur-md shadow-inner shrink-0">
-                      <Icon :icon="item.carId ? 'ep:van' : 'ep:cpu'" class="text-xl text-white" />
-                    </div>
+                  <div
+                    class="h-[80px] px-4 flex items-center justify-between overflow-hidden"
+                    :class="getStatusConfig(item.ifInline).bg"
+                  >
+                    <div class="flex items-center gap-3 z-10 max-w-[80%]">
+                      <div
+                        class="bg-white/20 p-2 rounded-lg backdrop-blur-md shadow-inner shrink-0"
+                      >
+                        <Icon :icon="item.carId ? 'ep:van' : 'ep:cpu'" class="text-xl text-white" />
+                      </div>
 
-                    <!-- 文本区域 -->
-                    <div class="flex flex-col overflow-hidden">
-                      <el-tooltip effect="dark" :content="item.deviceName" placement="top-start">
-                        <span
-                          class="text-white font-bold text-base leading-tight"
-                          :title="item.deviceName"
-                        >
-                          {{ item.deviceName }}
-                        </span>
-                      </el-tooltip>
+                      <!-- 文本区域 -->
+                      <div class="flex flex-col overflow-hidden">
+                        <el-tooltip effect="dark" :content="item.deviceName" placement="top-start">
+                          <span
+                            class="text-white font-bold text-base leading-tight"
+                            :title="item.deviceName"
+                          >
+                            {{ item.deviceName }}
+                          </span>
+                        </el-tooltip>
 
-                      <span class="text-white/80 text-xs font-mono truncate mt-0.5">
-                        {{ item.deviceCode }}
-                      </span>
+                        <span class="text-white/80 text-xs font-mono truncate mt-0.5">
+                          {{ item.deviceCode }}
+                        </span>
+                      </div>
                     </div>
-                  </div>
 
-                  <div class="z-10 shrink-0">
-                    <div
-                      class="flex items-center gap-1.5 bg-black/20 backdrop-blur-md px-2.5 py-1 rounded-full text-xs font-medium text-white shadow-sm border border-white/10"
-                    >
+                    <div class="z-10 shrink-0">
                       <div
-                        class="w-1.5 h-1.5 rounded-full bg-white animate-pulse"
-                        v-if="item.ifInline === 3"
+                        class="flex items-center gap-1.5 bg-black/20 backdrop-blur-md px-2.5 py-1 rounded-full text-xs font-medium text-white shadow-sm border border-white/10"
                       >
+                        <div
+                          class="w-1.5 h-1.5 rounded-full bg-white animate-pulse"
+                          v-if="item.ifInline === 3"
+                        >
+                        </div>
+                        <Icon :icon="getStatusConfig(item.ifInline).icon" v-else />
+                        <span>{{ getStatusConfig(item.ifInline).label }}</span>
                       </div>
-                      <Icon :icon="getStatusConfig(item.ifInline).icon" v-else />
-                      <span>{{ getStatusConfig(item.ifInline).label }}</span>
                     </div>
                   </div>
-                </div>
 
-                <!-- 内容区域 -->
-                <div
-                  class="flex-1 p-4 flex flex-col gap-3 text-sm text-gray-600 dark:text-gray-300 relative z-20 bg-white dark:bg-[#262727]"
-                >
-                  <!-- 编码行 -->
-                  <div class="flex items-center justify-between pb-2">
-                    <span class="text-gray-400 text-xs flex items-center gap-1.5">
-                      <Icon icon="ep:postcard" /> 设备编码
-                    </span>
-                    <span
-                      class="font-mono font-medium truncate max-w-[140px] select-all"
-                      :title="item.deviceCode"
-                    >
-                      {{ item.deviceCode }}
-                    </span>
-                  </div>
+                  <!-- 内容区域 -->
+                  <div
+                    class="flex-1 p-4 flex flex-col gap-3 text-sm text-gray-600 dark:text-gray-300 relative z-20 bg-white dark:bg-[#262727]"
+                  >
+                    <!-- 编码行 -->
+                    <div class="flex items-center justify-between pb-2">
+                      <span class="text-gray-400 text-xs flex items-center gap-1.5">
+                        <Icon icon="ep:postcard" /> 设备编码
+                      </span>
+                      <span
+                        class="font-mono font-medium truncate max-w-[140px] select-all"
+                        :title="item.deviceCode"
+                      >
+                        {{ item.deviceCode }}
+                      </span>
+                    </div>
 
-                  <!-- 类别行 -->
-                  <div class="flex items-center justify-between pb-2">
-                    <span class="text-gray-400 text-xs flex items-center gap-1.5">
-                      <Icon icon="ep:price-tag" /> 设备类别
-                    </span>
-                    <el-tag
-                      size="small"
-                      type="info"
-                      effect="light"
-                      round
-                      class="!bg-gray-100 dark:!bg-gray-800 !border-gray-200 dark:!border-gray-600"
-                    >
-                      {{ item.assetClassName || '-' }}
-                    </el-tag>
+                    <!-- 类别行 -->
+                    <div class="flex items-center justify-between pb-2">
+                      <span class="text-gray-400 text-xs flex items-center gap-1.5">
+                        <Icon icon="ep:price-tag" /> 设备类别
+                      </span>
+                      <el-tag
+                        size="small"
+                        type="info"
+                        effect="light"
+                        round
+                        class="!bg-gray-100 dark:!bg-gray-800 !border-gray-200 dark:!border-gray-600"
+                      >
+                        {{ item.assetClassName || '-' }}
+                      </el-tag>
+                    </div>
+
+                    <!-- 时间行 -->
+                    <div class="flex items-center justify-between">
+                      <span class="text-gray-400 text-xs flex items-center gap-1.5">
+                        <Icon icon="ep:clock" /> 最后上线
+                      </span>
+                      <span
+                        class="text-xs font-medium"
+                        :class="
+                          item.lastInlineTime ? 'text-gray-600 dark:text-gray-300' : 'text-gray-300'
+                        "
+                      >
+                        {{ item.lastInlineTime || '暂无记录' }}
+                      </span>
+                    </div>
                   </div>
 
-                  <!-- 时间行 -->
-                  <div class="flex items-center justify-between">
-                    <span class="text-gray-400 text-xs flex items-center gap-1.5">
-                      <Icon icon="ep:clock" /> 最后上线
-                    </span>
-                    <span
-                      class="text-xs font-medium"
-                      :class="
-                        item.lastInlineTime ? 'text-gray-600 dark:text-gray-300' : 'text-gray-300'
-                      "
-                    >
-                      {{ item.lastInlineTime || '暂无记录' }}
-                    </span>
+                  <!-- 底部操作栏 -->
+                  <div
+                    class="px-4 py-2.5 bg-gray-50/80 dark:bg-[#1d1e1f] flex justify-between items-center group-hover:bg-blue-50/30 dark:group-hover:bg-blue-900/10 transition-colors"
+                  >
+                    <span class="text-[10px] text-gray-400"></span>
+                    <!-- <span class="text-[10px] text-gray-400">ID: {{ item.id }}</span> -->
+                    <el-button type="primary" link size="small" class="!px-0 group/btn">
+                      <span
+                        class="mr-1 group-hover/btn:underline"
+                        @click="
+                          openDetail(
+                            item.id,
+                            item.ifInline,
+                            item.lastInlineTime,
+                            item.deviceName,
+                            item.deviceCode,
+                            item.deptName,
+                            item.vehicleName,
+                            item.carOnline ?? ''
+                          )
+                        "
+                        >查看详情</span
+                      >
+                      <Icon
+                        icon="ep:arrow-right"
+                        class="group-hover/btn:translate-x-1 transition-transform"
+                      />
+                    </el-button>
                   </div>
                 </div>
+              </el-scrollbar>
+            </template>
+            <div
+              v-else
+              :style="{ width: width + 'px', height: height + 'px' }"
+              class="flex flex-col items-center justify-center text-gray-400"
+            >
+              <div class="i-lucide-inbox text-5xl mb-4 op-50"></div>
 
-                <!-- 底部操作栏 -->
-                <div
-                  class="px-4 py-2.5 bg-gray-50/80 dark:bg-[#1d1e1f] flex justify-between items-center group-hover:bg-blue-50/30 dark:group-hover:bg-blue-900/10 transition-colors"
-                >
-                  <span class="text-[10px] text-gray-400"></span>
-                  <!-- <span class="text-[10px] text-gray-400">ID: {{ item.id }}</span> -->
-                  <el-button type="primary" link size="small" class="!px-0 group/btn">
-                    <span
-                      class="mr-1 group-hover/btn:underline"
-                      @click="
-                        openDetail(
-                          item.id,
-                          item.ifInline,
-                          item.lastInlineTime,
-                          item.deviceName,
-                          item.deviceCode,
-                          item.deptName,
-                          item.vehicleName,
-                          item.carOnline ?? ''
-                        )
-                      "
-                      >查看详情</span
-                    >
-                    <Icon
-                      icon="ep:arrow-right"
-                      class="group-hover/btn:translate-x-1 transition-transform"
-                    />
-                  </el-button>
-                </div>
-              </div>
-            </el-scrollbar>
+              <p class="text-sm font-medium">暂无相关数据</p>
+              <p class="text-xs mt-1 op-60">尝试调整过滤条件或刷新页面</p>
+            </div>
           </template>
         </el-auto-resizer>
       </div>

+ 83 - 79
src/views/pms/device/index.vue

@@ -380,81 +380,22 @@
             </template>
           </el-table-column>
           <el-table-column
-            :label="t('iotDevice.assets')"
-            align="center"
-            prop="assetProperty"
-            min-width="110"
-          >
-            <template #header>
-              <div class="flex items-center justify-center pb-[1px]">
-                <el-dropdown @command="handleAssetProperty">
-                  <span class="text-[#ad9399] text-[12px] cursor-pointer flex items-center gap-1">
-                    <span> 资产性质 </span> <Icon icon="ep:arrow-down" />
-                  </span>
-                  <template #dropdown>
-                    <el-dropdown-menu>
-                      <el-dropdown-item
-                        v-for="item in getStrDictOptions(DICT_TYPE.PMS_ASSET_PROPERTY)"
-                        :key="item.label"
-                        :command="item.value"
-                        >{{ item.label }}</el-dropdown-item
-                      >
-                    </el-dropdown-menu>
-                  </template>
-                </el-dropdown>
-              </div>
-            </template>
-            <template #default="scope">
-              <dict-tag :type="DICT_TYPE.PMS_ASSET_PROPERTY" :value="scope.row.assetProperty" />
-            </template>
-          </el-table-column>
-          <el-table-column
-            :label="t('iotDevice.assetClass')"
+            :label="t('deviceForm.model')"
             align="center"
-            prop="assetClassName"
+            prop="model"
             min-width="170"
           >
-            <template #header>
-              <span
-                style="display: inline-block"
-                class="text-[#ad9399] w-[70px] text-[12px] cursor-pointer z-[999] justify-center flex items-center"
-              >
-                <el-popover placement="bottom" :width="250" trigger="click">
-                  <template #reference>
-                    <div class="flex items-center cursor-pointer gap-1" @click.stop>
-                      <span> {{ t('iotDevice.assetClass') }} </span> <Icon icon="ep:arrow-down" />
-                    </div>
-                  </template>
-                  <div class="flex items-center gap-2">
-                    <el-tree-select
-                      :teleported="false"
-                      v-model="queryParams.assetClass"
-                      :data="productClassifyList"
-                      :props="defaultProps"
-                      check-strictly
-                      node-key="id"
-                      :placeholder="t('deviceForm.categoryHolder')"
-                      filterable
-                      @change="handleQuery"
-                    />
-                  </div>
-                </el-popover>
-              </span>
-            </template>
-          </el-table-column>
-
-          <el-table-column label="车牌号" align="center" prop="carNo" min-width="170">
             <template #header>
               <el-popover placement="bottom" :width="250" trigger="click">
                 <template #reference>
                   <div class="flex items-center cursor-pointer gap-1" @click.stop>
-                    <span> 车牌号 </span> <Icon icon="ep:arrow-down" />
+                    <span> {{ t('deviceForm.model') }} </span> <Icon icon="ep:arrow-down" />
                   </div>
                 </template>
                 <div class="flex items-center gap-2">
                   <el-input
-                    v-model="queryParams.carNo"
-                    placeholder="请输入车牌号"
+                    v-model="queryParams.model"
+                    placeholder="请输入规格型号"
                     style="width: 180px"
                     clearable
                   />
@@ -498,23 +439,19 @@
               </el-popover>
             </template>
           </el-table-column>
-          <el-table-column
-            :label="t('deviceForm.brand')"
-            align="center"
-            prop="brandName"
-            min-width="150"
-          >
+
+          <el-table-column label="车牌号" align="center" prop="carNo" min-width="170">
             <template #header>
               <el-popover placement="bottom" :width="250" trigger="click">
                 <template #reference>
                   <div class="flex items-center cursor-pointer gap-1" @click.stop>
-                    <span> {{ t('deviceForm.brand') }} </span> <Icon icon="ep:arrow-down" />
+                    <span> 车牌号 </span> <Icon icon="ep:arrow-down" />
                   </div>
                 </template>
                 <div class="flex items-center gap-2">
                   <el-input
-                    v-model="queryParams.brandName"
-                    placeholder="请输入品牌"
+                    v-model="queryParams.carNo"
+                    placeholder="请输入车牌号"
                     style="width: 180px"
                     clearable
                   />
@@ -523,23 +460,24 @@
               </el-popover>
             </template>
           </el-table-column>
+
           <el-table-column
-            :label="t('deviceForm.model')"
+            :label="t('deviceForm.brand')"
             align="center"
-            prop="model"
-            min-width="170"
+            prop="brandName"
+            min-width="150"
           >
             <template #header>
               <el-popover placement="bottom" :width="250" trigger="click">
                 <template #reference>
                   <div class="flex items-center cursor-pointer gap-1" @click.stop>
-                    <span> {{ t('deviceForm.model') }} </span> <Icon icon="ep:arrow-down" />
+                    <span> {{ t('deviceForm.brand') }} </span> <Icon icon="ep:arrow-down" />
                   </div>
                 </template>
                 <div class="flex items-center gap-2">
                   <el-input
-                    v-model="queryParams.model"
-                    placeholder="请输入规格型号"
+                    v-model="queryParams.brandName"
+                    placeholder="请输入品牌"
                     style="width: 180px"
                     clearable
                   />
@@ -548,6 +486,72 @@
               </el-popover>
             </template>
           </el-table-column>
+
+          <el-table-column
+            :label="t('iotDevice.assetClass')"
+            align="center"
+            prop="assetClassName"
+            min-width="170"
+          >
+            <template #header>
+              <span
+                style="display: inline-block"
+                class="text-[#ad9399] w-[70px] text-[12px] cursor-pointer z-[999] justify-center flex items-center"
+              >
+                <el-popover placement="bottom" :width="250" trigger="click">
+                  <template #reference>
+                    <div class="flex items-center cursor-pointer gap-1" @click.stop>
+                      <span> {{ t('iotDevice.assetClass') }} </span> <Icon icon="ep:arrow-down" />
+                    </div>
+                  </template>
+                  <div class="flex items-center gap-2">
+                    <el-tree-select
+                      :teleported="false"
+                      v-model="queryParams.assetClass"
+                      :data="productClassifyList"
+                      :props="defaultProps"
+                      check-strictly
+                      node-key="id"
+                      :placeholder="t('deviceForm.categoryHolder')"
+                      filterable
+                      @change="handleQuery"
+                    />
+                  </div>
+                </el-popover>
+              </span>
+            </template>
+          </el-table-column>
+
+          <el-table-column
+            :label="t('iotDevice.assets')"
+            align="center"
+            prop="assetProperty"
+            min-width="110"
+          >
+            <template #header>
+              <div class="flex items-center justify-center pb-[1px]">
+                <el-dropdown @command="handleAssetProperty">
+                  <span class="text-[#ad9399] text-[12px] cursor-pointer flex items-center gap-1">
+                    <span> 资产性质 </span> <Icon icon="ep:arrow-down" />
+                  </span>
+                  <template #dropdown>
+                    <el-dropdown-menu>
+                      <el-dropdown-item
+                        v-for="item in getStrDictOptions(DICT_TYPE.PMS_ASSET_PROPERTY)"
+                        :key="item.label"
+                        :command="item.value"
+                        >{{ item.label }}</el-dropdown-item
+                      >
+                    </el-dropdown-menu>
+                  </template>
+                </el-dropdown>
+              </div>
+            </template>
+            <template #default="scope">
+              <dict-tag :type="DICT_TYPE.PMS_ASSET_PROPERTY" :value="scope.row.assetProperty" />
+            </template>
+          </el-table-column>
+
           <el-table-column
             :label="t('devicePerson.rp')"
             align="center"

+ 43 - 37
src/views/pms/iotmainworkorder/IotMainWorkOrderOptimize.vue

@@ -5,7 +5,7 @@
       :model="formData"
       :rules="formRules"
       v-loading="formLoading"
-      style="margin-right: 4em; margin-left: 0.5em; margin-top: 1em"
+      style="margin-top: 1em; margin-right: 4em; margin-left: 0.5em"
       label-width="130px"
     >
       <div class="base-expandable-content">
@@ -553,8 +553,8 @@
     <ContentWrap>
       <div
         style="
-          margin-bottom: 15px;
           display: flex;
+          margin-bottom: 15px;
           justify-content: flex-start;
           align-items: center;
           gap: 12px;
@@ -1537,6 +1537,7 @@ const validateMainRuntime = (row: IotMainWorkOrderBomVO) => {
 
 // 校验 mainMileage 的方法
 const validateMainMileage = (row: IotMainWorkOrderBomVO) => {
+  if (row.mileageRule !== 0) return true
   // 清除之前的错误
   row.mainMileageError = ''
 
@@ -2998,17 +2999,17 @@ const handleRowClick = (row) => {
 }
 
 :deep(.el-input-number .el-input__inner) {
-  text-align: left !important;
   padding-left: 10px; /* 保持左侧间距 */
+  text-align: left !important;
 }
 
 /* 分组容器样式 */
 .form-group {
   position: relative;
-  border: 1px solid #dcdfe6;
-  border-radius: 4px;
   padding: 20px 15px 10px;
   margin-bottom: 18px;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
   transition: border-color 0.2s;
 }
 
@@ -3017,51 +3018,54 @@ const handleRowClick = (row) => {
   position: absolute;
   top: -10px;
   left: 20px;
-  background: white;
   padding: 0 8px;
-  color: #606266;
   font-size: 12px;
   font-weight: 500;
+  color: #606266;
+  background: white;
 }
 
 .error-text {
-  color: #f56c6c;
-  font-size: 12px;
   margin-top: 5px;
+  font-size: 12px;
+  color: #f56c6c;
 }
 
 :deep(.el-table__body) {
   .el-table__cell {
     .cell {
-      word-break: break-word;
       max-width: 600px; /* 最大宽度限制 */
+      word-break: break-word;
     }
   }
 }
 
 .full-content-cell {
-  white-space: nowrap; /* 禁止换行 */
   overflow: visible; /* 允许内容溢出单元格 */
+  white-space: nowrap; /* 禁止换行 */
 }
 
 /* 分组表头样式 */
 :deep(.el-table__header) {
   border: 1px solid #dcdfe6 !important;
 }
+
 :deep(.el-table__header th) {
   border-right: 1px solid #dcdfe6 !important;
   border-bottom: 1px solid #dcdfe6 !important;
 }
 
 :deep(.el-table__header .is-group th) {
+  position: relative;
+  font-weight: 600;
   background-color: #f5f7fa !important;
   border-bottom: 1px solid #dcdfe6 !important;
-  font-weight: 600;
-  position: relative;
 }
+
 :deep(.el-table__header .is-group th::after) {
   display: none !important;
 }
+
 /* 分组标题下的子表头单元格 */
 :deep(.el-table__header .el-table__cell:not(.is-group)) {
   border-top: 1px solid #dcdfe6 !important; /* 添加顶部边框连接分组标题 */
@@ -3085,14 +3089,14 @@ const handleRowClick = (row) => {
 
 /* 使用伪元素创建更明显的底部边框 */
 :deep(.el-table .group-first-row::after) {
-  content: '';
   position: absolute;
-  left: 0;
   right: 0;
   bottom: 0;
+  left: 0;
+  z-index: 1;
   height: 2px;
   background-color: #606266; /* 深灰色边框 */
-  z-index: 1;
+  content: '';
 }
 
 /* 调整分组行的高度,使边框更明显 */
@@ -3126,16 +3130,16 @@ const handleRowClick = (row) => {
 /* 单价列统一容器样式:强制居左,固定宽度,对齐基准一致 */
 .unit-price-container {
   width: 100%;
-  text-align: left; /* 强制内容居左 */
   padding: 0 4px; /* 可选:添加轻微内边距,避免内容贴边 */
+  text-align: left; /* 强制内容居左 */
   box-sizing: border-box;
 }
 
 /* 单价输入框样式:消除默认内边距差异,与静态文本对齐 */
 :deep(.unit-price-input .el-input__inner) {
-  text-align: left !important; /* 覆盖el-input默认居中对齐 */
-  padding-left: 8px !important; /* 统一输入框内边距,与文本缩进匹配 */
   padding-right: 8px !important;
+  padding-left: 8px !important; /* 统一输入框内边距,与文本缩进匹配 */
+  text-align: left !important; /* 覆盖el-input默认居中对齐 */
 }
 
 /* 单价静态文本样式:与输入框保持一致的内边距和对齐 */
@@ -3143,15 +3147,15 @@ const handleRowClick = (row) => {
   display: inline-block; /* 转为行内块,支持padding */
   width: 100%;
   padding: 4px 8px; /* 与输入框内边距匹配(输入框默认height约32px,padding上下4px) */
-  box-sizing: border-box;
   vertical-align: middle; /* 确保与输入框垂直对齐 */
+  box-sizing: border-box;
 }
 
 /* 禁用物料行的样式 */
 :deep(.disabled-material-row) {
-  background-color: #f5f7fa !important;
   color: #c0c4cc !important;
   cursor: not-allowed !important;
+  background-color: #f5f7fa !important;
 }
 
 :deep(.disabled-material-row:hover > td) {
@@ -3159,19 +3163,19 @@ const handleRowClick = (row) => {
 }
 
 :deep(.disabled-material-row .el-input.is-disabled .el-input__inner) {
-  background-color: #f5f7fa !important;
   color: #c0c4cc !important;
   cursor: not-allowed !important;
+  background-color: #f5f7fa !important;
 }
 
 :deep(.disabled-material-row .el-input-number.is-disabled) {
-  opacity: 0.6;
   cursor: not-allowed !important;
+  opacity: 0.6;
 }
 
 :deep(.disabled-material-row .el-button.is-disabled) {
-  opacity: 0.6;
   cursor: not-allowed !important;
+  opacity: 0.6;
 }
 
 /* 新增物料行的样式 */
@@ -3197,30 +3201,32 @@ const handleRowClick = (row) => {
 /* 必填输入框红色边框样式(含hover状态) */
 :deep(.is-required-input .el-input__inner) {
   border-color: #f56c6c !important; /* Element错误色 */
-  box-shadow: 0 0 0 1px rgba(245, 108, 108, 0.4) !important; /* 错误阴影 */
+  box-shadow: 0 0 0 1px rgb(245 108 108 / 40%) !important; /* 错误阴影 */
 }
+
 :deep(.is-required-input .el-input-number__input),
 :deep(.error-input .el-input-number__input) {
-  border-color: #f56c6c !important;
   background-color: #fef0f0 !important;
-  box-shadow: 0 0 0 1px rgba(245, 108, 108, 0.4) !important;
+  border-color: #f56c6c !important;
+  box-shadow: 0 0 0 1px rgb(245 108 108 / 40%) !important;
 }
 
 /* 鼠标悬停时保持红色边框(覆盖Element默认hover样式) */
 :deep(.is-required-input .el-input__inner:hover) {
   border-color: #f56c6c !important;
 }
+
 :deep(.is-required-input .el-input-number__input:hover),
 :deep(.error-input .el-input-number__input:hover) {
-  border-color: #f56c6c !important;
   background-color: #fef0f0 !important;
+  border-color: #f56c6c !important;
 }
 
 :deep(.is-required-input .el-input-number__input:focus),
 :deep(.error-input .el-input-number__input:focus) {
-  border-color: #f56c6c !important;
   background-color: #fef0f0 !important;
-  box-shadow: 0 0 0 1px rgba(245, 108, 108, 0.2) !important;
+  border-color: #f56c6c !important;
+  box-shadow: 0 0 0 1px rgb(245 108 108 / 20%) !important;
 }
 
 /* 状态列容器样式 - 水平排列 */
@@ -3239,9 +3245,9 @@ const handleRowClick = (row) => {
 
 /* 状态列样式优化 */
 .status-text {
+  min-width: 40px; /* 为文本设置最小宽度,确保对齐 */
   font-size: 12px;
   color: #666;
-  min-width: 40px; /* 为文本设置最小宽度,确保对齐 */
   text-align: left;
   flex-shrink: 0; /* 防止文本被压缩 */
 }
@@ -3271,12 +3277,12 @@ const handleRowClick = (row) => {
 
 /* 自定义淡红色背景的 tooltip */
 :deep(.main-runtime-tooltip) {
-  background: #fef0f0 !important;
-  border: 1px solid #fbc4c4 !important;
-  color: #f56c6c !important;
   max-width: 300px;
-  font-size: 12px;
   padding: 8px 12px;
+  font-size: 12px;
+  color: #f56c6c !important;
+  background: #fef0f0 !important;
+  border: 1px solid #fbc4c4 !important;
 }
 
 /* 隐藏 Tooltip 菱形箭头(核心需求) */
@@ -3289,14 +3295,14 @@ const handleRowClick = (row) => {
 
 /* 新增包装层样式,确保tooltip正确触发 */
 .main-runtime-input-wrapper {
-  width: 100%;
   position: relative;
+  width: 100%;
 }
 
 /* 负数值红色样式 */
 :deep(.negative-value) {
-  color: #f56c6c !important;
   font-weight: 600;
+  color: #f56c6c !important;
 }
 
 /* 确保在表格单元格中正确显示 */

+ 37 - 8
src/views/pms/iotopeationfill/index1.vue

@@ -252,7 +252,7 @@
               </el-form-item>
             </div>
 
-            <div v-if="companyName === 'ry'">
+            <div v-if="companyName === 'ry' && deviceItem.deviceName === '生产日报'">
               <div class="flex items-center justify-between mb-6">
                 <div class="flex items-center gap-2">
                   <div class="bg-[var(--el-color-primary)] w-1 h-5 rounded-full"></div>
@@ -292,6 +292,7 @@
                           class="w-full!"
                           value-format="x"
                           :disabled="fillStatus === '1'"
+                          @change="inputCurrentDepth()"
                         />
                       </el-form-item>
                     </template>
@@ -316,7 +317,7 @@
                           format="HH:mm"
                           value-format="HH:mm"
                           class="w-full!"
-                          @change="calculateDuration(row)"
+                          @change="acalculateDuration(row)"
                           :disabled="fillStatus === '1'"
                         />
                       </el-form-item>
@@ -341,7 +342,7 @@
                           format="HH:mm"
                           value-format="HH:mm"
                           class="w-full!"
-                          @change="calculateDuration(row)"
+                          @change="acalculateDuration(row)"
                           :disabled="fillStatus === '1'"
                         />
                       </el-form-item>
@@ -396,7 +397,7 @@
                           class="!w-full"
                           align="left"
                           placeholder="请输入结束井深"
-                          @input="(val) => inputCurrentDepth(val, $index)"
+                          @input="() => inputCurrentDepth()"
                           :disabled="fillStatus === '1'"
                         >
                           <template #suffix> m </template>
@@ -556,13 +557,41 @@ const removeProductionStatusRow = (index: number) => {
   reportDetails.value.splice(index, 1)
 }
 
-const inputCurrentDepth = useDebounceFn(function inputCurrentDepth(val: any, index: number) {
-  if (reportDetails.value && index === reportDetails.value.length - 1) {
+const acalculateDuration = (row: any) => {
+  calculateDuration(row)
+  inputCurrentDepth()
+}
+
+const inputCurrentDepth = useDebounceFn(function inputCurrentDepth() {
+  const details = reportDetails.value
+
+  if (Array.isArray(details) && details.length > 0) {
+    const latestDetail = details.reduce((prev, current) => {
+      const currentFullTime = dayjs(current.reportDate)
+        .hour(parseInt(current.endTime.split(':')[0]))
+        .minute(parseInt(current.endTime.split(':')[1]))
+        .valueOf()
+
+      const prevFullTime = dayjs(prev.reportDate)
+        .hour(parseInt(prev.endTime.split(':')[0]))
+        .minute(parseInt(prev.endTime.split(':')[1]))
+        .valueOf()
+
+      return currentFullTime >= prevFullTime ? current : prev
+    })
+
     const currentDepth = attrList.value.find((item) => item.description === 'currentDepth')
-    if (currentDepth) currentDepth.fillContent = val
+    if (currentDepth) currentDepth.fillContent = latestDetail.currentDepth
   }
 }, 300)
 
+// const inputCurrentDepth = useDebounceFn(function inputCurrentDepth(val: any, index: number) {
+//   if (reportDetails.value && index === reportDetails.value.length - 1) {
+//     const currentDepth = attrList.value.find((item) => item.description === 'currentDepth')
+//     if (currentDepth) currentDepth.fillContent = val
+//   }
+// }, 300)
+
 const showDepth = computed(() => {
   return attrList.value.some((item) => item.description === 'currentDepth')
 })
@@ -788,7 +817,7 @@ const getList = async () => {
       value: Number(key)
     }))
 
-    taskId.value = taskOptions.value[0].value
+    taskId.value = taskOptions.value[0]?.value
 
     IotOpeationFillApi.getReportDetails(deptId.split(',')[3]).then((res) => {
       reportDetails.value = (res ? (res as any[]) : []).map((item) => ({

+ 21 - 0
src/views/pms/iotprojecttask/IotProjectTaskForm.vue

@@ -478,6 +478,7 @@
         :headers="{ 'tenant-id': 1, 'device-id': 'undefined' }"
         name="files"
         class="w-50%"
+        @preview="handlePreview"
       >
         <el-button type="primary">上传工程设计</el-button>
         <template #tip>
@@ -493,6 +494,7 @@
         :headers="{ 'tenant-id': 1, 'device-id': 'undefined' }"
         name="files"
         class="w-50%"
+        @preview="handlePreview"
       >
         <el-button type="primary">上传地质设计</el-button>
         <template #tip>
@@ -508,6 +510,7 @@
         :headers="{ 'tenant-id': 1, 'device-id': 'undefined' }"
         name="files"
         class="w-50%"
+        @preview="handlePreview"
       >
         <el-button :disabled="tableData[0].status !== 'wg'" type="primary">上传完井报告</el-button>
         <template #tip>
@@ -817,6 +820,7 @@ import * as UserApi from '@/api/system/user'
 import { IotProjectTaskAttrsApi } from '@/api/pms/iotprojecttaskattrs'
 import { DICT_TYPE, getStrDictOptions, getDictLabel } from '@/utils/dict'
 import { IotOpeationFillApi } from '@/api/pms/iotopeationfill'
+import { Base64 } from 'js-base64'
 // 在导入部分添加Plus图标
 // import { Plus } from '@element-plus/icons-vue'
 
@@ -839,6 +843,23 @@ if (params.projectId) {
     : (query.projectId as string)
 }
 
+function handlePreview(file: any) {
+  if (!file.response) {
+    message.error('附件路径不存在')
+    return
+  }
+
+  try {
+    const filePath = file.response.data.filePath ?? file.response.data.files[0].filePath
+    const encodedPath = encodeURIComponent(Base64.encode(filePath))
+
+    window.open(`http://doc.deepoil.cc:8012/onlinePreview?url=${encodedPath}`)
+  } catch (error) {
+    console.error('预览附件失败:', error)
+    message.error('预览附件失败')
+  }
+}
+
 // 施工队伍 选择树 响应式变量
 const defaultExpandedKeys = ref<number[]>([]) // 默认展开的部门节点keys
 const treeSelectRef = ref() // 树选择组件的引用

+ 48 - 10
src/views/pms/iotrydailyreport/ry-form.vue

@@ -95,6 +95,8 @@ interface FormOriginal {
   remark: string
   createTime: number
   opinion: string
+
+  auditStatus: number
 }
 
 type Form = Partial<FormOriginal>
@@ -140,7 +142,8 @@ const FORM_KEYS: (keyof FormOriginal)[] = [
   'relocationTime',
   'winterBreakTime',
   'otherNptTime',
-  'otherNptReason'
+  'otherNptReason',
+  'auditStatus'
 ]
 
 const formRef = ref<FormInstance>()
@@ -169,10 +172,18 @@ const isApproval = computed(() => props.type === 'approval')
 const isEdit = computed(() => props.type === 'edit')
 const isMainFieldDisabled = computed(() => formType.value === 'readonly' || isApproval.value)
 
+const wellOptions = ref<{ label: string; value: number }[]>([])
+
 async function loadDetail(id: number) {
   loading.value = true
   try {
     const res = await IotRyDailyReportApi.getIotRyDailyReport(id)
+
+    wellOptions.value = Object.entries(res.wellNamePair).map(([value, label]) => ({
+      label: String(label),
+      value: Number(value)
+    }))
+
     form.value = initFormData()
     // 按需赋值
     FORM_KEYS.forEach((key) => {
@@ -428,9 +439,30 @@ const orange = computed(() => {
   return false
 })
 
-const inputCurrentDepth = useDebounceFn(function inputCurrentDepth(val: any, index: number) {
-  if (form.value.reportDetails && index === form.value.reportDetails.length - 1) {
-    form.value.currentDepth = val
+const acalculateDuration = (row: any) => {
+  calculateDuration(row)
+  inputCurrentDepth()
+}
+
+const inputCurrentDepth = useDebounceFn(function inputCurrentDepth() {
+  const details = form.value.reportDetails
+
+  if (Array.isArray(details) && details.length > 0) {
+    const latestDetail = details.reduce((prev, current) => {
+      const currentFullTime = dayjs(current.reportDate)
+        .hour(parseInt(current.endTime.split(':')[0]))
+        .minute(parseInt(current.endTime.split(':')[1]))
+        .valueOf()
+
+      const prevFullTime = dayjs(prev.reportDate)
+        .hour(parseInt(prev.endTime.split(':')[0]))
+        .minute(parseInt(prev.endTime.split(':')[1]))
+        .valueOf()
+
+      return currentFullTime >= prevFullTime ? current : prev
+    })
+
+    form.value.currentDepth = latestDetail.currentDepth
   }
 }, 300)
 </script>
@@ -517,9 +549,14 @@ const inputCurrentDepth = useDebounceFn(function inputCurrentDepth(val: any, ind
         <el-form-item label="项目" prop="contractName"
           ><el-input v-model="form.contractName" disabled
         /></el-form-item>
-        <el-form-item label="任务" prop="taskName"
-          ><el-input v-model="form.taskName" disabled
-        /></el-form-item>
+        <el-form-item label="任务井" prop="taskId">
+          <el-select
+            v-model="form.taskId"
+            placeholder="请选择任务井"
+            :options="wellOptions"
+            :disabled="form.auditStatus !== 20 && form.auditStatus !== 30"
+          />
+        </el-form-item>
         <el-form-item label="施工状态" prop="rigStatus">
           <el-select
             v-model="form.rigStatus"
@@ -700,6 +737,7 @@ const inputCurrentDepth = useDebounceFn(function inputCurrentDepth(val: any, ind
                       class="w-full!"
                       value-format="x"
                       :disabled="isMainFieldDisabled"
+                      @change="inputCurrentDepth()"
                     />
                   </el-form-item>
                 </template>
@@ -724,7 +762,7 @@ const inputCurrentDepth = useDebounceFn(function inputCurrentDepth(val: any, ind
                       format="HH:mm"
                       value-format="HH:mm"
                       class="w-full!"
-                      @change="calculateDuration(row)"
+                      @change="acalculateDuration(row)"
                       :disabled="isMainFieldDisabled"
                     />
                   </el-form-item>
@@ -749,7 +787,7 @@ const inputCurrentDepth = useDebounceFn(function inputCurrentDepth(val: any, ind
                       format="HH:mm"
                       value-format="HH:mm"
                       class="w-full!"
-                      @change="calculateDuration(row)"
+                      @change="acalculateDuration(row)"
                       :disabled="isMainFieldDisabled"
                     />
                   </el-form-item>
@@ -801,7 +839,7 @@ const inputCurrentDepth = useDebounceFn(function inputCurrentDepth(val: any, ind
                       align="left"
                       placeholder="请输入结束井深"
                       :disabled="isMainFieldDisabled"
-                      @input="(val) => inputCurrentDepth(val, $index)"
+                      @input="() => inputCurrentDepth()"
                     >
                       <template #suffix> m </template>
                     </el-input-number>

+ 20 - 4
src/views/pms/iotrydailyreport/ry-xj-form.vue

@@ -92,6 +92,8 @@ interface FormOriginal {
   remark: string
   createTime: number
   opinion: string
+
+  auditStatus: number
 }
 
 type Form = Partial<FormOriginal>
@@ -136,7 +138,8 @@ const FORM_KEYS: (keyof FormOriginal)[] = [
   'relocationTime',
   'winterBreakTime',
   'otherNptTime',
-  'otherNptReason'
+  'otherNptReason',
+  'auditStatus'
 ]
 
 const formRef = ref<FormInstance>()
@@ -165,10 +168,18 @@ const isApproval = computed(() => props.type === 'approval')
 const isEdit = computed(() => props.type === 'edit')
 const isMainFieldDisabled = computed(() => formType.value === 'readonly' || isApproval.value)
 
+const wellOptions = ref<{ label: string; value: number }[]>([])
+
 async function loadDetail(id: number) {
   loading.value = true
   try {
     const res = await IotRyDailyReportApi.getIotRyDailyReport(id)
+
+    wellOptions.value = Object.entries(res.wellNamePair).map(([value, label]) => ({
+      label: String(label),
+      value: Number(value)
+    }))
+
     form.value = initFormData()
     // 按需赋值
     FORM_KEYS.forEach((key) => {
@@ -503,9 +514,14 @@ const orange = computed(() => {
         <el-form-item label="项目" prop="contractName"
           ><el-input v-model="form.contractName" disabled
         /></el-form-item>
-        <el-form-item label="任务" prop="taskName"
-          ><el-input v-model="form.taskName" disabled
-        /></el-form-item>
+        <el-form-item label="任务井" prop="taskId">
+          <el-select
+            v-model="form.taskId"
+            placeholder="请选择任务井"
+            :options="wellOptions"
+            :disabled="form.auditStatus !== 20 && form.auditStatus !== 30"
+          />
+        </el-form-item>
         <el-form-item :label="t('project.status')" prop="repairStatus">
           <el-select
             v-model="form.repairStatus"

+ 25 - 1
src/views/report-statistics/work-order-completion.vue

@@ -6,6 +6,7 @@ import MiniBarChart from '@/components/WorkOrderCompletionBar/index.vue'
 import CountTo from '@/components/count-to1.vue'
 import { IotReportApi } from '@/api/pms/report'
 import { rangeShortcuts } from '@/utils/formatTime'
+import download from '@/utils/download'
 
 // 定义时间类型
 type TimeType = 'year' | 'month' | 'day'
@@ -256,6 +257,23 @@ const handleClear = () => {
 const handleChange = () => {
   activeTimeType.value = undefined
 }
+
+const message = useMessage()
+
+const exportLoading = ref(false)
+
+async function handleExport() {
+  try {
+    await message.exportConfirm()
+
+    exportLoading.value = true
+    const res = await IotReportApi.exportOrderPage(query.value)
+
+    download.excel(res, '工单完成情况.xlsx')
+  } finally {
+    exportLoading.value = false
+  }
+}
 </script>
 
 <template>
@@ -384,7 +402,13 @@ const handleChange = () => {
           <el-button size="default" @click="handleReset">重置</el-button>
           <!-- @click="handleExport"
             :loading="exportLoading" -->
-          <el-button size="default" plain type="success">
+          <el-button
+            @click="handleExport"
+            :loading="exportLoading"
+            size="default"
+            plain
+            type="success"
+          >
             <Icon icon="ep:download" class="mr-5px" /> 导出
           </el-button>
         </div>

+ 2 - 1
uno.config.ts

@@ -13,7 +13,8 @@ export default defineConfig({
       md: '768px',
       lg: '1024px',
       xl: '1280px',
-      '2xl': '1920px'
+      '2xl': '1920px',
+      '3xl': '2000px'
     }
   },
   // ...UnoCSS options