Преглед изворни кода

Merge remote-tracking branch 'origin/master'

zhangcl пре 4 дана
родитељ
комит
7c71b209d5

+ 1 - 0
package.json

@@ -69,6 +69,7 @@
     "min-dash": "^4.1.1",
     "mitt": "^3.0.1",
     "moment": "^2.30.1",
+    "motion-v": "^1.7.4",
     "nprogress": "^0.2.0",
     "pinia": "^2.1.7",
     "pinia-plugin-persistedstate": "^3.2.1",

+ 58 - 0
pnpm-lock.yaml

@@ -140,6 +140,9 @@ importers:
       moment:
         specifier: ^2.30.1
         version: 2.30.1
+      motion-v:
+        specifier: ^1.7.4
+        version: 1.7.4(@vueuse/core@10.11.1(vue@3.5.12(typescript@5.3.3)))(vue@3.5.12(typescript@5.3.3))
       nprogress:
         specifier: ^0.2.0
         version: 0.2.0
@@ -3390,6 +3393,20 @@ packages:
   fraction.js@4.3.7:
     resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
 
+  framer-motion@12.23.12:
+    resolution: {integrity: sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==}
+    peerDependencies:
+      '@emotion/is-prop-valid': '*'
+      react: ^18.0.0 || ^19.0.0
+      react-dom: ^18.0.0 || ^19.0.0
+    peerDependenciesMeta:
+      '@emotion/is-prop-valid':
+        optional: true
+      react:
+        optional: true
+      react-dom:
+        optional: true
+
   fs-extra@10.1.0:
     resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
     engines: {node: '>=12'}
@@ -3524,6 +3541,9 @@ packages:
     resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
     hasBin: true
 
+  hey-listen@1.0.8:
+    resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
+
   highlight.js@11.10.0:
     resolution: {integrity: sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==}
     engines: {node: '>=12.0.0'}
@@ -4055,6 +4075,18 @@ packages:
   moment@2.30.1:
     resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
 
+  motion-dom@12.23.12:
+    resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==}
+
+  motion-utils@12.23.6:
+    resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
+
+  motion-v@1.7.4:
+    resolution: {integrity: sha512-YNDUAsany04wfI7YtHxQK3kxzNvh+OdFUk9GpA3+hMt7j6P+5WrVAAgr8kmPPoVza9EsJiAVhqoN3YYFN0Twrw==}
+    peerDependencies:
+      '@vueuse/core': '>=10.0.0'
+      vue: '>=3.0.0'
+
   mpd-parser@0.22.1:
     resolution: {integrity: sha512-fwBebvpyPUU8bOzvhX0VQZgSohncbgYwUyJJoTSNpmy7ccD2ryiCvM7oRkn/xQH5cv73/xU7rJSNCLjdGFor0Q==}
     hasBin: true
@@ -8890,6 +8922,12 @@ snapshots:
 
   fraction.js@4.3.7: {}
 
+  framer-motion@12.23.12:
+    dependencies:
+      motion-dom: 12.23.12
+      motion-utils: 12.23.6
+      tslib: 2.8.1
+
   fs-extra@10.1.0:
     dependencies:
       graceful-fs: 4.2.11
@@ -9027,6 +9065,8 @@ snapshots:
 
   he@1.2.0: {}
 
+  hey-listen@1.0.8: {}
+
   highlight.js@11.10.0: {}
 
   htm@3.1.1: {}
@@ -9521,6 +9561,24 @@ snapshots:
 
   moment@2.30.1: {}
 
+  motion-dom@12.23.12:
+    dependencies:
+      motion-utils: 12.23.6
+
+  motion-utils@12.23.6: {}
+
+  motion-v@1.7.4(@vueuse/core@10.11.1(vue@3.5.12(typescript@5.3.3)))(vue@3.5.12(typescript@5.3.3)):
+    dependencies:
+      '@vueuse/core': 10.11.1(vue@3.5.12(typescript@5.3.3))
+      framer-motion: 12.23.12
+      hey-listen: 1.0.8
+      motion-dom: 12.23.12
+      vue: 3.5.12(typescript@5.3.3)
+    transitivePeerDependencies:
+      - '@emotion/is-prop-valid'
+      - react
+      - react-dom
+
   mpd-parser@0.22.1:
     dependencies:
       '@babel/runtime': 7.26.0

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

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

+ 11 - 0
src/api/pms/iotrhdailyreport/index.ts

@@ -47,6 +47,10 @@ export const IotRhDailyReportApi = {
     return await request.get({ url: `/pms/iot-rh-daily-report/statistics`, params })
   },
 
+  getIotRhDailyReportSummaryPolyline: async (params: any) => {
+    return await request.get({ url: `/pms/iot-rh-daily-report/polylineStatistics`, params })
+  },
+
   // 累计工作量统计
   totalWorkload: async (params: any) => {
     return await request.get({ url: `/pms/iot-rh-daily-report/totalWorkload`, params })
@@ -57,6 +61,13 @@ export const IotRhDailyReportApi = {
     return await request.get({ url: `/pms/iot-rh-daily-report/rhDailyReportStatistics`, params })
   },
 
+  exportRhDailyReportStatistics: async (params: any) => {
+    return await request.download({
+      url: `/pms/iot-rh-daily-report/exportStatistics`,
+      params
+    })
+  },
+
   // 按照日期查询瑞恒日报统计数据 未填报队伍明细
   rhUnReportDetails: async (params: any) => {
     return await request.get({ url: `/pms/iot-rh-daily-report/rhUnReportDetails`, params })

+ 15 - 0
src/api/pms/iotrydailyreport/index.ts

@@ -54,6 +54,21 @@ export interface IotRyDailyReportVO {
 
 // 瑞鹰日报 API
 export const IotRyDailyReportApi = {
+  exportRyDailyReportStatistics: async (params: any) => {
+    return await request.download({
+      url: `/pms/iot-ry-daily-report/exportStatistics`,
+      params
+    })
+  },
+
+  ryUnReportDetails: async (params: any) => {
+    return await request.get({ url: `/pms/iot-ry-daily-report/ryUnReportDetails`, params })
+  },
+
+  getIotRyDailyReportSummaryPolyline: async (params: any) => {
+    return await request.get({ url: `/pms/iot-ry-daily-report/polylineStatistics`, params })
+  },
+
   // 查询瑞鹰日报分页
   getIotRyDailyReportPage: async (params: any) => {
     return await request.get({ url: `/pms/iot-ry-daily-report/page`, params })

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

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

+ 20 - 6
src/utils/formatTime.ts

@@ -1,11 +1,15 @@
 import dayjs from 'dayjs'
+import quarter from 'dayjs/plugin/quarterOfYear'
+
+dayjs.extend(quarter)
+
 import type { TableColumnCtx } from 'element-plus'
 
 /**
  * 日期快捷选项适用于 el-date-picker
  */
 
-export  const rangeShortcuts = [
+export const rangeShortcuts = [
   {
     text: '今天',
     value: () => {
@@ -23,14 +27,16 @@ export  const rangeShortcuts = [
   {
     text: '本周',
     value: () => {
-      return [dayjs().startOf('week').toDate(), dayjs().endOf('week').toDate()]
+      return [dayjs().subtract(6, 'day').startOf('day').toDate(), dayjs().endOf('day').toDate()]
     }
   },
   {
     text: '上周',
     value: () => {
-      const lastWeek = dayjs().subtract(1, 'week')
-      return [lastWeek.startOf('week').toDate(), lastWeek.endOf('week').toDate()]
+      return [
+        dayjs().subtract(13, 'day').startOf('day').toDate(),
+        dayjs().subtract(7, 'day').endOf('day').toDate()
+      ]
     }
   },
   {
@@ -291,7 +297,11 @@ export function formatPast2(ms: number): string {
  * @param column 字段
  * @param cellValue 字段值
  */
-export function dateFormatter(_row: any, _column: TableColumnCtx<any> | null, cellValue: any): string {
+export function dateFormatter(
+  _row: any,
+  _column: TableColumnCtx<any> | null,
+  cellValue: any
+): string {
   return cellValue ? formatDate(cellValue) : ''
 }
 
@@ -302,7 +312,11 @@ export function dateFormatter(_row: any, _column: TableColumnCtx<any> | null, ce
  * @param column 字段
  * @param cellValue 字段值
  */
-export function dateFormatter2(_row: any, _column: TableColumnCtx<any> |null, cellValue: any): string {
+export function dateFormatter2(
+  _row: any,
+  _column: TableColumnCtx<any> | null,
+  cellValue: any
+): string {
   return cellValue ? formatDate(cellValue, 'YYYY-MM-DD') : ''
 }
 

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

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

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

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

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

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

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

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

+ 12 - 2
src/views/pms/iotrhdailyreport/index.vue

@@ -490,7 +490,7 @@ const selectedRowData = ref<Record<string, any> | null>(null)
 const loading = ref(true) // 列表的加载中
 const list = ref<IotRhDailyReportVO[]>([]) // 列表的数据
 const total = ref(0) // 列表的总页数
-const queryParams = reactive({
+let queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
   deptId: undefined,
@@ -799,6 +799,7 @@ const getStatistics = async () => {
 const getList = async () => {
   loading.value = true
   try {
+    console.log('22 :>> ', 11)
     const data = await IotRhDailyReportApi.getIotRhDailyReportPage(queryParams)
     list.value = data.list
     total.value = data.total
@@ -924,6 +925,8 @@ const handleQuery = () => {
   getList()
 }
 
+const route = useRoute()
+
 /** 重置按钮操作 */
 const resetQuery = () => {
   queryFormRef.value.resetFields()
@@ -995,7 +998,14 @@ let resizeObserver: ResizeObserver | null = null
 
 /** 初始化 **/
 onMounted(() => {
-  getList()
+  if (Object.keys(route.query).length > 0) {
+    queryParams = {
+      ...queryParams,
+      ...route.query,
+      deptId: Number(route.query.deptId) as any
+    }
+    handleQuery()
+  } else getList()
   // 创建 ResizeObserver 监听表格容器尺寸变化
   if (tableContainerRef.value?.$el) {
     resizeObserver = new ResizeObserver(() => {

+ 353 - 153
src/views/pms/iotrhdailyreport/summary.vue

@@ -1,105 +1,16 @@
 <script setup lang="ts">
 import DeptTree2 from '@/views/pms/iotrhdailyreport/DeptTree2.vue'
-import quarterOfYear from 'dayjs/plugin/quarterOfYear'
 import dayjs from 'dayjs'
 import { IotRhDailyReportApi } from '@/api/pms/iotrhdailyreport'
 import { useDebounceFn } from '@vueuse/core'
 import CountTo from '@/components/count-to1.vue'
+import * as echarts from 'echarts'
+import UnfilledReportDialog from './UnfilledReportDialog.vue'
 
-dayjs.extend(quarterOfYear)
+import { Motion, AnimatePresence } from 'motion-v'
 
-const rangeShortcuts = [
-  {
-    text: '今天',
-    value: () => {
-      const today = dayjs()
-      return [today.startOf('day').toDate(), today.endOf('day').toDate()]
-    }
-  },
-  {
-    text: '昨天',
-    value: () => {
-      const yesterday = dayjs().subtract(1, 'day')
-      return [yesterday.startOf('day').toDate(), yesterday.endOf('day').toDate()]
-    }
-  },
-  {
-    text: '本周',
-    value: () => {
-      return [dayjs().startOf('week').toDate(), dayjs().endOf('week').toDate()]
-    }
-  },
-  {
-    text: '上周',
-    value: () => {
-      const lastWeek = dayjs().subtract(1, 'week')
-      return [lastWeek.startOf('week').toDate(), lastWeek.endOf('week').toDate()]
-    }
-  },
-  {
-    text: '本月',
-    value: () => {
-      return [dayjs().startOf('month').toDate(), dayjs().endOf('month').toDate()]
-    }
-  },
-  {
-    text: '上月',
-    value: () => {
-      const lastMonth = dayjs().subtract(1, 'month')
-      return [lastMonth.startOf('month').toDate(), lastMonth.endOf('month').toDate()]
-    }
-  },
-  {
-    text: '本季度',
-    value: () => {
-      return [dayjs().startOf('quarter').toDate(), dayjs().endOf('quarter').toDate()]
-    }
-  },
-  {
-    text: '上季度',
-    value: () => {
-      const lastQuarter = dayjs().subtract(1, 'quarter')
-      return [lastQuarter.startOf('quarter').toDate(), lastQuarter.endOf('quarter').toDate()]
-    }
-  },
-  {
-    text: '今年',
-    value: () => {
-      return [dayjs().startOf('year').toDate(), dayjs().endOf('year').toDate()]
-    }
-  },
-  {
-    text: '去年',
-    value: () => {
-      const lastYear = dayjs().subtract(1, 'year')
-      return [lastYear.startOf('year').toDate(), lastYear.endOf('year').toDate()]
-    }
-  },
-  {
-    text: '最近7天',
-    value: () => {
-      return [dayjs().subtract(6, 'day').toDate(), dayjs().toDate()]
-    }
-  },
-  {
-    text: '最近30天',
-    value: () => {
-      return [dayjs().subtract(29, 'day').toDate(), dayjs().toDate()]
-    }
-  },
-  {
-    text: '最近90天',
-    value: () => {
-      return [dayjs().subtract(89, 'day').toDate(), dayjs().toDate()]
-    }
-  },
-  {
-    text: '最近一年',
-    value: () => {
-      return [dayjs().subtract(1, 'year').toDate(), dayjs().toDate()]
-    }
-  }
-]
+import { rangeShortcuts } from '@/utils/formatTime'
+import download from '@/utils/download'
 
 interface Query {
   pageNo: number
@@ -116,37 +27,43 @@ const query = ref<Query>({
   pageNo: 1,
   pageSize: 10,
   deptId: 157,
-  createTime: []
+  createTime: [
+    ...rangeShortcuts[2].value().map((item) => dayjs(item).format('YYYY-MM-DD HH:mm:ss'))
+  ]
 })
 
-const totalWorkKeys = [
-  ['totalCount', '个', '总数', 'i-tabler:report-analytics text-sky'],
+const totalWorkKeys: [string, string, string, string, number][] = [
+  ['totalCount', '个', '总数', 'i-tabler:report-analytics text-sky', 0],
   [
     'alreadyReported',
     '个',
     '已填报',
-    'i-material-symbols:check-circle-outline-rounded text-emerald'
+    'i-material-symbols:check-circle-outline-rounded text-emerald',
+    0
   ],
-  ['notReported', '个', '未填报', 'i-material-symbols:cancel-outline-rounded text-rose'],
+  ['notReported', '个', '未填报', 'i-material-symbols:cancel-outline-rounded text-rose', 0],
   [
     'totalFuelConsumption',
     'L',
     '累计油耗',
-    'i-material-symbols:directions-car-outline-rounded text-sky'
+    'i-material-symbols:directions-car-outline-rounded text-sky',
+    2
   ],
   [
     'totalPowerConsumption',
     'KWH',
     '累计用电量',
-    'i-material-symbols:electric-bolt-outline-rounded text-sky'
+    'i-material-symbols:electric-bolt-outline-rounded text-sky',
+    2
   ],
   [
     'totalWaterInjection',
     '方',
     '累计注水量',
-    'i-material-symbols:water-drop-outline-rounded text-sky'
+    'i-material-symbols:water-drop-outline-rounded text-sky',
+    2
   ],
-  ['totalGasInjection', '万方', '累计注气量', 'i-material-symbols:cloud-outline text-sky']
+  ['totalGasInjection', '万方', '累计注气量', 'i-material-symbols:cloud-outline text-sky', 4]
 ]
 
 const totalWork = ref({
@@ -170,7 +87,8 @@ const getTotal = useDebounceFn(async () => {
     let res1: any[]
     if (query.value.createTime.length !== 0) {
       res1 = await IotRhDailyReportApi.rhDailyReportStatistics({
-        createTime: query.value.createTime
+        createTime: query.value.createTime,
+        deptId: query.value.deptId
       })
 
       totalWork.value.totalCount = res1[0].count
@@ -201,8 +119,6 @@ interface List {
   transitTime: number | null
 }
 
-const total = ref<number>(1000)
-
 const list = ref<List[]>([])
 
 const type = ref('2')
@@ -241,31 +157,180 @@ const formatter = (row: List, column: any) => {
 const getList = useDebounceFn(async () => {
   listLoading.value = true
   try {
-    const res = (await IotRhDailyReportApi.getIotRhDailyReportSummary(query.value)) as {
-      total: number
-      list: any[]
-    }
+    const res = await IotRhDailyReportApi.getIotRhDailyReportSummary(query.value)
 
-    const { total: resTotal, list: resList } = res
+    const { list: reslist } = res
 
-    total.value = resTotal
+    type.value = reslist[0]?.type || '2'
 
-    type.value = resList[0]?.type || '2'
-
-    list.value = resList.map(
-      ({ id, projectDeptIa, projectDeptName, teamId, teamName, sort, taskId, type, ...other }) => ({
-        id: type === '2' ? projectDeptIa : teamId,
-        name: type === '2' ? projectDeptName : teamName,
-        ...other,
-        cumulativeGasInjection: (other.cumulativeGasInjection || 0) / 10000
-      })
+    list.value = reslist.map(
+      ({ id, projectDeptId, projectDeptName, teamId, teamName, sort, taskId, type, ...other }) => {
+        return {
+          id: type === '2' ? projectDeptId : teamId,
+          name: type === '2' ? projectDeptName : teamName,
+          ...other,
+          cumulativeGasInjection: (other.cumulativeGasInjection || 0) / 10000
+        }
+      }
     )
   } finally {
     listLoading.value = false
   }
 }, 1000)
 
+const tab = ref<'表格' | '看板'>('表格')
+
+const currentTab = ref<'表格' | '看板'>('表格')
+
+const deptName = ref('瑞恒兴域')
+
+const direction = ref<'left' | 'right'>('right')
+
+const handleSelectTab = (val: '表格' | '看板') => {
+  tab.value = val
+  direction.value = val === '看板' ? 'right' : 'left'
+  nextTick(() => {
+    currentTab.value = val
+    setTimeout(() => {
+      render()
+    })
+  })
+}
+
+const chartRef = ref<HTMLDivElement | null>(null)
+let chart: echarts.ECharts | null = null
+
+const xAxisData = ref<string[]>([])
+
+const legend = ref<string[][]>([
+  ['累计油耗 (吨)', 'cumulativeFuelConsumption'],
+  ['累计注气量 (万方)', 'cumulativeGasInjection'],
+  ['累计用电量 (KWH)', 'cumulativePowerConsumption'],
+  ['累计注水量 (方)', 'cumulativeWaterInjection'],
+  ['平均时效 (%)', 'transitTime']
+])
+
+const chartData = ref<Record<string, number[]>>({
+  cumulativeFuelConsumption: [],
+  cumulativeGasInjection: [],
+  cumulativePowerConsumption: [],
+  cumulativeWaterInjection: [],
+  transitTime: []
+})
+
+let chartLoading = ref(false)
+
+const getChart = useDebounceFn(async () => {
+  chartLoading.value = true
+
+  try {
+    const res = await IotRhDailyReportApi.getIotRhDailyReportSummaryPolyline(query.value)
+
+    chartData.value = {
+      cumulativeFuelConsumption: res.map((item) => item.cumulativeFuelConsumption || 0),
+      cumulativeGasInjection: res.map((item) => (item.cumulativeGasInjection || 0) / 10000),
+      cumulativePowerConsumption: res.map((item) => item.cumulativePowerConsumption || 0),
+      cumulativeWaterInjection: res.map((item) => item.cumulativeWaterInjection || 0),
+      transitTime: res.map((item) => (item.transitTime || 0) * 100)
+    }
+
+    xAxisData.value = res.map((item) => item.reportDate || '')
+  } finally {
+    chartLoading.value = false
+  }
+}, 1000)
+
+const render = () => {
+  if (!chartRef.value) return
+
+  chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
+
+  const values: number[] = []
+
+  for (const [_name, key] of legend.value) {
+    values.push(...(chartData.value[key] || []))
+  }
+
+  const maxVal = values.length === 0 ? 10000 : Math.max(...values)
+  const minVal = values.length === 0 ? 0 : Math.min(...values)
+
+  const maxDigits = (Math.floor(maxVal) + '').length
+  const minDigits = (Math.floor(Math.abs(minVal)) + '').length
+  const interval = Math.max(maxDigits, minDigits)
+
+  const maxInterval = interval
+  const minInterval = minDigits
+
+  const intervalArr = [0]
+  for (let i = 1; i <= interval; i++) {
+    intervalArr.push(Math.pow(10, i))
+  }
+
+  chart.setOption({
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'line'
+      },
+      formatter: (params) => {
+        let d = `${params[0].axisValueLabel}<br>`
+        let item = params.map((el) => {
+          return `<div class="flex items-center justify-between mt-1 gap-1">
+            <span>${el.marker} ${el.seriesName}</span>
+            <span>${chartData.value[legend.value[el.componentIndex][1]][el.dataIndex].toFixed(2)} ${el.seriesName.split(' ')[1]}</span>
+          </div>`
+        })
+
+        return d + item.join('')
+      }
+    },
+    legend: {
+      data: legend.value.map(([name]) => name),
+      show: true
+    },
+    xAxis: {
+      type: 'category',
+      data: xAxisData.value
+    },
+    yAxis: {
+      type: 'value',
+      min: minInterval,
+      max: maxInterval,
+      interval: 1,
+      axisLabel: {
+        formatter: (v) => {
+          const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
+
+          return num.toLocaleString()
+        }
+      }
+    },
+    series: legend.value.map(([name, key]) => ({
+      name,
+      type: 'line',
+      smooth: true,
+      showSymbol: true,
+      data: chartData.value[key].map((value) => {
+        // return value
+        if (value === 0) return 0
+
+        const isPositive = value > 0
+        const absItem = Math.abs(value)
+
+        const min_value = Math.max(...intervalArr.filter((v) => v <= absItem))
+        const min_index = intervalArr.findIndex((v) => v === min_value)
+
+        const new_value =
+          (absItem - min_value) / (intervalArr[min_index + 1] - intervalArr[min_index]) + min_index
+
+        return isPositive ? new_value : -new_value
+      })
+    }))
+  })
+}
+
 const handleDeptNodeClick = (node: any) => {
+  deptName.value = node.name
   query.value.deptId = node.id
   handleQuery()
 }
@@ -274,8 +339,11 @@ const handleQuery = (setPage = true) => {
   if (setPage) {
     query.value.pageNo = 1
   }
-  getTotal()
+  getChart().then(() => {
+    render()
+  })
   getList()
+  getTotal()
 }
 
 const resetQuery = () => {
@@ -298,6 +366,73 @@ watch(
 onMounted(() => {
   handleQuery()
 })
+
+const exportChart = () => {
+  if (!chart) return
+  let img = new Image()
+  img.src = chart.getDataURL({
+    type: 'png',
+    pixelRatio: 1,
+    backgroundColor: '#fff'
+  })
+
+  img.onload = function () {
+    let canvas = document.createElement('canvas')
+    canvas.width = img.width
+    canvas.height = img.height
+    let ctx = canvas.getContext('2d')
+    ctx?.drawImage(img, 0, 0)
+    let dataURL = canvas.toDataURL('image/png')
+
+    let a = document.createElement('a')
+
+    let event = new MouseEvent('click')
+
+    a.href = dataURL
+    a.download = `瑞恒日报统计数据.png`
+    a.dispatchEvent(event)
+  }
+}
+
+const exportData = async () => {
+  const res = await IotRhDailyReportApi.exportRhDailyReportStatistics(query.value)
+
+  download.excel(res, '瑞恒日报统计数据.xlsx')
+}
+
+const exportAll = async () => {
+  if (tab.value === '看板') exportChart()
+  else exportData()
+}
+
+const message = useMessage()
+
+const unfilledDialogRef = ref()
+
+const openUnfilledDialog = () => {
+  // 检查是否选择了创建时间
+  if (!query.value.createTime || query.value.createTime.length === 0) {
+    message.warning('请先选择创建时间范围')
+    return
+  }
+
+  // 打开弹窗
+  unfilledDialogRef.value?.open()
+}
+
+const router = useRouter()
+
+const tolist = (id: number) => {
+  const { pageNo, pageSize, ...rest } = query.value
+
+  router.push({
+    path: '/iotdayilyreport/IotRhDailyReport',
+    query: {
+      ...rest,
+      deptId: id
+    }
+  })
+}
 </script>
 
 <template>
@@ -305,7 +440,7 @@ onMounted(() => {
     <div class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow p-4">
       <DeptTree2 :deptId="id" @node-click="handleDeptNodeClick" />
     </div>
-    <div class="grid grid-rows-[62px_164px_1fr] h-full gap-5">
+    <div class="grid grid-rows-[62px_164px_1fr] h-full gap-4">
       <el-form
         size="default"
         class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-8 gap-8 flex items-center justify-between"
@@ -352,46 +487,111 @@ onMounted(() => {
         <div
           v-for="info in totalWorkKeys"
           :key="info[0]"
-          class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow p-4 flex flex-col items-center justify-center gap-2"
+          class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow p-1 flex flex-col items-center justify-center gap-1"
         >
           <div class="size-7.5" :class="info[3]"></div>
-          <count-to class="text-2xl font-medium" :start-val="0" :end-val="totalWork[info[0]]">
+          <count-to
+            class="text-2xl font-medium"
+            :start-val="0"
+            :end-val="totalWork[info[0]]"
+            :decimals="info[4]"
+            @click="info[2] === '未填报' ? openUnfilledDialog() : ''"
+          >
             <span class="text-xs leading-8 text-[var(--el-text-color-regular)]">暂无数据</span>
           </count-to>
           <div class="text-xs font-medium text-[var(--el-text-color-regular)]">{{ info[1] }}</div>
           <div class="text-sm font-medium text-[var(--el-text-color-regular)]">{{ info[2] }}</div>
         </div>
       </div>
-      <div class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow pt-4 px-8">
-        <el-table
-          v-loading="listLoading"
-          :data="list"
-          :stripe="true"
-          :style="{ width: '100%' }"
-          max-height="600"
-          class="min-h-143"
-          show-overflow-tooltip
-        >
-          <el-table-column
-            v-for="item in columns(type)"
-            :key="item.prop"
-            :label="item.label"
-            :prop="item.prop"
-            align="center"
-            :formatter="formatter"
-          />
-        </el-table>
-
-        <Pagination
-          class="mt-8"
-          :total="total"
-          v-model:page="query.pageNo"
-          v-model:limit="query.pageSize"
-          @pagination="getList"
-        />
+      <div
+        class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-8 py-4 grid grid-rows-[48px_1fr] gap-2"
+      >
+        <div class="flex items-center justify-between">
+          <el-button-group>
+            <el-button
+              size="default"
+              :type="tab === '表格' ? 'primary' : 'default'"
+              @click="handleSelectTab('表格')"
+              >表格
+            </el-button>
+            <el-button
+              size="default"
+              :type="tab === '看板' ? 'primary' : 'default'"
+              @click="handleSelectTab('看板')"
+              >看板
+            </el-button>
+          </el-button-group>
+          <h3 class="text-xl font-medium">{{ `${deptName}-${tab}` }}</h3>
+          <el-button size="default" type="primary" @click="exportAll">导出</el-button>
+        </div>
+        <el-auto-resizer>
+          <template #default="{ height, width }">
+            <Motion
+              as="div"
+              :style="{ position: 'relative', overflow: 'hidden' }"
+              :animate="{ height: `${height}px`, width: `${width}px` }"
+              :transition="{ type: 'spring', stiffness: 200, damping: 25, duration: 0.3 }"
+            >
+              <AnimatePresence :initial="false" mode="sync">
+                <Motion
+                  :key="currentTab"
+                  as="div"
+                  :initial="{ x: direction === 'left' ? '-100%' : '100%', opacity: 0 }"
+                  :animate="{ x: '0%', opacity: 1 }"
+                  :exit="{ x: direction === 'left' ? '50%' : '-50%', opacity: 0 }"
+                  :transition="{ type: 'tween', stiffness: 300, damping: 30, duration: 0.3 }"
+                  :style="{ position: 'absolute', left: 0, right: 0, top: 0 }"
+                >
+                  <div :style="{ width: `${width}px`, height: `${height}px` }">
+                    <el-table
+                      v-if="currentTab === '表格'"
+                      v-loading="listLoading"
+                      :data="list"
+                      :stripe="true"
+                      :width="width"
+                      :max-height="height"
+                      show-overflow-tooltip
+                    >
+                      <template v-for="item in columns(type)" :key="item.prop">
+                        <el-table-column
+                          v-if="item.prop !== 'name'"
+                          :label="item.label"
+                          :prop="item.prop"
+                          align="center"
+                          :formatter="formatter"
+                        />
+                        <el-table-column
+                          v-else
+                          :label="item.label"
+                          :prop="item.prop"
+                          align="center"
+                        >
+                          <template #default="{ row }">
+                            <el-button text type="primary" @click.prevent="tolist(row.id)">{{
+                              row.name
+                            }}</el-button>
+                          </template>
+                        </el-table-column>
+                      </template>
+                    </el-table>
+                    <div
+                      ref="chartRef"
+                      v-loading="chartLoading"
+                      :key="dayjs().valueOf()"
+                      v-else
+                      :style="{ width: `${width}px`, height: `${height}px` }"
+                    >
+                    </div>
+                  </div>
+                </Motion>
+              </AnimatePresence>
+            </Motion>
+          </template>
+        </el-auto-resizer>
       </div>
     </div>
   </div>
+  <UnfilledReportDialog ref="unfilledDialogRef" :query-params="query" />
 </template>
 
 <style scoped>
@@ -404,7 +604,7 @@ onMounted(() => {
   border-top-left-radius: 8px;
 
   .el-table__cell {
-    height: 52px;
+    height: 40px;
   }
 
   .el-table__header-wrapper {

+ 259 - 0
src/views/pms/iotrydailyreport/UnfilledReportDialog.vue

@@ -0,0 +1,259 @@
+<template>
+  <el-dialog
+    v-model="dialogVisible"
+    title="未填报详情"
+    width="80%"
+    top="5vh"
+    :close-on-click-modal="false"
+    @closed="handleClosed"
+  >
+    <!-- 搜索条件区域 -->
+    <ContentWrap class="mb-15px">
+      <el-form :model="searchParams" ref="searchFormRef" :inline="true" label-width="100px">
+        <el-form-item label="创建时间" prop="createTime">
+          <el-date-picker
+            v-model="searchParams.createTime"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            type="daterange"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+            class="!w-320px"
+            @change="handleSearch"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="handleSearch">
+            <Icon icon="ep:search" class="mr-5px" /> 搜索
+          </el-button>
+          <el-button @click="resetSearch">
+            <Icon icon="ep:refresh" class="mr-5px" /> 重置
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </ContentWrap>
+
+    <!-- 列表区域 -->
+    <ContentWrap>
+      <div class="table-container">
+        <el-table
+          v-loading="loading"
+          :data="list"
+          :stripe="true"
+          style="width: 100%"
+          :cell-style="cellStyle"
+          empty-text="暂无未填报数据"
+          table-layout="fixed"
+        >
+          <el-table-column label="日期" align="center" prop="reportDate" width="120">
+            <template #default="scope">
+              <span class="date-content">{{ scope.row.reportDate }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="部门名称" prop="deptNames" min-width="300">
+            <template #default="scope">
+              <div class="dept-names-content">
+                {{ scope.row.deptNames || '-' }}
+              </div>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+
+      <!-- 分页
+      <Pagination
+        :total="total"
+        v-model:page="searchParams.pageNo"
+        v-model:limit="searchParams.pageSize"
+        @pagination="getList"
+      /> -->
+    </ContentWrap>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, watch, nextTick } from 'vue'
+import { IotRyDailyReportApi } from '@/api/pms/iotrydailyreport'
+
+const { t } = useI18n()
+const message = useMessage()
+
+// 弹窗显示控制
+const dialogVisible = ref(false)
+
+// 搜索参数
+const searchParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  createTime: []
+})
+
+// 列表数据
+const list = ref<any[]>([])
+const total = ref(0)
+const loading = ref(false)
+
+// 接收父组件传递的查询参数
+const props = defineProps<{
+  queryParams: any
+}>()
+
+// 搜索表单引用
+const searchFormRef = ref()
+
+// 打开弹窗
+const open = () => {
+  // 复制父组件的查询参数
+  if (props.queryParams.createTime && props.queryParams.createTime.length > 0) {
+    searchParams.createTime = [...props.queryParams.createTime]
+  }
+
+  dialogVisible.value = true
+  // 获取数据
+  nextTick(() => {
+    getList()
+  })
+}
+
+// 获取列表数据
+const getList = async () => {
+  // 检查时间范围
+  if (!searchParams.createTime || searchParams.createTime.length === 0) {
+    list.value = []
+    total.value = 0
+    return
+  }
+
+  loading.value = true
+  try {
+    const res = await IotRyDailyReportApi.ryUnReportDetails({
+      createTime: searchParams.createTime,
+      projectClassification: props.queryParams.projectClassification
+    })
+
+    // 处理返回数据
+    if (res && Array.isArray(res)) {
+      list.value = res
+      total.value = res.length
+    } else {
+      list.value = []
+      total.value = 0
+    }
+  } catch (error) {
+    console.error('获取未填报数据失败', error)
+    message.error('获取未填报数据失败')
+    list.value = []
+    total.value = 0
+  } finally {
+    loading.value = false
+  }
+}
+
+// 搜索
+const handleSearch = () => {
+  searchParams.pageNo = 1
+  getList()
+}
+
+// 重置搜索
+const resetSearch = () => {
+  searchFormRef.value?.resetFields()
+  handleSearch()
+}
+
+// 单元格样式
+const cellStyle = ({ row, column, rowIndex, columnIndex }: any) => {
+  // 为所有列设置基本样式
+  const baseStyle = {
+    padding: '8px 4px'
+  }
+
+  if (column.property === 'deptNames') {
+    return {
+      ...baseStyle,
+      whiteSpace: 'normal',
+      wordBreak: 'break-all',
+      lineHeight: '1.5'
+    }
+  }
+
+  return baseStyle
+}
+
+// 弹窗关闭处理
+const handleClosed = () => {
+  list.value = []
+  total.value = 0
+  searchParams.pageNo = 1
+}
+
+// 暴露方法给父组件
+defineExpose({
+  open
+})
+
+// 监听父组件查询参数变化
+watch(
+  () => props.queryParams.createTime,
+  (newVal) => {
+    if (newVal && newVal.length > 0) {
+      searchParams.createTime = [...newVal]
+    }
+  },
+  { deep: true }
+)
+</script>
+
+<style scoped>
+/* 表格容器确保正确布局 */
+.table-container {
+  width: 100%;
+  overflow-x: auto;
+}
+
+.date-content {
+  white-space: nowrap;
+}
+
+.dept-names-content {
+  white-space: normal;
+  word-break: break-all;
+  line-height: 1.5;
+  padding: 8px 4px;
+}
+
+/* 深度样式修改确保表格正确显示 */
+:deep(.el-table) {
+  table-layout: fixed;
+}
+
+:deep(.el-table .el-table__cell) {
+  box-sizing: border-box;
+}
+
+:deep(.el-table .cell) {
+  white-space: normal;
+  word-break: break-all;
+  line-height: 1.5;
+  padding: 8px 4px;
+}
+
+:deep(.el-table td.el-table__cell) {
+  padding: 8px 4px;
+  border-bottom: 1px solid var(--el-table-border-color);
+}
+
+:deep(.el-table th.el-table__cell) {
+  padding: 8px 4px;
+  background-color: var(--el-table-header-bg-color);
+}
+
+/* 确保列宽正确分配 */
+:deep(.el-table__body colgroup col:nth-child(1)) {
+  width: 120px;
+}
+
+:deep(.el-table__body colgroup col:nth-child(2)) {
+  width: auto;
+}
+</style>

+ 18 - 10
src/views/pms/iotrydailyreport/index.vue

@@ -251,9 +251,9 @@
                 resizable
               >
                 <template #default="scope">
-                <span :class="{'fuel-warning': shouldShowFuelWarning(scope.row)}">
-                  {{ scope.row.dailyFuel }}
-                </span>
+                  <span :class="{ 'fuel-warning': shouldShowFuelWarning(scope.row) }">
+                    {{ scope.row.dailyFuel }}
+                  </span>
                 </template>
               </el-table-column>
             </el-table-column>
@@ -539,7 +539,7 @@ const rootDeptId = ref(158)
 const loading = ref(true) // 列表的加载中
 const list = ref<IotRyDailyReportVO[]>([]) // 列表的数据
 const total = ref(0) // 列表的总页数
-const queryParams = reactive({
+let queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
   deptId: undefined,
@@ -856,7 +856,6 @@ const cellStyle = ({
   rowIndex: number
   columnIndex: number
 }) => {
-
   // 处理当日油耗预警
   if (column.property === 'dailyFuel') {
     if (shouldShowFuelWarning(row)) {
@@ -864,7 +863,7 @@ const cellStyle = ({
         color: 'red',
         fontWeight: 'bold',
         backgroundColor: '#fff5f5' // 可选:添加背景色突出显示
-      };
+      }
     }
   }
 
@@ -914,9 +913,9 @@ const getList = async () => {
 
 // 在 cellStyle 函数附近添加油耗预警判断函数
 const shouldShowFuelWarning = (row: any): boolean => {
-  const dailyFuel = parseFloat(row.dailyFuel);
-  return !isNaN(dailyFuel) && dailyFuel > 15;
-};
+  const dailyFuel = parseFloat(row.dailyFuel)
+  return !isNaN(dailyFuel) && dailyFuel > 15
+}
 
 // 计算列宽度
 
@@ -994,9 +993,18 @@ const handleExport = async () => {
 // 声明 ResizeObserver 实例
 let resizeObserver: ResizeObserver | null = null
 
+const route = useRoute()
+
 /** 初始化 **/
 onMounted(() => {
-  getList()
+  if (Object.keys(route.query).length > 0) {
+    queryParams = {
+      ...queryParams,
+      ...route.query,
+      deptId: Number(route.query.deptId) as any
+    }
+    handleQuery()
+  } else getList()
   // 创建 ResizeObserver 监听表格容器尺寸变化
   if (tableContainerRef.value?.$el) {
     resizeObserver = new ResizeObserver(() => {

+ 336 - 144
src/views/pms/iotrydailyreport/summary.vue

@@ -1,105 +1,16 @@
 <script setup lang="ts">
 import DeptTree2 from '@/views/pms/iotrhdailyreport/DeptTree2.vue'
-import quarterOfYear from 'dayjs/plugin/quarterOfYear'
 import dayjs from 'dayjs'
 import { IotRyDailyReportApi } from '@/api/pms/iotrydailyreport'
 import { useDebounceFn } from '@vueuse/core'
 import CountTo from '@/components/count-to1.vue'
+import * as echarts from 'echarts'
+import UnfilledReportDialog from './UnfilledReportDialog.vue'
 
-dayjs.extend(quarterOfYear)
+import { Motion, AnimatePresence } from 'motion-v'
 
-const rangeShortcuts = [
-  {
-    text: '今天',
-    value: () => {
-      const today = dayjs()
-      return [today.startOf('day').toDate(), today.endOf('day').toDate()]
-    }
-  },
-  {
-    text: '昨天',
-    value: () => {
-      const yesterday = dayjs().subtract(1, 'day')
-      return [yesterday.startOf('day').toDate(), yesterday.endOf('day').toDate()]
-    }
-  },
-  {
-    text: '本周',
-    value: () => {
-      return [dayjs().startOf('week').toDate(), dayjs().endOf('week').toDate()]
-    }
-  },
-  {
-    text: '上周',
-    value: () => {
-      const lastWeek = dayjs().subtract(1, 'week')
-      return [lastWeek.startOf('week').toDate(), lastWeek.endOf('week').toDate()]
-    }
-  },
-  {
-    text: '本月',
-    value: () => {
-      return [dayjs().startOf('month').toDate(), dayjs().endOf('month').toDate()]
-    }
-  },
-  {
-    text: '上月',
-    value: () => {
-      const lastMonth = dayjs().subtract(1, 'month')
-      return [lastMonth.startOf('month').toDate(), lastMonth.endOf('month').toDate()]
-    }
-  },
-  {
-    text: '本季度',
-    value: () => {
-      return [dayjs().startOf('quarter').toDate(), dayjs().endOf('quarter').toDate()]
-    }
-  },
-  {
-    text: '上季度',
-    value: () => {
-      const lastQuarter = dayjs().subtract(1, 'quarter')
-      return [lastQuarter.startOf('quarter').toDate(), lastQuarter.endOf('quarter').toDate()]
-    }
-  },
-  {
-    text: '今年',
-    value: () => {
-      return [dayjs().startOf('year').toDate(), dayjs().endOf('year').toDate()]
-    }
-  },
-  {
-    text: '去年',
-    value: () => {
-      const lastYear = dayjs().subtract(1, 'year')
-      return [lastYear.startOf('year').toDate(), lastYear.endOf('year').toDate()]
-    }
-  },
-  {
-    text: '最近7天',
-    value: () => {
-      return [dayjs().subtract(6, 'day').toDate(), dayjs().toDate()]
-    }
-  },
-  {
-    text: '最近30天',
-    value: () => {
-      return [dayjs().subtract(29, 'day').toDate(), dayjs().toDate()]
-    }
-  },
-  {
-    text: '最近90天',
-    value: () => {
-      return [dayjs().subtract(89, 'day').toDate(), dayjs().toDate()]
-    }
-  },
-  {
-    text: '最近一年',
-    value: () => {
-      return [dayjs().subtract(1, 'year').toDate(), dayjs().toDate()]
-    }
-  }
-]
+import { rangeShortcuts } from '@/utils/formatTime'
+import download from '@/utils/download'
 
 interface Query {
   pageNo: number
@@ -117,32 +28,37 @@ const query = ref<Query>({
   pageNo: 1,
   pageSize: 10,
   deptId: 158,
-  createTime: [],
+  createTime: [
+    ...rangeShortcuts[2].value().map((item) => dayjs(item).format('YYYY-MM-DD HH:mm:ss'))
+  ],
   projectClassification: 1
 })
 
-const totalWorkKeys = [
-  ['totalCount', '个', '总数', 'i-tabler:report-analytics text-sky'],
+const totalWorkKeys: [string, string, string, string, number][] = [
+  ['totalCount', '个', '总数', 'i-tabler:report-analytics text-sky', 0],
   [
     'alreadyReported',
     '个',
     '已填报',
-    'i-material-symbols:check-circle-outline-rounded text-emerald'
+    'i-material-symbols:check-circle-outline-rounded text-emerald',
+    0
   ],
-  ['notReported', '个', '未填报', 'i-material-symbols:cancel-outline-rounded text-rose'],
+  ['notReported', '个', '未填报', 'i-material-symbols:cancel-outline-rounded text-rose', 0],
   [
     'totalFuelConsumption',
     '吨',
     '累计油耗',
-    'i-material-symbols:directions-car-outline-rounded text-sky'
+    'i-material-symbols:directions-car-outline-rounded text-sky',
+    2
   ],
   [
     'totalPowerConsumption',
     'KWH',
     '累计用电量',
-    'i-material-symbols:electric-bolt-outline-rounded text-sky'
+    'i-material-symbols:electric-bolt-outline-rounded text-sky',
+    2
   ],
-  ['totalFootage', 'M', '累计进尺', 'i-solar:ruler-bold text-sky']
+  ['totalFootage', 'M', '累计进尺', 'i-solar:ruler-bold text-sky', 2]
 ]
 
 const totalWork = ref({
@@ -197,8 +113,6 @@ interface List {
   transitTime: number | null
 }
 
-const total = ref<number>(1000)
-
 const list = ref<List[]>([])
 
 const type = ref('2')
@@ -233,20 +147,15 @@ const formatter = (row: List, column: any) => {
 const getList = useDebounceFn(async () => {
   listLoading.value = true
   try {
-    const res = (await IotRyDailyReportApi.getIotRyDailyReportSummary(query.value)) as {
-      total: number
-      list: any[]
-    }
-
-    const { total: resTotal, list: resList } = res
+    const res = await IotRyDailyReportApi.getIotRyDailyReportSummary(query.value)
 
-    total.value = resTotal
+    const { list: reslist } = res
 
-    type.value = resList[0]?.type || '2'
+    type.value = reslist[0]?.type || '2'
 
-    list.value = resList.map(
-      ({ id, projectDeptIa, projectDeptName, teamId, teamName, sort, taskId, type, ...other }) => ({
-        id: type === '2' ? projectDeptIa : teamId,
+    list.value = reslist.map(
+      ({ id, projectDeptId, projectDeptName, teamId, teamName, sort, taskId, type, ...other }) => ({
+        id: type === '2' ? projectDeptId : teamId,
         name: type === '2' ? projectDeptName : teamName,
         ...other
       })
@@ -256,6 +165,154 @@ const getList = useDebounceFn(async () => {
   }
 }, 1000)
 
+const tab = ref<'表格' | '看板'>('表格')
+
+const currentTab = ref<'表格' | '看板'>('表格')
+
+const deptName = ref('瑞恒兴域')
+
+const direction = ref<'left' | 'right'>('right')
+
+const handleSelectTab = (val: '表格' | '看板') => {
+  tab.value = val
+  direction.value = val === '看板' ? 'right' : 'left'
+  nextTick(() => {
+    currentTab.value = val
+    setTimeout(() => {
+      render()
+    })
+  })
+}
+
+const chartRef = ref<HTMLDivElement | null>(null)
+let chart: echarts.ECharts | null = null
+
+const xAxisData = ref<string[]>([])
+
+const legend = ref<string[][]>([
+  ['累计油耗 (吨)', 'cumulativeFuelConsumption'],
+  ['累计进尺 (M)', 'cumulativeFootage'],
+  ['累计用电量 (KWH)', 'cumulativePowerConsumption'],
+  ['平均时效 (%)', 'transitTime']
+])
+
+const chartData = ref<Record<string, number[]>>({
+  cumulativeFuelConsumption: [],
+  cumulativeFootage: [],
+  cumulativePowerConsumption: [],
+  transitTime: []
+})
+
+let chartLoading = ref(false)
+
+const getChart = useDebounceFn(async () => {
+  chartLoading.value = true
+
+  try {
+    const res = await IotRyDailyReportApi.getIotRyDailyReportSummaryPolyline(query.value)
+
+    chartData.value = {
+      cumulativeFuelConsumption: res.map((item) => item.cumulativeFuelConsumption || 0),
+      cumulativeFootage: res.map((item) => item.cumulativeFootage || 0),
+      cumulativePowerConsumption: res.map((item) => item.cumulativePowerConsumption || 0),
+      transitTime: res.map((item) => (item.transitTime || 0) * 100)
+    }
+
+    xAxisData.value = res.map((item) => item.reportDate || '')
+  } finally {
+    chartLoading.value = false
+  }
+}, 1000)
+
+const render = () => {
+  if (!chartRef.value) return
+
+  chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
+
+  const values: number[] = []
+
+  for (const [_name, key] of legend.value) {
+    values.push(...(chartData.value[key] || []))
+  }
+
+  const maxVal = values.length === 0 ? 10000 : Math.max(...values)
+  const minVal = values.length === 0 ? 0 : Math.min(...values)
+
+  const maxDigits = (Math.floor(maxVal) + '').length
+  const minDigits = (Math.floor(Math.abs(minVal)) + '').length
+  const interval = Math.max(maxDigits, minDigits)
+
+  const maxInterval = interval
+  const minInterval = minDigits
+
+  const intervalArr = [0]
+  for (let i = 1; i <= interval; i++) {
+    intervalArr.push(Math.pow(10, i))
+  }
+
+  chart.setOption({
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'line'
+      },
+      formatter: (params) => {
+        let d = `${params[0].axisValueLabel}<br>`
+        let item = params.map((el) => {
+          return `<div class="flex items-center justify-between mt-1 gap-1">
+            <span>${el.marker} ${el.seriesName}</span>
+            <span>${chartData.value[legend.value[el.componentIndex][1]][el.dataIndex].toFixed(2)} ${el.seriesName.split(' ')[1]}</span>
+          </div>`
+        })
+
+        return d + item.join('')
+      }
+    },
+    legend: {
+      data: legend.value.map(([name]) => name),
+      show: true
+    },
+    xAxis: {
+      type: 'category',
+      data: xAxisData.value
+    },
+    yAxis: {
+      type: 'value',
+      min: minInterval,
+      max: maxInterval,
+      interval: 1,
+      axisLabel: {
+        formatter: (v) => {
+          const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
+
+          return num.toLocaleString()
+        }
+      }
+    },
+    series: legend.value.map(([name, key]) => ({
+      name,
+      type: 'line',
+      smooth: true,
+      showSymbol: true,
+      data: chartData.value[key].map((value) => {
+        // return value
+        if (value === 0) return 0
+
+        const isPositive = value > 0
+        const absItem = Math.abs(value)
+
+        const min_value = Math.max(...intervalArr.filter((v) => v <= absItem))
+        const min_index = intervalArr.findIndex((v) => v === min_value)
+
+        const new_value =
+          (absItem - min_value) / (intervalArr[min_index + 1] - intervalArr[min_index]) + min_index
+
+        return isPositive ? new_value : -new_value
+      })
+    }))
+  })
+}
+
 const handleDeptNodeClick = (node: any) => {
   query.value.deptId = node.id
   handleQuery()
@@ -265,8 +322,11 @@ const handleQuery = (setPage = true) => {
   if (setPage) {
     query.value.pageNo = 1
   }
-  getTotal()
+  getChart().then(() => {
+    render()
+  })
   getList()
+  getTotal()
 }
 
 const resetQuery = () => {
@@ -288,6 +348,73 @@ watch(
 onMounted(() => {
   handleQuery()
 })
+
+const exportChart = () => {
+  if (!chart) return
+  let img = new Image()
+  img.src = chart.getDataURL({
+    type: 'png',
+    pixelRatio: 1,
+    backgroundColor: '#fff'
+  })
+
+  img.onload = function () {
+    let canvas = document.createElement('canvas')
+    canvas.width = img.width
+    canvas.height = img.height
+    let ctx = canvas.getContext('2d')
+    ctx?.drawImage(img, 0, 0)
+    let dataURL = canvas.toDataURL('image/png')
+
+    let a = document.createElement('a')
+
+    let event = new MouseEvent('click')
+
+    a.href = dataURL
+    a.download = `瑞鹰钻井日报统计数据.png`
+    a.dispatchEvent(event)
+  }
+}
+
+const exportData = async () => {
+  const res = await IotRyDailyReportApi.exportRyDailyReportStatistics(query.value)
+
+  download.excel(res, '瑞鹰钻井日报统计数据.xlsx')
+}
+
+const exportAll = async () => {
+  if (tab.value === '看板') exportChart()
+  else exportData()
+}
+
+const message = useMessage()
+
+const unfilledDialogRef = ref()
+
+const openUnfilledDialog = () => {
+  // 检查是否选择了创建时间
+  if (!query.value.createTime || query.value.createTime.length === 0) {
+    message.warning('请先选择创建时间范围')
+    return
+  }
+
+  // 打开弹窗
+  unfilledDialogRef.value?.open()
+}
+
+const router = useRouter()
+
+const tolist = (id: number) => {
+  const { pageNo, pageSize, ...rest } = query.value
+
+  router.push({
+    path: '/iotdayilyreport/IotRyDailyReport',
+    query: {
+      ...rest,
+      deptId: id
+    }
+  })
+}
 </script>
 
 <template>
@@ -345,43 +472,108 @@ onMounted(() => {
           class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow p-4 flex flex-col items-center justify-center gap-2"
         >
           <div class="size-7.5" :class="info[3]"></div>
-          <count-to class="text-2xl font-medium" :start-val="0" :end-val="totalWork[info[0]]">
+          <count-to
+            class="text-2xl font-medium"
+            :start-val="0"
+            :end-val="totalWork[info[0]]"
+            :decimals="info[4]"
+            @click="info[2] === '未填报' ? openUnfilledDialog() : ''"
+          >
             <span class="text-xs leading-8 text-[var(--el-text-color-regular)]">暂无数据</span>
           </count-to>
           <div class="text-xs font-medium text-[var(--el-text-color-regular)]">{{ info[1] }}</div>
           <div class="text-sm font-medium text-[var(--el-text-color-regular)]">{{ info[2] }}</div>
         </div>
       </div>
-      <div class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow pt-4 px-8">
-        <el-table
-          v-loading="listLoading"
-          :data="list"
-          :stripe="true"
-          :style="{ width: '100%' }"
-          max-height="600"
-          class="min-h-143"
-          show-overflow-tooltip
-        >
-          <el-table-column
-            v-for="item in columns(type)"
-            :key="item.prop"
-            :label="item.label"
-            :prop="item.prop"
-            align="center"
-            :formatter="formatter"
-          />
-        </el-table>
-
-        <Pagination
-          class="mt-8"
-          :total="total"
-          v-model:page="query.pageNo"
-          v-model:limit="query.pageSize"
-          @pagination="getList"
-        />
+      <div
+        class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-8 py-4 grid grid-rows-[48px_1fr] gap-2"
+      >
+        <div class="flex items-center justify-between">
+          <el-button-group>
+            <el-button
+              size="default"
+              :type="tab === '表格' ? 'primary' : 'default'"
+              @click="handleSelectTab('表格')"
+              >表格
+            </el-button>
+            <el-button
+              size="default"
+              :type="tab === '看板' ? 'primary' : 'default'"
+              @click="handleSelectTab('看板')"
+              >看板
+            </el-button>
+          </el-button-group>
+          <h3 class="text-xl font-medium">{{ `${deptName}-${tab}` }}</h3>
+          <el-button size="default" type="primary" @click="exportAll">导出</el-button>
+        </div>
+        <el-auto-resizer>
+          <template #default="{ height, width }">
+            <Motion
+              as="div"
+              :style="{ position: 'relative', overflow: 'hidden' }"
+              :animate="{ height: `${height}px`, width: `${width}px` }"
+              :transition="{ type: 'spring', stiffness: 200, damping: 25, duration: 0.3 }"
+            >
+              <AnimatePresence :initial="false" mode="sync">
+                <Motion
+                  :key="currentTab"
+                  as="div"
+                  :initial="{ x: direction === 'left' ? '-100%' : '100%', opacity: 0 }"
+                  :animate="{ x: '0%', opacity: 1 }"
+                  :exit="{ x: direction === 'left' ? '50%' : '-50%', opacity: 0 }"
+                  :transition="{ type: 'tween', stiffness: 300, damping: 30, duration: 0.3 }"
+                  :style="{ position: 'absolute', left: 0, right: 0, top: 0 }"
+                >
+                  <div :style="{ width: `${width}px`, height: `${height}px` }">
+                    <el-table
+                      v-if="currentTab === '表格'"
+                      v-loading="listLoading"
+                      :data="list"
+                      :stripe="true"
+                      :width="width"
+                      :max-height="height"
+                      show-overflow-tooltip
+                    >
+                      <template v-for="item in columns(type)" :key="item.prop">
+                        <el-table-column
+                          v-if="item.prop !== 'name'"
+                          :label="item.label"
+                          :prop="item.prop"
+                          align="center"
+                          :formatter="formatter"
+                        />
+                        <el-table-column
+                          v-else
+                          :label="item.label"
+                          :prop="item.prop"
+                          align="center"
+                        >
+                          <template #default="{ row }">
+                            <el-button text type="primary" @click.prevent="tolist(row.id)">{{
+                              row.name
+                            }}</el-button>
+                          </template>
+                        </el-table-column>
+                      </template>
+                    </el-table>
+                    <div
+                      ref="chartRef"
+                      v-loading="chartLoading"
+                      :key="dayjs().valueOf()"
+                      v-else
+                      :style="{ width: `${width}px`, height: `${height}px` }"
+                    >
+                    </div>
+                  </div>
+                </Motion>
+              </AnimatePresence>
+            </Motion>
+          </template>
+        </el-auto-resizer>
       </div>
     </div>
   </div>
+  <UnfilledReportDialog ref="unfilledDialogRef" :query-params="query" />
 </template>
 
 <style scoped>

+ 13 - 4
src/views/pms/iotrydailyreport/xjindex.vue

@@ -497,7 +497,7 @@ const selectedRowData = ref<Record<string, any> | null>(null)
 const loading = ref(true) // 列表的加载中
 const list = ref<IotRyDailyReportVO[]>([]) // 列表的数据
 const total = ref(0) // 列表的总页数
-const queryParams = reactive({
+let queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
   deptId: undefined,
@@ -802,14 +802,14 @@ const cellStyle = ({
 }) => {
   // 当日油耗预警逻辑
   if (column.property === 'dailyFuel') {
-    const dailyFuel = parseFloat(row.dailyFuel) || 0;
+    const dailyFuel = parseFloat(row.dailyFuel) || 0
     if (dailyFuel > 5) {
       return {
         backgroundColor: '#e6f8ff', // 浅黄色背景
         color: '#0a35c4', // 橙色文字
         fontWeight: 'bold',
         border: '1px solid #ffd591' // 可选:添加边框突出显示
-      };
+      }
     }
   }
 
@@ -1006,9 +1006,18 @@ const handleExport = async () => {
 // 声明 ResizeObserver 实例
 let resizeObserver: ResizeObserver | null = null
 
+const route = useRoute()
+
 /** 初始化 **/
 onMounted(() => {
-  getList()
+  if (Object.keys(route.query).length > 0) {
+    queryParams = {
+      ...queryParams,
+      ...route.query,
+      deptId: Number(route.query.deptId) as any
+    }
+    handleQuery()
+  } else getList()
   // 创建 ResizeObserver 监听表格容器尺寸变化
   if (tableContainerRef.value?.$el) {
     resizeObserver = new ResizeObserver(() => {

+ 340 - 145
src/views/pms/iotrydailyreport/xsummary.vue

@@ -1,105 +1,16 @@
 <script setup lang="ts">
 import DeptTree2 from '@/views/pms/iotrhdailyreport/DeptTree2.vue'
-import quarterOfYear from 'dayjs/plugin/quarterOfYear'
 import dayjs from 'dayjs'
 import { IotRyDailyReportApi } from '@/api/pms/iotrydailyreport'
 import { useDebounceFn } from '@vueuse/core'
 import CountTo from '@/components/count-to1.vue'
+import * as echarts from 'echarts'
+import UnfilledReportDialog from './UnfilledReportDialog.vue'
 
-dayjs.extend(quarterOfYear)
+import { Motion, AnimatePresence } from 'motion-v'
 
-const rangeShortcuts = [
-  {
-    text: '今天',
-    value: () => {
-      const today = dayjs()
-      return [today.startOf('day').toDate(), today.endOf('day').toDate()]
-    }
-  },
-  {
-    text: '昨天',
-    value: () => {
-      const yesterday = dayjs().subtract(1, 'day')
-      return [yesterday.startOf('day').toDate(), yesterday.endOf('day').toDate()]
-    }
-  },
-  {
-    text: '本周',
-    value: () => {
-      return [dayjs().startOf('week').toDate(), dayjs().endOf('week').toDate()]
-    }
-  },
-  {
-    text: '上周',
-    value: () => {
-      const lastWeek = dayjs().subtract(1, 'week')
-      return [lastWeek.startOf('week').toDate(), lastWeek.endOf('week').toDate()]
-    }
-  },
-  {
-    text: '本月',
-    value: () => {
-      return [dayjs().startOf('month').toDate(), dayjs().endOf('month').toDate()]
-    }
-  },
-  {
-    text: '上月',
-    value: () => {
-      const lastMonth = dayjs().subtract(1, 'month')
-      return [lastMonth.startOf('month').toDate(), lastMonth.endOf('month').toDate()]
-    }
-  },
-  {
-    text: '本季度',
-    value: () => {
-      return [dayjs().startOf('quarter').toDate(), dayjs().endOf('quarter').toDate()]
-    }
-  },
-  {
-    text: '上季度',
-    value: () => {
-      const lastQuarter = dayjs().subtract(1, 'quarter')
-      return [lastQuarter.startOf('quarter').toDate(), lastQuarter.endOf('quarter').toDate()]
-    }
-  },
-  {
-    text: '今年',
-    value: () => {
-      return [dayjs().startOf('year').toDate(), dayjs().endOf('year').toDate()]
-    }
-  },
-  {
-    text: '去年',
-    value: () => {
-      const lastYear = dayjs().subtract(1, 'year')
-      return [lastYear.startOf('year').toDate(), lastYear.endOf('year').toDate()]
-    }
-  },
-  {
-    text: '最近7天',
-    value: () => {
-      return [dayjs().subtract(6, 'day').toDate(), dayjs().toDate()]
-    }
-  },
-  {
-    text: '最近30天',
-    value: () => {
-      return [dayjs().subtract(29, 'day').toDate(), dayjs().toDate()]
-    }
-  },
-  {
-    text: '最近90天',
-    value: () => {
-      return [dayjs().subtract(89, 'day').toDate(), dayjs().toDate()]
-    }
-  },
-  {
-    text: '最近一年',
-    value: () => {
-      return [dayjs().subtract(1, 'year').toDate(), dayjs().toDate()]
-    }
-  }
-]
+import { rangeShortcuts } from '@/utils/formatTime'
+import download from '@/utils/download'
 
 interface Query {
   pageNo: number
@@ -117,33 +28,38 @@ const query = ref<Query>({
   pageNo: 1,
   pageSize: 10,
   deptId: 158,
-  createTime: [],
+  createTime: [
+    ...rangeShortcuts[2].value().map((item) => dayjs(item).format('YYYY-MM-DD HH:mm:ss'))
+  ],
   projectClassification: 2
 })
 
-const totalWorkKeys = [
-  ['totalCount', '个', '总数', 'i-tabler:report-analytics text-sky'],
+const totalWorkKeys: [string, string, string, string, number][] = [
+  ['totalCount', '个', '总数', 'i-tabler:report-analytics text-sky', 0],
   [
     'alreadyReported',
     '个',
     '已填报',
-    'i-material-symbols:check-circle-outline-rounded text-emerald'
+    'i-material-symbols:check-circle-outline-rounded text-emerald',
+    0
   ],
-  ['notReported', '个', '未填报', 'i-material-symbols:cancel-outline-rounded text-rose'],
+  ['notReported', '个', '未填报', 'i-material-symbols:cancel-outline-rounded text-rose', 0],
   [
     'totalFuelConsumption',
     '吨',
     '累计油耗',
-    'i-material-symbols:directions-car-outline-rounded text-sky'
+    'i-material-symbols:directions-car-outline-rounded text-sky',
+    2
   ],
   [
     'totalPowerConsumption',
     'KWH',
     '累计用电量',
-    'i-material-symbols:electric-bolt-outline-rounded text-sky'
+    'i-material-symbols:electric-bolt-outline-rounded text-sky',
+    2
   ],
-  ['constructionWells', '个', '累计施工井数', 'i-mdi:progress-wrench text-sky'],
-  ['completedWells', '个', '累计完工井数', 'i-mdi:wrench-check-outline text-emerald']
+  ['constructionWells', '个', '累计施工井数', 'i-mdi:progress-wrench text-sky', 0],
+  ['completedWells', '个', '累计完工井数', 'i-mdi:wrench-check-outline text-emerald', 0]
 ]
 
 const totalWork = ref({
@@ -198,8 +114,6 @@ interface List {
   transitTime: number | null
 }
 
-const total = ref<number>(1000)
-
 const list = ref<List[]>([])
 
 const type = ref('2')
@@ -238,20 +152,15 @@ const formatter = (row: List, column: any) => {
 const getList = useDebounceFn(async () => {
   listLoading.value = true
   try {
-    const res = (await IotRyDailyReportApi.getIotRyDailyReportSummary(query.value)) as {
-      total: number
-      list: any[]
-    }
+    const res = await IotRyDailyReportApi.getIotRyDailyReportSummary(query.value)
 
-    const { total: resTotal, list: resList } = res
+    const { list: reslist } = res
 
-    total.value = resTotal
+    type.value = reslist[0]?.type || '2'
 
-    type.value = resList[0]?.type || '2'
-
-    list.value = resList.map(
-      ({ id, projectDeptIa, projectDeptName, teamId, teamName, sort, taskId, type, ...other }) => ({
-        id: type === '2' ? projectDeptIa : teamId,
+    list.value = reslist.map(
+      ({ id, projectDeptId, projectDeptName, teamId, teamName, sort, taskId, type, ...other }) => ({
+        id: type === '2' ? projectDeptId : teamId,
         name: type === '2' ? projectDeptName : teamName,
         ...other
       })
@@ -261,6 +170,157 @@ const getList = useDebounceFn(async () => {
   }
 }, 1000)
 
+const tab = ref<'表格' | '看板'>('表格')
+
+const currentTab = ref<'表格' | '看板'>('表格')
+
+const deptName = ref('瑞恒兴域')
+
+const direction = ref<'left' | 'right'>('right')
+
+const handleSelectTab = (val: '表格' | '看板') => {
+  tab.value = val
+  direction.value = val === '看板' ? 'right' : 'left'
+  nextTick(() => {
+    currentTab.value = val
+    setTimeout(() => {
+      render()
+    })
+  })
+}
+
+const chartRef = ref<HTMLDivElement | null>(null)
+let chart: echarts.ECharts | null = null
+
+const xAxisData = ref<string[]>([])
+
+const legend = ref<string[][]>([
+  ['累计施工井数 (个)', 'cumulativeConstructWells'],
+  ['累计完工井数 (个)', 'cumulativeCompletedWells'],
+  ['累计油耗 (吨)', 'cumulativeFuelConsumption'],
+  ['累计用电量 (KWH)', 'cumulativePowerConsumption'],
+  ['平均时效 (%)', 'transitTime']
+])
+
+const chartData = ref<Record<string, number[]>>({
+  cumulativeFuelConsumption: [],
+  cumulativeConstructWells: [],
+  cumulativeCompletedWells: [],
+  cumulativePowerConsumption: [],
+  transitTime: []
+})
+
+let chartLoading = ref(false)
+
+const getChart = useDebounceFn(async () => {
+  chartLoading.value = true
+
+  try {
+    const res = await IotRyDailyReportApi.getIotRyDailyReportSummaryPolyline(query.value)
+
+    chartData.value = {
+      cumulativeFuelConsumption: res.map((item) => item.cumulativeFuelConsumption || 0),
+      cumulativeConstructWells: res.map((item) => item.cumulativeConstructWells || 0),
+      cumulativeCompletedWells: res.map((item) => item.cumulativeCompletedWells || 0),
+      cumulativePowerConsumption: res.map((item) => item.cumulativePowerConsumption || 0),
+      transitTime: res.map((item) => (item.transitTime || 0) * 100)
+    }
+
+    xAxisData.value = res.map((item) => item.reportDate || '')
+  } finally {
+    chartLoading.value = false
+  }
+}, 1000)
+
+const render = () => {
+  if (!chartRef.value) return
+
+  chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
+
+  const values: number[] = []
+
+  for (const [_name, key] of legend.value) {
+    values.push(...(chartData.value[key] || []))
+  }
+
+  const maxVal = values.length === 0 ? 10000 : Math.max(...values)
+  const minVal = values.length === 0 ? 0 : Math.min(...values)
+
+  const maxDigits = (Math.floor(maxVal) + '').length
+  const minDigits = (Math.floor(Math.abs(minVal)) + '').length
+  const interval = Math.max(maxDigits, minDigits)
+
+  const maxInterval = interval
+  const minInterval = minDigits
+
+  const intervalArr = [0]
+  for (let i = 1; i <= interval; i++) {
+    intervalArr.push(Math.pow(10, i))
+  }
+
+  chart.setOption({
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'line'
+      },
+      formatter: (params) => {
+        let d = `${params[0].axisValueLabel}<br>`
+        let item = params.map((el) => {
+          return `<div class="flex items-center justify-between mt-1 gap-1">
+            <span>${el.marker} ${el.seriesName}</span>
+            <span>${chartData.value[legend.value[el.componentIndex][1]][el.dataIndex].toFixed(2)} ${el.seriesName.split(' ')[1]}</span>
+          </div>`
+        })
+
+        return d + item.join('')
+      }
+    },
+    legend: {
+      data: legend.value.map(([name]) => name),
+      show: true
+    },
+    xAxis: {
+      type: 'category',
+      data: xAxisData.value
+    },
+    yAxis: {
+      type: 'value',
+      min: minInterval,
+      max: maxInterval,
+      interval: 1,
+      axisLabel: {
+        formatter: (v) => {
+          const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
+
+          return num.toLocaleString()
+        }
+      }
+    },
+    series: legend.value.map(([name, key]) => ({
+      name,
+      type: 'line',
+      smooth: true,
+      showSymbol: true,
+      data: chartData.value[key].map((value) => {
+        // return value
+        if (value === 0) return 0
+
+        const isPositive = value > 0
+        const absItem = Math.abs(value)
+
+        const min_value = Math.max(...intervalArr.filter((v) => v <= absItem))
+        const min_index = intervalArr.findIndex((v) => v === min_value)
+
+        const new_value =
+          (absItem - min_value) / (intervalArr[min_index + 1] - intervalArr[min_index]) + min_index
+
+        return isPositive ? new_value : -new_value
+      })
+    }))
+  })
+}
+
 const handleDeptNodeClick = (node: any) => {
   query.value.deptId = node.id
   handleQuery()
@@ -270,8 +330,11 @@ const handleQuery = (setPage = true) => {
   if (setPage) {
     query.value.pageNo = 1
   }
-  getTotal()
+  getChart().then(() => {
+    render()
+  })
   getList()
+  getTotal()
 }
 
 const resetQuery = () => {
@@ -293,6 +356,73 @@ watch(
 onMounted(() => {
   handleQuery()
 })
+
+const exportChart = () => {
+  if (!chart) return
+  let img = new Image()
+  img.src = chart.getDataURL({
+    type: 'png',
+    pixelRatio: 1,
+    backgroundColor: '#fff'
+  })
+
+  img.onload = function () {
+    let canvas = document.createElement('canvas')
+    canvas.width = img.width
+    canvas.height = img.height
+    let ctx = canvas.getContext('2d')
+    ctx?.drawImage(img, 0, 0)
+    let dataURL = canvas.toDataURL('image/png')
+
+    let a = document.createElement('a')
+
+    let event = new MouseEvent('click')
+
+    a.href = dataURL
+    a.download = `瑞鹰修井日报统计数据.png`
+    a.dispatchEvent(event)
+  }
+}
+
+const exportData = async () => {
+  const res = await IotRyDailyReportApi.exportRyDailyReportStatistics(query.value)
+
+  download.excel(res, '瑞鹰修井日报统计数据.xlsx')
+}
+
+const exportAll = async () => {
+  if (tab.value === '看板') exportChart()
+  else exportData()
+}
+
+const message = useMessage()
+
+const unfilledDialogRef = ref()
+
+const openUnfilledDialog = () => {
+  // 检查是否选择了创建时间
+  if (!query.value.createTime || query.value.createTime.length === 0) {
+    message.warning('请先选择创建时间范围')
+    return
+  }
+
+  // 打开弹窗
+  unfilledDialogRef.value?.open()
+}
+
+const router = useRouter()
+
+const tolist = (id: number) => {
+  const { pageNo, pageSize, ...rest } = query.value
+
+  router.push({
+    path: '/iotdayilyreport/IotRyXjDailyReport',
+    query: {
+      ...rest,
+      deptId: id
+    }
+  })
+}
 </script>
 
 <template>
@@ -350,43 +480,108 @@ onMounted(() => {
           class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow p-4 flex flex-col items-center justify-center gap-2"
         >
           <div class="size-7.5" :class="info[3]"></div>
-          <count-to class="text-2xl font-medium" :start-val="0" :end-val="totalWork[info[0]]">
+          <count-to
+            class="text-2xl font-medium"
+            :start-val="0"
+            :end-val="totalWork[info[0]]"
+            :decimals="info[4]"
+            @click="info[2] === '未填报' ? openUnfilledDialog() : ''"
+          >
             <span class="text-xs leading-8 text-[var(--el-text-color-regular)]">暂无数据</span>
           </count-to>
           <div class="text-xs font-medium text-[var(--el-text-color-regular)]">{{ info[1] }}</div>
           <div class="text-sm font-medium text-[var(--el-text-color-regular)]">{{ info[2] }}</div>
         </div>
       </div>
-      <div class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow pt-4 px-8">
-        <el-table
-          v-loading="listLoading"
-          :data="list"
-          :stripe="true"
-          :style="{ width: '100%' }"
-          max-height="600"
-          class="min-h-143"
-          show-overflow-tooltip
-        >
-          <el-table-column
-            v-for="item in columns(type)"
-            :key="item.prop"
-            :label="item.label"
-            :prop="item.prop"
-            align="center"
-            :formatter="formatter"
-          />
-        </el-table>
-
-        <Pagination
-          class="mt-8"
-          :total="total"
-          v-model:page="query.pageNo"
-          v-model:limit="query.pageSize"
-          @pagination="getList"
-        />
+      <div
+        class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-8 py-4 grid grid-rows-[48px_1fr] gap-2"
+      >
+        <div class="flex items-center justify-between">
+          <el-button-group>
+            <el-button
+              size="default"
+              :type="tab === '表格' ? 'primary' : 'default'"
+              @click="handleSelectTab('表格')"
+              >表格
+            </el-button>
+            <el-button
+              size="default"
+              :type="tab === '看板' ? 'primary' : 'default'"
+              @click="handleSelectTab('看板')"
+              >看板
+            </el-button>
+          </el-button-group>
+          <h3 class="text-xl font-medium">{{ `${deptName}-${tab}` }}</h3>
+          <el-button size="default" type="primary" @click="exportAll">导出</el-button>
+        </div>
+        <el-auto-resizer>
+          <template #default="{ height, width }">
+            <Motion
+              as="div"
+              :style="{ position: 'relative', overflow: 'hidden' }"
+              :animate="{ height: `${height}px`, width: `${width}px` }"
+              :transition="{ type: 'spring', stiffness: 200, damping: 25, duration: 0.3 }"
+            >
+              <AnimatePresence :initial="false" mode="sync">
+                <Motion
+                  :key="currentTab"
+                  as="div"
+                  :initial="{ x: direction === 'left' ? '-100%' : '100%', opacity: 0 }"
+                  :animate="{ x: '0%', opacity: 1 }"
+                  :exit="{ x: direction === 'left' ? '50%' : '-50%', opacity: 0 }"
+                  :transition="{ type: 'tween', stiffness: 300, damping: 30, duration: 0.3 }"
+                  :style="{ position: 'absolute', left: 0, right: 0, top: 0 }"
+                >
+                  <div :style="{ width: `${width}px`, height: `${height}px` }">
+                    <el-table
+                      v-if="currentTab === '表格'"
+                      v-loading="listLoading"
+                      :data="list"
+                      :stripe="true"
+                      :width="width"
+                      :max-height="height"
+                      show-overflow-tooltip
+                    >
+                      <template v-for="item in columns(type)" :key="item.prop">
+                        <el-table-column
+                          v-if="item.prop !== 'name'"
+                          :label="item.label"
+                          :prop="item.prop"
+                          align="center"
+                          :formatter="formatter"
+                        />
+                        <el-table-column
+                          v-else
+                          :label="item.label"
+                          :prop="item.prop"
+                          align="center"
+                        >
+                          <template #default="{ row }">
+                            <el-button text type="primary" @click.prevent="tolist(row.id)">{{
+                              row.name
+                            }}</el-button>
+                          </template>
+                        </el-table-column>
+                      </template>
+                    </el-table>
+                    <div
+                      ref="chartRef"
+                      v-loading="chartLoading"
+                      :key="dayjs().valueOf()"
+                      v-else
+                      :style="{ width: `${width}px`, height: `${height}px` }"
+                    >
+                    </div>
+                  </div>
+                </Motion>
+              </AnimatePresence>
+            </Motion>
+          </template>
+        </el-auto-resizer>
       </div>
     </div>
   </div>
+  <UnfilledReportDialog ref="unfilledDialogRef" :query-params="query" />
 </template>
 
 <style scoped>