yanghao 2 일 전
부모
커밋
4b6203dae6
34개의 변경된 파일4002개의 추가작업 그리고 669개의 파일을 삭제
  1. 1 1
      .env.local
  2. 13 3
      src/api/login/index.ts
  3. 8 0
      src/api/pms/device/index.ts
  4. 1 0
      src/api/pms/iotopeationfill/index.ts
  5. 4 0
      src/api/pms/iotrddailyreport/index.ts
  6. 6 0
      src/api/pms/iotrhdailyreport/index.ts
  7. 30 0
      src/api/pms/stat/index.ts
  8. 8 7
      src/components/ZmTable/ZmTableColumn.vue
  9. 10 2
      src/components/ZmTable/index.vue
  10. 1 1
      src/components/count-to1.vue
  11. 29 9
      src/permission.ts
  12. 115 0
      src/utils/td-color.ts
  13. 778 0
      src/views/oli-connection/monitoring/detail copy.vue
  14. 356 225
      src/views/oli-connection/monitoring/detail.vue
  15. 693 0
      src/views/pms/device/monitor/TdDeviceInfo copy.vue
  16. 381 225
      src/views/pms/device/monitor/TdDeviceInfo.vue
  17. 17 19
      src/views/pms/device/personlog/DevicePerson.vue
  18. 24 20
      src/views/pms/device/statuslog/DeviceStatus.vue
  19. 43 13
      src/views/pms/iotopeationfill/index1.vue
  20. 7 6
      src/views/pms/iotrddailyreport/FillDailyReportForm.vue
  21. 28 5
      src/views/pms/iotrddailyreport/fillDailyReport.vue
  22. 31 1
      src/views/pms/iotrddailyreport/index.vue
  23. 29 3
      src/views/pms/iotrhdailyreport/rh-form.vue
  24. 3 3
      src/views/pms/iotrydailyreport/summary.vue
  25. 4 4
      src/views/pms/iotrydailyreport/xsummary.vue
  26. 154 109
      src/views/pms/stat/rdkb.vue
  27. 138 0
      src/views/pms/stat/rdkb/availability.vue
  28. 144 0
      src/views/pms/stat/rdkb/constructionBriefing.vue
  29. 279 0
      src/views/pms/stat/rdkb/exception.vue
  30. 218 0
      src/views/pms/stat/rdkb/utilization.vue
  31. 361 0
      src/views/pms/stat/rdkb/workload.vue
  32. 36 11
      src/views/report-statistics/costs.vue
  33. 21 0
      src/views/report-statistics/daily-report.vue
  34. 31 2
      src/views/report-statistics/work-order-completion.vue

+ 1 - 1
.env.local

@@ -3,7 +3,7 @@ NODE_ENV=development
 
 VITE_DEV=true
 
-# 请求路径  http://192.168.188.149:48080  https://iot.deepoil.cc http://192.168.188.198:48080
+# 请求路径  http://192.168.188.149:48080  https://iot.deepoil.cc
 VITE_BASE_URL='https://iot.deepoil.cc'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持 S3 服务

+ 13 - 3
src/api/login/index.ts

@@ -1,5 +1,6 @@
 import request from '@/config/axios'
 import type { RegisterVO, UserLoginVO } from './types'
+import { head } from 'lodash-es'
 
 export interface SmsCodeVO {
   mobile: string
@@ -16,12 +17,12 @@ export const login = (data: UserLoginVO) => {
   return request.post({ url: '/system/auth/login', data })
 }
 
-export const dingTalkLogin = (data:{code:string,type:number,state:number}) => {
-  return request.post({ url: '/system/auth/h5SocialLogin', data,headers:{'tenant-id':1} })
+export const dingTalkLogin = (data: { code: string; type: number; state: number }) => {
+  return request.post({ url: '/system/auth/h5SocialLogin', data, headers: { 'tenant-id': 1 } })
 }
 
 export const simpleLogin = (id: any) => {
-  return request.post({ url: '/system/auth/simple/login/'+id })
+  return request.post({ url: '/system/auth/simple/login/' + id })
 }
 // 注册
 export const register = (data: RegisterVO) => {
@@ -90,3 +91,12 @@ export const reqCheck = (data: any) => {
 export const smsResetPassword = (data: any) => {
   return request.post({ url: '/system/auth/reset-password', data })
 }
+
+// 门户用户登录
+export const portalLogin = (data: any) => {
+  return request.post({
+    url: 'system/auth/portal/login',
+    headers: { 'portal-secret': 'cc99d802-ce5c-5f62-b037-9a00726e7109' },
+    data
+  })
+}

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

@@ -153,6 +153,14 @@ export const IotDeviceApi = {
     return await request.download({ url: `/rq/iot-device/export-excel`, params })
   },
 
+  exportIotDeviceAdjust: async (params) => {
+    return await request.download({ url: `/pms/iot-device-status-log/export-excel`, params })
+  },
+
+  exportIotDevicePerson: async (params) => {
+    return await request.download({ url: `/pms/iot-device-person-log/export-excel`, params })
+  },
+
   exportIotDeviceAllot: async (params) => {
     return await request.download({ url: `/pms/iot-device-allot-log/export-excel`, params })
   },

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

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

+ 4 - 0
src/api/pms/iotrddailyreport/index.ts

@@ -99,6 +99,10 @@ export const IotRdDailyReportApi = {
     return await request.download({ url: `/pms/iot-rd-daily-report/export-excel`, params })
   },
 
+  exportIotRdDailyReportDetails: async (params) => {
+    return await request.download({ url: `/pms/iot-rd-daily-report/export-detail`, params })
+  },
+
   // 查询项目任务实际进度列表
   taskActualProgress: async (params: any) => {
     return await request.get({ url: `/pms/iot-rd-daily-report/taskActualProgress`, params })

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

@@ -113,5 +113,11 @@ export const IotRhDailyReportApi = {
   // 导出瑞恒日报 Excel
   exportIotRhDailyReport: async (params) => {
     return await request.download({ url: `/pms/iot-rh-daily-report/export-excel`, params })
+  },
+  exportIotRhDailyReportWell: async (params) => {
+    return await request.download({ url: `/pms/iot-rh-daily-report/exportSingleWells`, params })
+  },
+  exportIotRhDailyReportTeam: async (params) => {
+    return await request.download({ url: `/pms/iot-rh-daily-report/exportSingleTeams`, params })
   }
 }

+ 30 - 0
src/api/pms/stat/index.ts

@@ -123,6 +123,12 @@ export const IotStatApi = {
   getDeviceCount: async (params: any) => {
     return await request.get({ url: `/rq/stat/home/device/count/` + params })
   },
+  getAbnormalDevice: async (params: any) => {
+    return await request.get({ url: `/rq/stat/inspect/exception/device` + params })
+  },
+  getOutliers: async (params: any) => {
+    return await request.get({ url: `/rq/iot-inspect-order-detail/report/status` + params })
+  },
   getRhRate: async (params: any) => {
     return await request.get({ url: `/rq/stat/rh/device/utilizationRate`, params })
   },
@@ -174,5 +180,29 @@ export const IotStatApi = {
   },
   getDevSta: async (params: any) => {
     return await request.get({ url: `/rq/iot-opeation-fill/getDeviceCount`, params })
+  },
+  getRdWorkload: async (params: any) => {
+    return await request.get({ url: `/pms/iot-rd-daily-report/summaryStatistics`, params })
+  },
+  getRdWorkloadYear: async (params: any) => {
+    return await request.get({ url: `/pms/iot-rd-daily-report/workloadKanban`, params })
+  },
+  getUtilization: async (params: any) => {
+    return await request.get({ url: `/rq/stat/rd/device/utilizationRates`, params })
+  },
+  getWhl: async () => {
+    return await request.get({ url: `/rq/report/rd/whl` })
+  },
+  getDeviceException: async (params: any) => {
+    return await request.get({ url: `/rq/stat/inspect/exception/device`, params })
+  },
+  getStatusException: async (params: any) => {
+    return await request.get({ url: `/rq/iot-inspect-order-detail/report/status`, params })
+  },
+  getProductionException: async (params: any) => {
+    return await request.get({ url: `/pms/iot-rd-daily-report/abnormalAlert`, params })
+  },
+  getConstructionBriefing: async () => {
+    return await request.get({ url: `/pms/iot-rd-daily-report/constructionBriefing` })
   }
 }

+ 8 - 7
src/components/ZmTable/ZmTableColumn.vue

@@ -117,19 +117,20 @@ const calculativeWidth = () => {
   if (props.zmFilterable) labelWidth += 22
   if (props.zmSortable) labelWidth += 22
 
-  const maxWidth = Math.max(...values.map((value) => getTextWidth(value) + 38), labelWidth)
+  const maxWidth = Math.min(
+    Math.max(...values.map((value) => getTextWidth(value) + 38), labelWidth),
+    360
+  )
 
   defaultOptions.value.minWidth = maxWidth
 }
 
 watch(
   () => tableContext.loading.value,
-  (loading) => {
-    if (!loading) {
-      nextTick(() => {
-        calculativeWidth()
-      })
-    }
+  () => {
+    nextTick(() => {
+      calculativeWidth()
+    })
   },
   { immediate: true }
 )

+ 10 - 2
src/components/ZmTable/index.vue

@@ -8,6 +8,7 @@ interface Props extends /* @vue-ignore */ Partial<Omit<TableProps<T>, 'data'>> {
   handleQuery?: (payload?: FilterPayload) => void
   sortingFields?: SortField[]
   sortFn?: (prop: string, order: SortOrder | null) => void
+  customClass?: boolean
 }
 
 const props = defineProps<Props>()
@@ -25,7 +26,8 @@ const defaultOptions: Partial<Props> = {
   border: true,
   highlightCurrentRow: true,
   showOverflowTooltip: true,
-  scrollbarAlwaysOn: false
+  scrollbarAlwaysOn: false,
+  customClass: false
 }
 
 const bindProps = computed(() => {
@@ -85,7 +87,13 @@ defineExpose({
 </script>
 
 <template>
-  <el-table ref="tableRef" v-loading="loading" class="zm-table" v-bind="bindProps" :data="data">
+  <el-table
+    ref="tableRef"
+    v-loading="loading"
+    :class="{ 'zm-table': !customClass }"
+    v-bind="bindProps"
+    :data="data"
+  >
     <template v-for="(_, name) in $slots" #[name]="slotData">
       <slot :name="name" v-bind="slotData || {}"></slot>
     </template>

+ 1 - 1
src/components/count-to1.vue

@@ -88,7 +88,7 @@ defineExpose({
 
 <template>
   <span class="flex">
-    <span v-if="endVal">{{ value }}</span>
+    <span v-if="endVal !== null && endVal !== undefined">{{ value }}</span>
     <slot v-else></slot>
   </span>
 </template>

+ 29 - 9
src/permission.ts

@@ -1,16 +1,15 @@
 import router from './router'
 import type { RouteRecordRaw } from 'vue-router'
 import { isRelogin } from '@/config/axios/service'
-import {getAccessToken} from '@/utils/auth'
+import { getAccessToken } from '@/utils/auth'
 import { useTitle } from '@/hooks/web/useTitle'
 import { useNProgress } from '@/hooks/web/useNProgress'
 import { usePageLoading } from '@/hooks/web/usePageLoading'
 import { useDictStoreWithOut } from '@/store/modules/dict'
 import { useUserStoreWithOut } from '@/store/modules/user'
 import { usePermissionStoreWithOut } from '@/store/modules/permission'
-import * as LoginApi from "@/api/login";
-import * as authUtil from "@/utils/auth";
-
+import * as LoginApi from '@/api/login'
+import * as authUtil from '@/utils/auth'
 
 const { start, done } = useNProgress()
 
@@ -87,6 +86,18 @@ router.beforeEach(async (to, from, next) => {
   if (getAccessToken()) {
     if (to.path === '/login') {
       next({ path: '/' })
+    } else if (to.fullPath.includes('portalLogin')) {
+      // authUtil.removeToken()
+      // deleteUserCache()
+      const userStore = useUserStoreWithOut()
+      await userStore.loginOut()
+      await getTenantId()
+      const res = await LoginApi.portalLogin({
+        username: to.query.username
+      })
+
+      authUtil.setToken(res)
+      next({ path: '/' })
     } else {
       // 获取所有字典
       const dictStore = useDictStoreWithOut()
@@ -116,16 +127,25 @@ router.beforeEach(async (to, from, next) => {
       }
     }
   } else {
-    if (whiteList.indexOf(to.path) !== -1) {
-      const code = to.query.code;
+    if (to.query.username) {
+      await getTenantId()
+      const res = await LoginApi.portalLogin({
+        username: to.query.username
+      })
+
+      authUtil.setToken(res)
+
+      next({ path: '/index' })
+    } else if (whiteList.indexOf(to.path) !== -1) {
+      const code = to.query.code
       if (code) {
         debugger
         await getTenantId()
-        const res = await LoginApi.socialLogin('20', typeof code === "string" ? code :"", '22')
+        const res = await LoginApi.socialLogin('20', typeof code === 'string' ? code : '', '22')
         authUtil.setToken(res)
-        next({ path: 'index' });
+        next({ path: 'index' })
       } else {
-        next(); // 正常导航
+        next() // 正常导航
       }
       // next()
     } else {

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

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

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

@@ -0,0 +1,778 @@
+<script setup lang="ts">
+import { Odometer, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
+import { IotDeviceApi } from '@/api/pms/device'
+import dayjs from 'dayjs'
+import { rangeShortcuts } from '@/utils/formatTime'
+import { IotStatApi, cancelAllRequests } from '@/api/pms/stat'
+
+import * as echarts from 'echarts'
+import { colors } from '@/utils/td-color'
+import { useSocketBus } from '@/utils/useSocketBus'
+
+const { query } = useRoute()
+
+const data = ref({
+  deviceCode: query.code || '',
+  deviceName: query.name || '',
+  lastInlineTime: query.time || '',
+  ifInline: query.ifInline || '',
+  dept: query.dept || '',
+  vehicle: query.vehicle || '',
+  carOnline: query.carOnline || ''
+})
+
+const { open: connect, onAny, close } = useSocketBus(data.value.deviceCode as string)
+
+onAny((msg) => {
+  if (!Array.isArray(msg) || msg.length === 0) return
+
+  const valueMap = new Map<string, number>()
+
+  for (const item of msg) {
+    const { identity, modelName, readTime, logValue } = item
+
+    const value = logValue ? Number(logValue) : 0
+
+    if (identity) {
+      valueMap.set(identity, value)
+    }
+
+    if (modelName && chartData.value[modelName]) {
+      chartData.value[modelName].push({
+        ts: dayjs(readTime).valueOf(),
+        value
+      })
+
+      updateSingleSeries(modelName)
+    }
+  }
+
+  const updateDimensions = (list) => {
+    list.forEach((item) => {
+      const v = valueMap.get(item.identifier)
+      if (v !== undefined) {
+        item.value = v
+      }
+    })
+  }
+
+  updateDimensions(dimensions.value)
+  updateDimensions(gatewayDimensions.value)
+  updateDimensions(carDimensions.value)
+
+  // 3️⃣ 统一一次调用
+  genderIntervalArr()
+})
+
+interface Dimensions {
+  identifier: string
+  name: string
+  value: string
+  color?: string
+  response?: boolean
+}
+
+const dimensions = ref<Dimensions[]>([])
+const gatewayDimensions = ref<Dimensions[]>([])
+const carDimensions = ref<Dimensions[]>([])
+
+const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
+
+interface SelectedDimension {
+  [key: Dimensions['name']]: boolean
+}
+
+const selectedDimension = ref<SelectedDimension>({})
+
+const dimensionLoading = ref(false)
+
+const disabledDimension = computed(() => (identifier: string) => {
+  const response = dimensions.value.find((item) => item.identifier === identifier)?.response
+
+  return { disabled: disabledDimensions.value.includes(identifier) || response, loading: response }
+})
+
+async function loadDimensions() {
+  if (!query.id) return
+
+  dimensionLoading.value = true
+
+  const gateway = (((await IotDeviceApi.getIotDeviceTds(Number(query.id))) as any[]) ?? [])
+    .sort((a, b) => b.modelOrder - a.modelOrder)
+    .map((item) => ({
+      identifier: item.identifier,
+      name: item.modelName,
+      value: item.value
+    }))
+  const car = (((await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))) as any[]) ?? [])
+    .sort((a, b) => b.modelOrder - a.modelOrder)
+    .map((item) => ({
+      identifier: item.identifier,
+      name: item.modelName,
+      value: item.value
+    }))
+
+  dimensions.value = [...gateway, ...car]
+    .filter((item) => !disabledDimensions.value.includes(item.identifier))
+    .map((item, index) => ({
+      ...item,
+      color: colors[index]
+    }))
+
+  gatewayDimensions.value = gateway
+  carDimensions.value = car
+
+  selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
+
+  selectedDimension.value[dimensions.value[0].name] = true
+
+  dimensionLoading.value = false
+}
+
+// async function updateDimensionValues() {
+//   if (!query.id) return
+
+//   try {
+//     // 1. 并行获取最新数据
+//     const [gatewayRes, carRes] = await Promise.all([
+//       IotDeviceApi.getIotDeviceTds(Number(query.id)),
+//       IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))
+//     ])
+
+//     // 2. 创建一个 Map 用于快速查找 (Identifier -> Value)
+//     // 这样可以将复杂度从 O(N*M) 降低到 O(N)
+//     const newValueMap = new Map<string, any>()
+
+//     const addToMap = (data: any[]) => {
+//       if (!data) return
+//       data.forEach((item) => {
+//         if (item.identifier) {
+//           newValueMap.set(item.identifier, item.value)
+//         }
+//       })
+//     }
+
+//     addToMap(gatewayRes as any[])
+//     addToMap(carRes as any[])
+
+//     // 3. 更新 dimensions.value (保留了之前的 color 和其他属性)
+//     dimensions.value.forEach((item) => {
+//       if (newValueMap.has(item.identifier)) {
+//         item.value = newValueMap.get(item.identifier)
+//       }
+//     })
+
+//     // 4. 如果还需要同步更新 gatewayDimensions 和 carDimensions
+//     // (假设这些是引用类型,如果它们引用的是同一个对象,上面更新 dimensions 时可能已经同步了。
+//     // 如果它们是独立的对象数组,则需要显式更新)
+
+//     // 更新 Gateway 原始列表
+//     gatewayDimensions.value.forEach((item) => {
+//       if (newValueMap.has(item.identifier)) {
+//         item.value = newValueMap.get(item.identifier)
+//       }
+//     })
+
+//     // 更新 Car 原始列表
+//     carDimensions.value.forEach((item) => {
+//       if (newValueMap.has(item.identifier)) {
+//         item.value = newValueMap.get(item.identifier)
+//       }
+//     })
+//   } catch (error) {
+//     console.error('Failed to update dimension values:', error)
+//   }
+// }
+
+const selectedDate = ref<string[]>([
+  dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
+  dayjs().format('YYYY-MM-DD HH:mm:ss')
+])
+
+interface ChartData {
+  [key: Dimensions['name']]: { ts: number; value: number }[]
+}
+
+const chartData = ref<ChartData>({})
+
+let intervalArr = ref<number[]>([])
+let maxInterval = ref(0)
+let minInterval = ref(0)
+
+const chartRef = ref<HTMLDivElement | null>(null)
+let chart: echarts.ECharts | null = null
+
+// const genderIntervalArrDebounce = useDebounceFn(
+//   (init: boolean = false) => genderIntervalArr(init),
+//   300
+// )
+
+function genderIntervalArr(init: boolean = false) {
+  const values: number[] = []
+
+  for (const [key, value] of Object.entries(selectedDimension.value)) {
+    if (value) {
+      values.push(...(chartData.value[key]?.map((item) => item.value) ?? []))
+    }
+  }
+
+  const maxVal = values.length === 0 ? 10000 : Math.max(...values)
+  const minVal = values.length === 0 ? 0 : Math.min(...values) > 0 ? 0 : Math.min(...values)
+
+  const maxDigits = (Math.floor(maxVal) + '').length
+  const minDigits = minVal === 0 ? 0 : (Math.floor(Math.abs(minVal)) + '').length
+
+  const interval = Math.max(maxDigits, minDigits)
+
+  maxInterval.value = interval
+  minInterval.value = minDigits
+
+  intervalArr.value = [0]
+  for (let i = 1; i <= interval; i++) {
+    intervalArr.value.push(Math.pow(10, i))
+  }
+
+  if (!init) {
+    chart?.setOption({
+      yAxis: {
+        min: -minInterval.value,
+        max: maxInterval.value
+      }
+    })
+  }
+}
+
+function chartInit() {
+  if (!chart) return
+
+  chart.on('legendselectchanged', (params: any) => {
+    selectedDimension.value = params.selected
+  })
+
+  window.addEventListener('resize', () => {
+    if (chart) chart.resize()
+  })
+}
+
+function render() {
+  if (!chartRef.value) return
+
+  if (!chart) chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
+
+  chartInit()
+
+  genderIntervalArr(true)
+
+  chart.setOption({
+    animation: true,
+    animationDuration: 200,
+    animationEasing: 'linear',
+    animationDurationUpdate: 200,
+    animationEasingUpdate: 'linear',
+    grid: {
+      left: '6%',
+      top: '5%',
+      right: '6%',
+      bottom: '12%'
+    },
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'line'
+      },
+      formatter: (params) => {
+        let d = `${params[0].axisValueLabel}<br>`
+        const exist: string[] = []
+        params = params.filter((el) => {
+          if (exist.includes(el.seriesName)) return false
+          exist.push(el.seriesName)
+          return true
+        })
+        let item = params.map(
+          (el) => `<div class="flex items-center justify-between mt-1">
+            <span>${el.marker} ${el.seriesName}</span>
+            <span>${el.value[2]?.toFixed(2)}</span>
+          </div>`
+        )
+
+        return d + item.join('')
+      }
+    },
+    xAxis: {
+      type: 'time',
+      axisLabel: {
+        formatter: (v) => dayjs(v).format('YYYY-MM-DD\nHH:mm:ss'),
+        rotate: 0,
+        align: 'left'
+      }
+    },
+    dataZoom: [
+      { type: 'inside', xAxisIndex: 0 },
+      { type: 'slider', xAxisIndex: 0 }
+    ],
+    yAxis: {
+      type: 'value',
+      min: -minInterval.value,
+      max: maxInterval.value,
+      interval: 1,
+      axisLabel: {
+        formatter: (v) => {
+          const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
+
+          return num.toLocaleString()
+        }
+      },
+      show: false
+    },
+    legend: {
+      data: dimensions.value.map((item) => item.name),
+      selected: selectedDimension.value,
+      show: false
+    },
+    // series: dimensions.value.map((item) => ({
+    //   name: item.name,
+    //   type: 'line',
+    //   smooth: true,
+    //   showSymbol: false,
+    //   color: item.color,
+    //   data: [] // 占位数组
+    // }))
+    series: dimensions.value.map((item) => ({
+      name: item.name,
+      type: 'line',
+
+      smooth: 0.2,
+
+      showSymbol: false,
+
+      endLabel: {
+        show: true,
+        formatter: (params) => params.value[2]?.toFixed(2),
+        offset: [6, 0],
+        color: item.color,
+        fontSize: 12
+      },
+
+      emphasis: {
+        focus: 'series'
+      },
+
+      lineStyle: {
+        width: 2
+      },
+
+      color: item.color,
+      data: [] // 占位数组
+    }))
+  })
+}
+
+function mapData({ value, ts }) {
+  if (!value) return [ts, 0, 0]
+
+  const isPositive = value > 0
+  const absItem = Math.abs(value)
+
+  const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
+  const min_index = intervalArr.value.findIndex((v) => v === min_value)
+
+  const new_value =
+    (absItem - min_value) / (intervalArr.value[min_index + 1] - intervalArr.value[min_index]) +
+    min_index
+
+  return [ts, isPositive ? new_value : -new_value, value]
+}
+
+function updateSingleSeries(name: string) {
+  if (!chart) render()
+  if (!chart) return
+
+  const idx = dimensions.value.findIndex((item) => item.name === name)
+  if (idx === -1) return
+
+  const data = chartData.value[name].map((v) => mapData(v))
+
+  chart.setOption({
+    series: [{ name, data }]
+  })
+}
+
+const lastTsMap = ref<Record<Dimensions['name'], number>>({})
+
+// async function fetchIncrementData() {
+//   for (const item of dimensions.value) {
+//     const { identifier, name } = item
+
+//     const lastTs = lastTsMap.value[name]
+//     if (!lastTs) continue
+
+//     item.response = true
+
+//     IotStatApi.getDeviceInfoChart(
+//       data.value.deviceCode,
+//       identifier,
+//       dayjs(lastTs).format('YYYY-MM-DD HH:mm:ss'),
+//       dayjs().format('YYYY-MM-DD HH:mm:ss')
+//     )
+//       .then((res) => {
+//         if (!res.length) return
+
+//         const sorted = res
+//           .sort((a, b) => a.ts - b.ts)
+//           .map((item) => ({ ts: item.ts, value: item.value }))
+//         // push 到本地
+//         chartData.value[name].push(...sorted)
+//         // 更新 lastTs
+//         lastTsMap.value[identifier] = sorted.at(-1).ts
+
+//         // 更新图表
+//         updateSingleSeries(name)
+//       })
+//       .finally(() => {
+//         item.response = false
+//       })
+//   }
+// }
+
+// const timer = ref<NodeJS.Timeout | null>(null)
+
+// function startAutoFetch() {
+//   timer.value = setInterval(() => {
+//     updateDimensionValues()
+//     fetchIncrementData()
+//   }, 10000)
+// }
+
+// function stopAutoFetch() {
+//   cancelAllRequests()
+//   if (timer.value) clearInterval(timer.value)
+//   timer.value = null
+// }
+
+const chartLoading = ref(false)
+
+async function initLoadChartData(real_time: boolean = true) {
+  if (!dimensions.value.length) return
+
+  chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
+
+  chartLoading.value = true
+
+  dimensions.value = dimensions.value.map((item) => {
+    item.response = true
+    return item
+  })
+
+  for (const item of dimensions.value) {
+    const { identifier, name } = item
+    try {
+      const res = await IotStatApi.getDeviceInfoChart(
+        data.value.deviceCode,
+        identifier,
+        selectedDate.value[0],
+        selectedDate.value[1]
+      )
+
+      const sorted = res
+        .sort((a, b) => a.ts - b.ts)
+        .map((item) => ({ ts: item.ts, value: item.value }))
+
+      chartData.value[name] = sorted
+
+      lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
+
+      updateSingleSeries(name)
+
+      chartLoading.value = false
+
+      if (selectedDimension.value[name]) {
+        genderIntervalArr()
+      }
+    } finally {
+      item.response = false
+    }
+  }
+
+  if (real_time) {
+    // startAutoFetch()
+    connect()
+  }
+}
+
+async function initfn(load: boolean = true, real_time: boolean = true) {
+  if (load) await loadDimensions()
+  render()
+  initLoadChartData(real_time)
+}
+
+onMounted(() => {
+  initfn()
+})
+
+function reset() {
+  cancelAllRequests().then(() => {
+    selectedDate.value = [
+      dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
+      dayjs().format('YYYY-MM-DD HH:mm:ss')
+    ]
+
+    close()
+    // stopAutoFetch()
+    if (chart) chart.clear()
+    initfn(false)
+  })
+}
+
+function handleDateChange() {
+  cancelAllRequests().then(() => {
+    close()
+    // stopAutoFetch()
+    if (chart) chart.clear()
+    initfn(false, false)
+  })
+}
+
+function handleClickSpec(modelName: string) {
+  selectedDimension.value[modelName] = !selectedDimension.value[modelName]
+  chart?.setOption({
+    legend: {
+      selected: selectedDimension.value
+    }
+  })
+  chart?.resize()
+  genderIntervalArr()
+}
+
+const exportChart = () => {
+  if (!chart) return
+  let img = new Image()
+  img.src = chart.getDataURL({
+    type: 'png',
+    pixelRatio: 1,
+    backgroundColor: '#fff'
+  })
+
+  img.onload = function () {
+    let canvas = document.createElement('canvas')
+    canvas.width = img.width
+    canvas.height = img.height
+    let ctx = canvas.getContext('2d')
+    ctx?.drawImage(img, 0, 0)
+    let dataURL = canvas.toDataURL('image/png')
+
+    let a = document.createElement('a')
+
+    let event = new MouseEvent('click')
+
+    a.href = dataURL
+    a.download = `${data.value.deviceName}-设备监控-${dayjs().format('YYYY-MM-DD HH:mm:ss')}.png`
+    a.dispatchEvent(event)
+  }
+}
+
+const maxmin = computed(() => {
+  if (!dimensions.value.length) return []
+  return dimensions.value
+    .filter((v) => selectedDimension.value[v.name])
+    .map((v) => ({
+      name: v.name,
+      color: v.color,
+      max: Math.max(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2),
+      min: Math.min(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2)
+    }))
+})
+
+onUnmounted(() => {
+  // stopAutoFetch()
+  close()
+
+  window.removeEventListener('resize', () => {
+    if (chart) chart.resize()
+  })
+})
+</script>
+
+<template>
+  <div
+    class="w-full bg-gradient-to-r from-blue-100 to-white rounded-lg p-4 shadow"
+    id="td-device-info"
+  >
+    <h2 class="flex items-center gap-2">
+      <el-icon class="text-sky!"><Odometer /></el-icon> 设备基础信息
+    </h2>
+    <el-form size="default" label-position="top" class="mt-4 grid grid-cols-4 gap-2">
+      <el-form-item label="资产编码"> {{ data.deviceCode }} </el-form-item>
+      <el-form-item label="设备类别"> {{ data.deviceName }} </el-form-item>
+      <el-form-item label="所在部门"> {{ data.dept }} </el-form-item>
+      <el-form-item label="网关状态" class="online" type="plain">
+        <el-tag
+          v-if="data.ifInline === '3'"
+          type="success"
+          size="default"
+          class="flex items-center"
+        >
+          <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
+          在线
+        </el-tag>
+
+        <el-tag v-if="data.ifInline === '4'" type="danger" size="default" class="flex items-center">
+          <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
+          离线
+        </el-tag>
+      </el-form-item>
+      <el-form-item v-if="data.carOnline" label="中航北斗" class="online" type="plain">
+        <el-tag
+          v-if="data.carOnline === 'true'"
+          type="success"
+          size="default"
+          class="flex items-center"
+        >
+          <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
+          在线
+        </el-tag>
+
+        <el-tag
+          v-if="data.carOnline === 'false'"
+          type="danger"
+          size="default"
+          class="flex items-center"
+        >
+          <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
+          离线
+        </el-tag>
+      </el-form-item>
+      <el-form-item label="最后数据时间"> {{ data.lastInlineTime }} </el-form-item>
+      <el-form-item v-if="data.vehicle" label="车牌号码"> {{ data.vehicle }} </el-form-item>
+    </el-form>
+  </div>
+  <div class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow min-h-50">
+    <header class="font-medium text-center w-full">网关数采</header>
+    <div
+      v-loading="dimensionLoading"
+      element-loading-background="transparent"
+      class="w-full mt-4 grid grid-cols-5 gap-2 min-h-30"
+      id="dimension"
+    >
+      <button
+        v-for="item in gatewayDimensions"
+        :key="item.identifier"
+        class="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-8 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
+        :class="{ 'bg-blue-200': selectedDimension[item.name] }"
+        :disabled="disabledDimension(item.identifier).disabled"
+        @click="handleClickSpec(item.name)"
+      >
+        <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2 relative">
+          <!-- <i
+            v-show="disabledDimension(item.identifier).loading"
+            class="i-line-md:loading-loop size-5 absolute -left-6"
+          ></i> -->
+          {{ item.name }}
+        </span>
+        <!-- <span class="text-lg font-medium ms-a">{{ item.value }}</span> -->
+        <animated-count-to :value="item.value" will-change class="text-lg font-medium ms-a" />
+      </button>
+    </div>
+  </div>
+  <div
+    v-if="carDimensions.length"
+    class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow"
+  >
+    <header class="font-medium text-center w-full">中航北斗</header>
+    <div class="w-full mt-4 grid grid-cols-5 gap-2 min-h-30" id="dimension">
+      <button
+        v-for="item in carDimensions"
+        :key="item.identifier"
+        class="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-6 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
+        :class="{ 'bg-blue-200': selectedDimension[item.name] }"
+        :disabled="disabledDimension(item.identifier).disabled"
+        @click="handleClickSpec(item.name)"
+      >
+        <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2">
+          <!-- <i
+            v-show="disabledDimension(item.identifier).loading"
+            class="i-line-md:loading-loop size-5"
+          ></i> -->
+          {{ item.name }}
+        </span>
+        <span class="text-lg font-medium ms-a">{{ item.value }}</span>
+      </button>
+    </div>
+  </div>
+  <div class="w-full mt-4 bg-gradient-to-r from-blue-100 to-white min-h-175 rounded-lg p-6 shadow">
+    <header class="flex items-center justify-between">
+      <h3 class="flex items-center gap-2">
+        <div class="i-material-symbols:area-chart-outline-rounded text-sky size-6" text-sky></div>
+        数据趋势
+      </h3>
+      <div class="flex gap-4">
+        <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
+        <el-button size="default" @click="reset">重置</el-button>
+        <el-date-picker
+          v-model="selectedDate"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="datetimerange"
+          unlink-panels
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :shortcuts="rangeShortcuts"
+          size="default"
+          class="w-100!"
+          placement="bottom-end"
+          @change="handleDateChange"
+        />
+      </div>
+    </header>
+    <div class="flex h-160 mt-4">
+      <div class="flex gap-1">
+        <button
+          v-for="item of maxmin"
+          :key="item.name"
+          class="w-8 h-full flex flex-col items-center justify-between py-2 gap-1 rounded bg-transparent border-none"
+          @click="handleClickSpec(item.name)"
+        >
+          <span class="[writing-mode:sideways-lr]">{{ item.max }}</span>
+          <div class="flex-1 w-1" :style="{ backgroundColor: item.color }"></div>
+          <span class="[writing-mode:sideways-lr]">{{ item.name }}</span>
+          <div class="flex-1 w-1" :style="{ backgroundColor: item.color }"></div>
+          <span class="[writing-mode:sideways-lr]">{{ item.min }}</span>
+        </button>
+      </div>
+      <div class="flex flex-1">
+        <div
+          v-loading="chartLoading"
+          element-loading-background="transparent"
+          ref="chartRef"
+          class="flex-1 h-full"
+        >
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+:deep(.el-form-item) {
+  margin-bottom: 0;
+
+  .el-form-item__label {
+    margin-bottom: 0;
+  }
+
+  .el-form-item__content {
+    font-size: 1rem;
+    font-weight: 500;
+  }
+
+  &.online {
+    .el-form-item__content {
+      height: 2.5rem;
+
+      .el-tag__content {
+        display: flex;
+        align-items: center;
+        gap: 2px;
+      }
+    }
+  }
+}
+</style>

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

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

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

@@ -0,0 +1,693 @@
+<script setup lang="ts">
+import { Odometer, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
+import { IotDeviceApi } from '@/api/pms/device'
+import dayjs from 'dayjs'
+import { rangeShortcuts } from '@/utils/formatTime'
+import { IotStatApi, cancelAllRequests } from '@/api/pms/stat'
+
+import * as echarts from 'echarts'
+import { colors } from '@/utils/td-color'
+
+const { query } = useRoute()
+
+const data = ref({
+  deviceCode: query.code || '',
+  deviceName: query.name || '',
+  lastInlineTime: query.time || '',
+  ifInline: query.ifInline || '',
+  dept: query.dept || '',
+  vehicle: query.vehicle || '',
+  carOnline: query.carOnline || ''
+})
+
+interface Dimensions {
+  identifier: string
+  name: string
+  value: string
+  color?: string
+  response?: boolean
+}
+
+const dimensions = ref<Dimensions[]>([])
+const gatewayDimensions = ref<Dimensions[]>([])
+const carDimensions = ref<Dimensions[]>([])
+
+const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
+
+interface SelectedDimension {
+  [key: Dimensions['name']]: boolean
+}
+
+const selectedDimension = ref<SelectedDimension>({})
+
+const dimensionLoading = ref(false)
+
+const disabledDimension = computed(() => (identifier: string) => {
+  const response = dimensions.value.find((item) => item.identifier === identifier)?.response
+
+  return { disabled: disabledDimensions.value.includes(identifier) || response, loading: response }
+})
+
+async function loadDimensions() {
+  if (!query.id) return
+
+  dimensionLoading.value = true
+
+  const gateway = (((await IotDeviceApi.getIotDeviceTds(Number(query.id))) as any[]) ?? [])
+    .sort((a, b) => b.modelOrder - a.modelOrder)
+    .map((item) => ({
+      identifier: item.identifier,
+      name: item.modelName,
+      value: item.value
+    }))
+  const car = (((await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))) as any[]) ?? [])
+    .sort((a, b) => b.modelOrder - a.modelOrder)
+    .map((item) => ({
+      identifier: item.identifier,
+      name: item.modelName,
+      value: item.value
+    }))
+
+  dimensions.value = [...gateway, ...car]
+    .filter((item) => !disabledDimensions.value.includes(item.identifier))
+    .map((item, index) => ({
+      ...item,
+      color: colors[index]
+    }))
+
+  gatewayDimensions.value = gateway
+  carDimensions.value = car
+
+  selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
+
+  selectedDimension.value[dimensions.value[0].name] = true
+
+  dimensionLoading.value = false
+}
+
+async function updateDimensionValues() {
+  if (!query.id) return
+
+  try {
+    // 1. 并行获取最新数据
+    const [gatewayRes, carRes] = await Promise.all([
+      IotDeviceApi.getIotDeviceTds(Number(query.id)),
+      IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))
+    ])
+
+    // 2. 创建一个 Map 用于快速查找 (Identifier -> Value)
+    // 这样可以将复杂度从 O(N*M) 降低到 O(N)
+    const newValueMap = new Map<string, any>()
+
+    const addToMap = (data: any[]) => {
+      if (!data) return
+      data.forEach((item) => {
+        if (item.identifier) {
+          newValueMap.set(item.identifier, item.value)
+        }
+      })
+    }
+
+    addToMap(gatewayRes as any[])
+    addToMap(carRes as any[])
+
+    // 3. 更新 dimensions.value (保留了之前的 color 和其他属性)
+    dimensions.value.forEach((item) => {
+      if (newValueMap.has(item.identifier)) {
+        item.value = newValueMap.get(item.identifier)
+      }
+    })
+
+    // 4. 如果还需要同步更新 gatewayDimensions 和 carDimensions
+    // (假设这些是引用类型,如果它们引用的是同一个对象,上面更新 dimensions 时可能已经同步了。
+    // 如果它们是独立的对象数组,则需要显式更新)
+
+    // 更新 Gateway 原始列表
+    gatewayDimensions.value.forEach((item) => {
+      if (newValueMap.has(item.identifier)) {
+        item.value = newValueMap.get(item.identifier)
+      }
+    })
+
+    // 更新 Car 原始列表
+    carDimensions.value.forEach((item) => {
+      if (newValueMap.has(item.identifier)) {
+        item.value = newValueMap.get(item.identifier)
+      }
+    })
+  } catch (error) {
+    console.error('Failed to update dimension values:', error)
+  }
+}
+
+const selectedDate = ref<string[]>([
+  ...rangeShortcuts[0].value().map((v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'))
+])
+
+interface ChartData {
+  [key: Dimensions['name']]: { ts: number; value: number }[]
+}
+
+const chartData = ref<ChartData>({})
+
+let intervalArr = ref<number[]>([])
+let maxInterval = ref(0)
+let minInterval = ref(0)
+
+const chartRef = ref<HTMLDivElement | null>(null)
+let chart: echarts.ECharts | null = null
+
+// const genderIntervalArrDebounce = useDebounceFn(
+//   (init: boolean = false) => genderIntervalArr(init),
+//   300
+// )
+
+function genderIntervalArr(init: boolean = false) {
+  const values: number[] = []
+
+  for (const [key, value] of Object.entries(selectedDimension.value)) {
+    if (value) {
+      values.push(...(chartData.value[key]?.map((item) => item.value) ?? []))
+    }
+  }
+
+  const maxVal = values.length === 0 ? 10000 : Math.max(...values)
+  const minVal = values.length === 0 ? 0 : Math.min(...values) > 0 ? 0 : Math.min(...values)
+
+  const maxDigits = (Math.floor(maxVal) + '').length
+  const minDigits = minVal === 0 ? 0 : (Math.floor(Math.abs(minVal)) + '').length
+
+  const interval = Math.max(maxDigits, minDigits)
+
+  maxInterval.value = interval
+  minInterval.value = minDigits
+
+  intervalArr.value = [0]
+  for (let i = 1; i <= interval; i++) {
+    intervalArr.value.push(Math.pow(10, i))
+  }
+
+  if (!init) {
+    chart?.setOption({
+      yAxis: {
+        min: -minInterval.value,
+        max: maxInterval.value
+      }
+    })
+  }
+}
+
+function chartInit() {
+  if (!chart) return
+
+  chart.on('legendselectchanged', (params: any) => {
+    selectedDimension.value = params.selected
+  })
+
+  window.addEventListener('resize', () => {
+    if (chart) chart.resize()
+  })
+}
+
+function render() {
+  if (!chartRef.value) return
+
+  if (!chart) chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
+
+  chartInit()
+
+  genderIntervalArr(true)
+
+  chart.setOption({
+    grid: {
+      left: '6%',
+      top: '5%',
+      right: '6%',
+      bottom: '12%'
+    },
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'line'
+      },
+      formatter: (params) => {
+        let d = `${params[0].axisValueLabel}<br>`
+        const exist: string[] = []
+        params = params.filter((el) => {
+          if (exist.includes(el.seriesName)) return false
+          exist.push(el.seriesName)
+          return true
+        })
+        let item = params.map(
+          (el) => `<div class="flex items-center justify-between mt-1">
+            <span>${el.marker} ${el.seriesName}</span>
+            <span>${el.value[2]?.toFixed(2)}</span>
+          </div>`
+        )
+
+        return d + item.join('')
+      }
+    },
+    xAxis: {
+      type: 'time',
+      axisLabel: {
+        formatter: (v) => dayjs(v).format('YYYY-MM-DD\nHH:mm:ss'),
+        rotate: 0,
+        align: 'left'
+      }
+    },
+    dataZoom: [
+      { type: 'inside', xAxisIndex: 0 },
+      { type: 'slider', xAxisIndex: 0 }
+    ],
+    yAxis: {
+      type: 'value',
+      min: -minInterval.value,
+      max: maxInterval.value,
+      interval: 1,
+      axisLabel: {
+        formatter: (v) => {
+          const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
+
+          return num.toLocaleString()
+        }
+      },
+      show: false
+    },
+    legend: {
+      data: dimensions.value.map((item) => item.name),
+      selected: selectedDimension.value,
+      show: false
+    },
+    series: dimensions.value.map((item) => ({
+      name: item.name,
+      type: 'line',
+      smooth: true,
+      showSymbol: false,
+      color: item.color,
+      data: [] // 占位数组
+    }))
+  })
+}
+
+function mapData({ value, ts }) {
+  if (!value) return [ts, 0, 0]
+
+  const isPositive = value > 0
+  const absItem = Math.abs(value)
+
+  const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
+  const min_index = intervalArr.value.findIndex((v) => v === min_value)
+
+  const new_value =
+    (absItem - min_value) / (intervalArr.value[min_index + 1] - intervalArr.value[min_index]) +
+    min_index
+
+  return [ts, isPositive ? new_value : -new_value, value]
+}
+
+function updateSingleSeries(name: string) {
+  if (!chart) render()
+  if (!chart) return
+
+  const idx = dimensions.value.findIndex((item) => item.name === name)
+  if (idx === -1) return
+
+  const data = chartData.value[name].map((v) => mapData(v))
+
+  chart.setOption({
+    series: [{ name, data }]
+  })
+}
+
+const lastTsMap = ref<Record<Dimensions['name'], number>>({})
+
+async function fetchIncrementData() {
+  for (const item of dimensions.value) {
+    const { identifier, name } = item
+
+    const lastTs = lastTsMap.value[name]
+    if (!lastTs) continue
+
+    item.response = true
+
+    IotStatApi.getDeviceInfoChart(
+      data.value.deviceCode,
+      identifier,
+      dayjs(lastTs).format('YYYY-MM-DD HH:mm:ss'),
+      dayjs().format('YYYY-MM-DD HH:mm:ss')
+    )
+      .then((res) => {
+        if (!res.length) return
+
+        const sorted = res
+          .sort((a, b) => a.ts - b.ts)
+          .map((item) => ({ ts: item.ts, value: item.value }))
+        // push 到本地
+        chartData.value[name].push(...sorted)
+        // 更新 lastTs
+        lastTsMap.value[identifier] = sorted.at(-1).ts
+
+        // 更新图表
+        updateSingleSeries(name)
+      })
+      .finally(() => {
+        item.response = false
+      })
+  }
+}
+
+const timer = ref<NodeJS.Timeout | null>(null)
+
+function startAutoFetch() {
+  timer.value = setInterval(() => {
+    updateDimensionValues()
+    fetchIncrementData()
+  }, 10000)
+}
+
+function stopAutoFetch() {
+  cancelAllRequests()
+  if (timer.value) clearInterval(timer.value)
+  timer.value = null
+}
+
+const chartLoading = ref(false)
+
+async function initLoadChartData(real_time: boolean = true) {
+  if (!dimensions.value.length) return
+
+  chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
+
+  chartLoading.value = true
+
+  dimensions.value = dimensions.value.map((item) => {
+    item.response = true
+    return item
+  })
+
+  for (const item of dimensions.value) {
+    const { identifier, name } = item
+    try {
+      const res = await IotStatApi.getDeviceInfoChart(
+        data.value.deviceCode,
+        identifier,
+        selectedDate.value[0],
+        selectedDate.value[1]
+      )
+
+      const sorted = res
+        .sort((a, b) => a.ts - b.ts)
+        .map((item) => ({ ts: item.ts, value: item.value }))
+
+      chartData.value[name] = sorted
+
+      lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
+
+      updateSingleSeries(name)
+
+      chartLoading.value = false
+
+      if (selectedDimension.value[name]) {
+        genderIntervalArr()
+      }
+    } finally {
+      item.response = false
+    }
+  }
+
+  if (real_time) startAutoFetch()
+}
+
+async function initfn(load: boolean = true, real_time: boolean = true) {
+  if (load) await loadDimensions()
+  render()
+  initLoadChartData(real_time)
+}
+
+onMounted(() => {
+  initfn()
+})
+
+function reset() {
+  cancelAllRequests().then(() => {
+    selectedDate.value = rangeShortcuts[0]
+      .value()
+      .map((v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'))
+
+    stopAutoFetch()
+    if (chart) chart.clear()
+    initfn(false)
+  })
+}
+
+function handleDateChange() {
+  cancelAllRequests().then(() => {
+    stopAutoFetch()
+    if (chart) chart.clear()
+    initfn(false, false)
+  })
+}
+
+function handleClickSpec(modelName: string) {
+  selectedDimension.value[modelName] = !selectedDimension.value[modelName]
+  chart?.setOption({
+    legend: {
+      selected: selectedDimension.value
+    }
+  })
+  chart?.resize()
+  genderIntervalArr()
+}
+
+const exportChart = () => {
+  if (!chart) return
+  let img = new Image()
+  img.src = chart.getDataURL({
+    type: 'png',
+    pixelRatio: 1,
+    backgroundColor: '#fff'
+  })
+
+  img.onload = function () {
+    let canvas = document.createElement('canvas')
+    canvas.width = img.width
+    canvas.height = img.height
+    let ctx = canvas.getContext('2d')
+    ctx?.drawImage(img, 0, 0)
+    let dataURL = canvas.toDataURL('image/png')
+
+    let a = document.createElement('a')
+
+    let event = new MouseEvent('click')
+
+    a.href = dataURL
+    a.download = `${data.value.deviceName}-设备监控-${dayjs().format('YYYY-MM-DD HH:mm:ss')}.png`
+    a.dispatchEvent(event)
+  }
+}
+
+const maxmin = computed(() => {
+  if (!dimensions.value.length) return []
+  return dimensions.value
+    .filter((v) => selectedDimension.value[v.name])
+    .map((v) => ({
+      name: v.name,
+      color: v.color,
+      max: Math.max(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2),
+      min: Math.min(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2)
+    }))
+})
+
+onUnmounted(() => {
+  stopAutoFetch()
+
+  window.removeEventListener('resize', () => {
+    if (chart) chart.resize()
+  })
+})
+</script>
+
+<template>
+  <div
+    class="w-full bg-gradient-to-r from-blue-100 to-white rounded-lg p-6 shadow"
+    id="td-device-info"
+  >
+    <h2 class="flex items-center gap-2">
+      <el-icon class="text-sky!"><Odometer /></el-icon> 设备基础信息
+    </h2>
+    <el-form size="large" label-position="top" class="mt-4 grid grid-cols-3 gap-4">
+      <el-form-item label="资产编码"> {{ data.deviceCode }} </el-form-item>
+      <el-form-item label="设备类别"> {{ data.deviceName }} </el-form-item>
+      <el-form-item label="所在部门"> {{ data.dept }} </el-form-item>
+      <el-form-item label="网关状态" class="online" type="plain">
+        <el-tag
+          v-if="data.ifInline === '3'"
+          type="success"
+          size="default"
+          class="flex items-center"
+        >
+          <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
+          在线
+        </el-tag>
+
+        <el-tag v-if="data.ifInline === '4'" type="danger" size="default" class="flex items-center">
+          <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
+          离线
+        </el-tag>
+      </el-form-item>
+      <el-form-item v-if="data.carOnline" label="中航北斗" class="online" type="plain">
+        <el-tag
+          v-if="data.carOnline === 'true'"
+          type="success"
+          size="default"
+          class="flex items-center"
+        >
+          <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
+          在线
+        </el-tag>
+
+        <el-tag
+          v-if="data.carOnline === 'false'"
+          type="danger"
+          size="default"
+          class="flex items-center"
+        >
+          <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
+          离线
+        </el-tag>
+      </el-form-item>
+      <el-form-item label="最后数据时间"> {{ data.lastInlineTime }} </el-form-item>
+      <el-form-item v-if="data.vehicle" label="车牌号码"> {{ data.vehicle }} </el-form-item>
+    </el-form>
+  </div>
+  <div class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow min-h-50">
+    <header class="font-medium text-center w-full">网关数采</header>
+    <div
+      v-loading="dimensionLoading"
+      element-loading-background="transparent"
+      class="w-full mt-4 grid grid-cols-4 gap-4 min-h-30"
+      id="dimension"
+    >
+      <button
+        v-for="item in gatewayDimensions"
+        :key="item.identifier"
+        class="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-8 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
+        :class="{ 'bg-blue-200': selectedDimension[item.name] }"
+        :disabled="disabledDimension(item.identifier).disabled"
+        @click="handleClickSpec(item.name)"
+      >
+        <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2 relative">
+          <!-- <i
+            v-show="disabledDimension(item.identifier).loading"
+            class="i-line-md:loading-loop size-5 absolute -left-6"
+          ></i> -->
+          {{ item.name }}
+        </span>
+        <span class="text-lg font-medium ms-a">{{ item.value }}</span>
+      </button>
+    </div>
+  </div>
+  <div
+    v-if="carDimensions.length"
+    class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow"
+  >
+    <header class="font-medium text-center w-full">中航北斗</header>
+    <div class="w-full mt-4 grid grid-cols-4 gap-4" id="dimension">
+      <button
+        v-for="item in carDimensions"
+        :key="item.identifier"
+        class="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-6 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
+        :class="{ 'bg-blue-200': selectedDimension[item.name] }"
+        :disabled="disabledDimension(item.identifier).disabled"
+        @click="handleClickSpec(item.name)"
+      >
+        <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2">
+          <!-- <i
+            v-show="disabledDimension(item.identifier).loading"
+            class="i-line-md:loading-loop size-5"
+          ></i> -->
+          {{ item.name }}
+        </span>
+        <span class="text-lg font-medium ms-a">{{ item.value }}</span>
+      </button>
+    </div>
+  </div>
+  <div class="w-full mt-4 bg-gradient-to-r from-blue-100 to-white min-h-175 rounded-lg p-6 shadow">
+    <header class="flex items-center justify-between">
+      <h3 class="flex items-center gap-2">
+        <div class="i-material-symbols:area-chart-outline-rounded text-sky size-6" text-sky></div>
+        数据趋势
+      </h3>
+      <div class="flex gap-4">
+        <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
+        <el-button size="default" @click="reset">重置</el-button>
+        <el-date-picker
+          v-model="selectedDate"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="datetimerange"
+          unlink-panels
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :shortcuts="rangeShortcuts"
+          size="default"
+          class="w-100!"
+          placement="bottom-end"
+          @change="handleDateChange"
+        />
+      </div>
+    </header>
+    <div class="flex h-160 mt-4">
+      <div class="flex gap-1">
+        <button
+          v-for="item of maxmin"
+          :key="item.name"
+          class="w-8 h-full flex flex-col items-center justify-between py-2 gap-1 rounded bg-transparent border-none"
+          @click="handleClickSpec(item.name)"
+        >
+          <span class="[writing-mode:sideways-lr]">{{ item.max }}</span>
+          <div class="flex-1 w-1" :style="{ backgroundColor: item.color }"></div>
+          <span class="[writing-mode:sideways-lr]">{{ item.name }}</span>
+          <div class="flex-1 w-1" :style="{ backgroundColor: item.color }"></div>
+          <span class="[writing-mode:sideways-lr]">{{ item.min }}</span>
+        </button>
+      </div>
+      <div class="flex flex-1">
+        <div
+          v-loading="chartLoading"
+          element-loading-background="transparent"
+          ref="chartRef"
+          class="flex-1 h-full"
+        >
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+:deep(.el-form-item) {
+  margin-bottom: 0;
+
+  .el-form-item__label {
+    margin-bottom: 0;
+  }
+
+  .el-form-item__content {
+    font-size: 1rem;
+    font-weight: 500;
+  }
+
+  &.online {
+    .el-form-item__content {
+      height: 2.5rem;
+
+      .el-tag__content {
+        display: flex;
+        align-items: center;
+        gap: 2px;
+      }
+    }
+  }
+}
+</style>

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

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

+ 17 - 19
src/views/pms/device/personlog/DevicePerson.vue

@@ -16,7 +16,11 @@
           :inline="true"
           label-width="68px"
         >
-          <el-form-item :label="t('devicePerson.deviceCode')" prop="deviceCode" style="margin-left: 20px">
+          <el-form-item
+            :label="t('devicePerson.deviceCode')"
+            prop="deviceCode"
+            style="margin-left: 20px"
+          >
             <el-input
               v-model="queryParams.deviceCode"
               :placeholder="t('devicePerson.codeHolder')"
@@ -35,7 +39,11 @@
             />
           </el-form-item>
 
-          <el-form-item :label="t('devicePerson.responsiblePerson')" prop="setFlag" label-width="140px">
+          <el-form-item
+            :label="t('devicePerson.responsiblePerson')"
+            prop="setFlag"
+            label-width="140px"
+          >
             <el-select
               v-model="queryParams.setFlag"
               :placeholder="t('devicePerson.choose')"
@@ -75,13 +83,8 @@
             >
               <Icon icon="ep:plus" class="mr-5px" /> {{ t('devicePerson.setUp') }}
             </el-button>
-            <el-button
-              type="success"
-              plain
-              @click="handleExport"
-              :loading="exportLoading"
-              v-hasPermi="['rq:iot-device:export']"
-            >
+            <!-- v-hasPermi="['rq:iot-device:export']" -->
+            <el-button type="success" plain @click="handleExport" :loading="exportLoading">
               <Icon icon="ep:download" class="mr-5px" /> 导出
             </el-button>
           </el-form-item>
@@ -131,7 +134,7 @@
   </el-row>
   <DevicePersonLogDrawer
     :model-value="drawerVisible"
-    @update:model-value="val => drawerVisible = val"
+    @update:model-value="(val) => (drawerVisible = val)"
     :device-id="currentDeviceId"
     ref="showDrawer"
   />
@@ -140,10 +143,9 @@
 <script setup lang="ts">
 import download from '@/utils/download'
 import { IotDeviceApi, IotDeviceVO } from '@/api/pms/device'
-import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
 import DeptTree from '@/views/system/user/DeptTree.vue'
 import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
-import DevicePersonLogDrawer from "@/views/pms/device/personlog/DevicePersonLogDrawer.vue";
+import DevicePersonLogDrawer from '@/views/pms/device/personlog/DevicePersonLogDrawer.vue'
 
 /** 设备台账 列表 */
 defineOptions({ name: 'IotDevicePerson' })
@@ -270,7 +272,7 @@ const resultOptions = computed(() => [
   {
     label: '否',
     value: 'N' // 空值会触发 clearable 效果
-  },
+  }
 ])
 
 const handleDetail = (id: number) => {
@@ -295,12 +297,8 @@ const handleView = async (deviceId: number) => {
 /** 导出按钮操作 */
 const handleExport = async () => {
   try {
-    // 导出的二次确认
-    await message.exportConfirm()
-    // 发起导出
-    exportLoading.value = true
-    const data = await IotDeviceApi.exportIotDevice(queryParams)
-    download.excel(data, '设备台账.xls')
+    const data = await IotDeviceApi.exportIotDevicePerson(queryParams)
+    download.excel(data, '设备责任人.xls')
   } catch {
   } finally {
     exportLoading.value = false

+ 24 - 20
src/views/pms/device/statuslog/DeviceStatus.vue

@@ -16,7 +16,11 @@
           :inline="true"
           label-width="68px"
         >
-          <el-form-item :label="t('devicePerson.deviceCode')" prop="deviceCode" style="margin-left: 25px">
+          <el-form-item
+            :label="t('devicePerson.deviceCode')"
+            prop="deviceCode"
+            style="margin-left: 25px"
+          >
             <el-input
               v-model="queryParams.deviceCode"
               :placeholder="t('devicePerson.codeHolder')"
@@ -49,7 +53,12 @@
               />
             </el-select>
           </el-form-item>
-          <el-form-item v-show="ifShow" :label="t('devicePerson.status')" label-width="85px" prop="deviceStatus">
+          <el-form-item
+            v-show="ifShow"
+            :label="t('devicePerson.status')"
+            label-width="85px"
+            prop="deviceStatus"
+          >
             <el-select
               v-model="queryParams.deviceStatus"
               :label="t('devicePerson.status')"
@@ -97,7 +106,8 @@
               {{ t('devicePerson.moreSearch') }}</el-button
             >
             <el-button v-if="ifShow" @click="moreQuery(false)" type="danger"
-              ><Icon icon="ep:search" class="mr-5px" /> {{ t('devicePerson.closeSearch') }}</el-button
+              ><Icon icon="ep:search" class="mr-5px" />
+              {{ t('devicePerson.closeSearch') }}</el-button
             >
             <el-button @click="handleQuery"
               ><Icon icon="ep:search" class="mr-5px" /> {{ t('devicePerson.search') }}</el-button
@@ -113,13 +123,8 @@
             >
               <Icon icon="ep:plus" class="mr-5px" /> {{ t('deviceStatus.setUp') }}
             </el-button>
-            <el-button
-              type="success"
-              plain
-              @click="handleExport"
-              :loading="exportLoading"
-              v-hasPermi="['rq:iot-device:export']"
-            >
+            <!-- v-hasPermi="['rq:iot-device:export']" -->
+            <el-button type="success" plain @click="handleExport" :loading="exportLoading">
               <Icon icon="ep:download" class="mr-5px" /> 导出
             </el-button>
           </el-form-item>
@@ -129,7 +134,7 @@
       <!-- 列表 -->
       <ContentWrap>
         <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
-          <el-table-column :label="t('monitor.serial')" width="70" align="center" >
+          <el-table-column :label="t('monitor.serial')" width="70" align="center">
             <template #default="scope">
               {{ scope.$index + 1 }}
             </template>
@@ -181,7 +186,7 @@
   </el-row>
   <DeviceStatusLogDrawer
     :model-value="drawerVisible"
-    @update:model-value="val => drawerVisible = val"
+    @update:model-value="(val) => (drawerVisible = val)"
     :device-id="currentDeviceId"
     ref="showDrawer"
   />
@@ -191,10 +196,9 @@
 import download from '@/utils/download'
 import { IotDeviceApi, IotDeviceVO } from '@/api/pms/device'
 import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
-import { dateFormatter } from '@/utils/formatTime'
 import DeptTree from '@/views/system/user/DeptTree.vue'
 import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
-import DeviceStatusLogDrawer from "@/views/pms/device/statuslog/DeviceStatusLogDrawer.vue";
+import DeviceStatusLogDrawer from '@/views/pms/device/statuslog/DeviceStatusLogDrawer.vue'
 
 /** 设备台账 列表 */
 defineOptions({ name: 'IotDeviceStatus' })
@@ -271,7 +275,7 @@ const resultOptions = computed(() => [
   {
     label: '否',
     value: 'N' // 空值会触发 clearable 效果
-  },
+  }
 ])
 
 const showDrawer = ref()
@@ -344,12 +348,12 @@ const handleDetail = (id: number) => {
 /** 导出按钮操作 */
 const handleExport = async () => {
   try {
-    // 导出的二次确认
-    await message.exportConfirm()
-    // 发起导出
+    // // 导出的二次确认
+    // await message.exportConfirm()
+    // // 发起导出
     exportLoading.value = true
-    const data = await IotDeviceApi.exportIotDevice(queryParams)
-    download.excel(data, '设备台账.xls')
+    const data = await IotDeviceApi.exportIotDeviceAdjust(queryParams)
+    download.excel(data, '设备状态调整.xls')
   } catch {
   } finally {
     exportLoading.value = false

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

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

+ 7 - 6
src/views/pms/iotrddailyreport/FillDailyReportForm.vue

@@ -197,7 +197,7 @@
                 v-model="formData.platformId"
                 placeholder="请选择平台井"
                 style="width: 100%"
-                :disabled="isReadonlyMode && formData.auditStatus !== 20"
+                :disabled="isReadonlyMode && query.istime !== 'true'"
               >
                 <el-option
                   v-for="platform in platformOptions"
@@ -458,7 +458,7 @@
               v-model="formData[field.key]"
               :controls="false"
               align="left"
-              :disabled="isReadonlyMode && formData.auditStatus !== 20"
+              :disabled="query.istime !== 'true'"
             />
           </el-form-item>
         </div>
@@ -469,7 +469,7 @@
               <el-input
                 v-model="formData.otherNptReason"
                 placeholder="请输入其他非生产原因"
-                :disabled="isReadonlyMode && formData.auditStatus !== 20"
+                :disabled="query.istime !== 'true'"
               />
             </el-form-item>
           </el-col>
@@ -797,7 +797,7 @@ const { t } = useI18n()
 const message = useMessage()
 const { delView } = useTagsViewStore()
 const { push, currentRoute } = useRouter()
-const { params } = useRoute()
+const { params, query } = useRoute()
 const userStore = useUserStore()
 
 /** 填报日报 表单 */
@@ -1098,7 +1098,7 @@ const formData = ref<any>({
 
 // 添加上传成功处理函数
 const handleUploadSuccess = (result: any) => {
-  console.log('上传成功:', result)
+  console.log('上传成功', result)
 
   try {
     // 检查响应是否成功
@@ -1601,7 +1601,8 @@ const submitForm = async () => {
       customFuel: fuel.customFuel ? parseFloat(fuel.customFuel) : null
     })), */
     // 确保当日油耗是数字格式
-    dailyFuel: formData.value.dailyFuel ? parseFloat(formData.value.dailyFuel) : 0
+    dailyFuel: formData.value.dailyFuel ? parseFloat(formData.value.dailyFuel) : 0,
+    nonProduct: query.istime === 'true' ? 'Y' : ''
   }
 
   console.log('baseSubmitData:', baseSubmitData)

+ 28 - 5
src/views/pms/iotrddailyreport/fillDailyReport.vue

@@ -60,9 +60,9 @@
             >
               <Icon icon="ep:plus" class="mr-5px" /> 新增
             </el-button>
-            <el-button type="success" plain @click="handleExport" :loading="exportLoading">
+            <!-- <el-button type="success" plain @click="handleExport" :loading="exportLoading">
               <Icon icon="ep:download" class="mr-5px" /> 导出
-            </el-button>
+            </el-button> -->
           </el-form-item>
         </el-form>
       </ContentWrap>
@@ -100,7 +100,7 @@
               <el-button
                 link
                 type="warning"
-                @click="openForm('fill', scope.row.id)"
+                @click="openForm('fill', scope.row.id, 'true')"
                 v-hasPermi="['pms:iot-rd-daily-report:non-productive']"
                 v-if="scope.row.auditStatus === 20"
               >
@@ -120,9 +120,32 @@
               <dict-tag :type="DICT_TYPE.OPERATION_FILL_ORDER_STATUS" :value="scope.row.status" />
             </template>
           </el-table-column>
+          <el-table-column label="审批状态" align="center" prop="auditStatus" :min-width="84">
+            <template #default="scope">
+              <el-tag v-if="scope.row.auditStatus === 0" type="info">
+                {{ '待提交' }}
+              </el-tag>
+              <el-tag v-else-if="scope.row.auditStatus === 10">
+                {{ '待审批' }}
+              </el-tag>
+              <el-tag v-else-if="scope.row.auditStatus === 20" type="success">
+                {{ '审批通过' }}
+              </el-tag>
+              <el-tag v-else-if="scope.row.auditStatus === 30" type="danger">
+                {{ '审批拒绝' }}
+              </el-tag>
+            </template>
+          </el-table-column>
           <el-table-column label="施工队伍" align="center" prop="deptName" />
           <el-table-column label="项目" align="center" prop="contractName" />
           <el-table-column label="任务" align="center" prop="taskName" />
+          <el-table-column label="非生产时间" align="center" prop="nonProductFlag">
+            <template #default="scope">
+              <el-tag :type="scope.row.nonProductFlag ? 'success' : 'danger'">
+                {{ scope.row.nonProductFlag ? '已填写' : '未填写' }}
+              </el-tag>
+            </template>
+          </el-table-column>
           <el-table-column label="带班干部" align="center" prop="responsiblePersonNames" />
           <el-table-column label="填报人" align="center" prop="submitterNames" />
           <!--
@@ -358,8 +381,8 @@ const resetQuery = () => {
 
 /** 添加/修改操作 */
 const formRef = ref()
-const openForm = (type: string, id?: number) => {
-  push({ name: 'FillDailyReportForm', params: { id: id, mode: 'fill' } })
+const openForm = (type: string, id?: number, istime: string = 'false') => {
+  push({ name: 'FillDailyReportForm', params: { id: id, mode: 'fill' }, query: { istime: istime } })
 }
 
 /** 删除按钮操作 */

+ 31 - 1
src/views/pms/iotrddailyreport/index.vue

@@ -223,6 +223,14 @@
             :min-width="columnWidths.faultDowntime.width"
             resizable
           />
+          <el-table-column label="非生产时间" align="center" prop="nonProductFlag" :min-width="80">
+            <template #default="scope">
+              <el-tag :type="scope.row.nonProductFlag ? 'success' : 'danger'">
+                {{ scope.row.nonProductFlag ? '已填写' : '未填写' }}
+              </el-tag>
+            </template>
+          </el-table-column>
+
           <el-table-column
             label="项目"
             align="center"
@@ -237,6 +245,22 @@
             :min-width="columnWidths.timeRange.width"
             resizable
           />
+          <el-table-column label="审批状态" align="center" prop="auditStatus" :min-width="84">
+            <template #default="scope">
+              <el-tag v-if="scope.row.auditStatus === 0" type="info">
+                {{ '待提交' }}
+              </el-tag>
+              <el-tag v-else-if="scope.row.auditStatus === 10">
+                {{ '待审批' }}
+              </el-tag>
+              <el-tag v-else-if="scope.row.auditStatus === 20" type="success">
+                {{ '审批通过' }}
+              </el-tag>
+              <el-tag v-else-if="scope.row.auditStatus === 30" type="danger">
+                {{ '审批拒绝' }}
+              </el-tag>
+            </template>
+          </el-table-column>
 
           <el-table-column label="操作" align="center" min-width="120px" fixed="right">
             <template #default="scope">
@@ -286,6 +310,7 @@ import { useDebounceFn } from '@vueuse/core'
 
 import dayjs from 'dayjs'
 import quarterOfYear from 'dayjs/plugin/quarterOfYear'
+import download from '@/utils/download'
 
 dayjs.extend(quarterOfYear)
 
@@ -688,7 +713,12 @@ const handleDelete = async (id: number) => {
 }
 
 const exportLoading = ref(false)
-const handleExport = async () => {}
+
+const handleExport = async () => {
+  const res = await IotRdDailyReportApi.exportIotRdDailyReportDetails(queryParams)
+
+  download.excel(res, '瑞都日报明细.xlsx')
+}
 
 // 声明 ResizeObserver 实例
 let resizeObserver: ResizeObserver | null = null

+ 29 - 3
src/views/pms/iotrhdailyreport/rh-form.vue

@@ -1,5 +1,6 @@
 <script lang="ts" setup generic="T">
 import { IotRhDailyReportApi } from '@/api/pms/iotrhdailyreport'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
 import { FormInstance, FormRules } from 'element-plus'
 import { computed, reactive, ref, watch, nextTick } from 'vue'
 
@@ -43,6 +44,7 @@ interface FormOriginal {
   dailyWaterInjection: number
   dailyInjectGasTime: number
   dailyInjectWaterTime: number
+  constructionStatus: string
 
   // 11个非生产时间字段
   repairTime: number
@@ -84,6 +86,7 @@ const FORM_KEYS: (keyof FormOriginal)[] = [
   'dailyWaterInjection',
   'dailyInjectGasTime',
   'dailyInjectWaterTime',
+  'constructionStatus',
   'productionStatus',
   'remark',
   'relocationDays',
@@ -195,7 +198,9 @@ const sumNonProdTimes = () => {
 // 24小时平衡校验器
 const validateTotalTime =
   (isNon: boolean = false) =>
-  (_rule: any, _value: any, callback: any) => {
+  (rule: any, _value: any, callback: any) => {
+    const field = rule.field
+
     const gasTime = form.value.dailyInjectGasTime || 0
     const waterTime = form.value.dailyInjectWaterTime || 0
     const nonProdSum = sumNonProdTimes()
@@ -212,8 +217,13 @@ const validateTotalTime =
     }
 
     if (Math.abs(total - 24) > 0.01) {
-      if (!isNon) callback(new Error(msg))
-      else callback(new Error())
+      if (!isNon) {
+        if (field === 'dailyInjectWaterTime' && waterTime === 0) {
+          callback()
+        } else if (field === 'dailyInjectGasTime' && gasTime === 0 && waterTime !== 0) {
+          callback()
+        } else callback(new Error(msg))
+      } else callback(new Error())
     } else {
       callback()
     }
@@ -234,6 +244,7 @@ const rules = reactive<FormRules>({
   dailyWaterInjection: [
     { required: true, message: '请输入当日注水量', trigger: ['blur', 'change'] }
   ],
+  constructionStatus: [{ required: true, message: '请输入施工状态', trigger: ['blur', 'change'] }],
   productionStatus: [{ required: true, message: '请输入生产动态', trigger: ['blur', 'change'] }],
 
   // 生产时间绑定校验
@@ -401,6 +412,21 @@ const handleAudit = async (auditStatus: 20 | 30) => {
             :class="{ 'warning-input': transitTime.original > 1.2 }"
           />
         </el-form-item>
+        <el-form-item label="施工状态" prop="constructionStatus">
+          <el-select
+            v-model="form.constructionStatus"
+            placeholder="请选择施工状态"
+            :disabled="isMainFieldDisabled"
+            clearable
+          >
+            <el-option
+              v-for="(dict, index) in getStrDictOptions(DICT_TYPE.PMS_PROJECT_TASK_SCHEDULE)"
+              :key="index"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
         <el-form-item label="当日注气量(方)" prop="dailyGasInjection">
           <el-input-number
             class="!w-full"

+ 3 - 3
src/views/pms/iotrydailyreport/summary.vue

@@ -227,9 +227,9 @@ const xAxisData = ref<string[]>([])
 
 const legend = ref<string[][]>([
   // ['累计油耗 (万升)', 'cumulativeFuelConsumption'],
-  ['累计油耗 (升)', 'cumulativeFuelConsumption'],
-  ['累计进尺 (M)', 'cumulativeFootage'],
-  ['累计用电量 (KWh)', 'cumulativePowerConsumption'],
+  ['油耗 (升)', 'cumulativeFuelConsumption'],
+  ['进尺 (M)', 'cumulativeFootage'],
+  ['用电量 (KWh)', 'cumulativePowerConsumption'],
   // ['累计用电量 (MWh)', 'cumulativePowerConsumption'],
   ['平均时效 (%)', 'transitTime']
 ])

+ 4 - 4
src/views/pms/iotrydailyreport/xsummary.vue

@@ -232,12 +232,12 @@ let chart: echarts.ECharts | null = null
 const xAxisData = ref<string[]>([])
 
 const legend = ref<string[][]>([
-  ['累计施工井数 (个)', 'cumulativeConstructWells'],
-  ['累计完工井数 (个)', 'cumulativeCompletedWells'],
+  ['施工井数 (个)', 'cumulativeConstructWells'],
+  ['完工井数 (个)', 'cumulativeCompletedWells'],
   // ['累计油耗 (万升)', 'cumulativeFuelConsumption'],
-  ['累计油耗 (升)', 'cumulativeFuelConsumption'],
+  ['油耗 (升)', 'cumulativeFuelConsumption'],
   // ['累计用电量 (MWh)', 'cumulativePowerConsumption'],
-  ['累计用电量 (KWh)', 'cumulativePowerConsumption'],
+  ['用电量 (KWh)', 'cumulativePowerConsumption'],
   ['平均时效 (%)', 'transitTime']
 ])
 

+ 154 - 109
src/views/pms/stat/rdkb.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="page-container">
+  <div class="min-h-screen p-4 bg-[#3a6fa3] flex flex-col">
     <el-row :gutter="16" class="summary">
       <!-- 原有的统计卡片部分保持不变 -->
       <el-col v-loading="loading" :sm="3" :xs="24">
@@ -96,7 +96,7 @@
               gap: 6px;
             "
           >
-            <div ref="statusChartRef" style="width: 100%; max-width: 200px; height: 170px"></div>
+            <div ref="statusChartRef" style="width: 100%; height: 170px; max-width: 200px"></div>
             <div class="text-[12px] h-[100px] w-[90%] flex flex-col justify-between items-center">
               <div v-for="item in legendData" :key="item.name" class="flex">
                 <div class="flex items-center gap-1">
@@ -123,25 +123,23 @@
           </template>
           <div
             style="
-              min-height: 295px;
               display: flex;
+              min-height: 295px;
               flex-direction: column;
               align-items: center;
               gap: 8px;
             "
           >
-            <div ref="domesticChartRef" style="width: 100%; max-width: 420px; height: 200px"></div>
+            <div ref="domesticChartRef" style="width: 100%; height: 200px; max-width: 420px"></div>
             <div
               class="domestic-legend"
               style="
+                display: flex;
                 width: 100%;
                 max-width: 520px;
-                display: flex;
+                font-size: 12px;
                 flex-wrap: wrap;
-
                 gap: 1px;
-
-                font-size: 12px;
               "
             >
               <div
@@ -163,12 +161,12 @@
                 <span
                   class="legend-name"
                   style="
-                    color: #fff;
+                    display: inline-block;
                     max-width: 150px;
                     overflow: hidden;
+                    color: #fff;
                     text-overflow: ellipsis;
                     white-space: nowrap;
-                    display: inline-block;
                   "
                   >{{ item.dept }}</span
                 >
@@ -178,7 +176,8 @@
         </el-card>
       </el-col>
       <el-col :span="8" :xs="24">
-        <el-card class="chart-card" shadow="never">
+        <WorkloadChart />
+        <!-- <el-card class="chart-card" shadow="never">
           <template #header>
             <div class="flex items-center justify-between">
               <span class="text-base font-medium" style="color: #b6c8da">{{
@@ -187,14 +186,22 @@
             </div>
           </template>
           <div ref="qxRef" class="h-[290px]"></div>
-        </el-card>
+        </el-card> -->
+      </el-col>
+    </el-row>
+    <el-row :gutter="16" class="mb-4">
+      <el-col class="mb-4" :span="12" :xs="24">
+        <AvailabilityChart />
+      </el-col>
+      <el-col class="mb-4" :span="12" :xs="24">
+        <ExceptionChart />
       </el-col>
     </el-row>
-
     <el-row :gutter="16" class="mb-4">
-      <!-- 备件更换情况部分保持不变 -->
       <el-col :span="12" :xs="24">
-        <el-card class="chart-card" shadow="never">
+        <UtilizationChart />
+      </el-col>
+      <!-- <el-card class="chart-card" shadow="never">
           <template #header>
             <div style="display: flex; flex-direction: row; justify-content: space-between">
               <span class="text-base font-medium" style="color: #b6c8da">{{
@@ -214,37 +221,37 @@
               </div>
             </div>
           </template>
-          <!-- 添加两个卡片 -->
-          <!--          <div class="flex justify-between mb-2">-->
-          <!--            <el-card class="stat-card">-->
-          <!--              <div class="flex flex-row justify-evenly">-->
-          <!--                <div>-->
-          <!--                  <Icon icon="fa-solid:award" size="30" color="blue" />-->
-          <!--                </div>-->
-          <!--                <div class="flex flex-col items-center">-->
-          <!--                  <span class="text-sm " style="color: #101010">{{t('stat.spareCount')}}</span>-->
-          <!--                  <span class="text-lg font-bold">{{ totalMaterialCount }}</span>-->
-          <!--                </div>-->
-          <!--              </div>-->
-          <!--            </el-card>-->
-          <!--            <el-card class="stat-card">-->
-          <!--              <div class="flex flex-row justify-evenly">-->
-          <!--                <div>-->
-          <!--                  <Icon icon="fa-solid:yen-sign" size="30" color="orange" />-->
-          <!--                </div>-->
-          <!--                <div class="flex flex-col items-center">-->
-          <!--                  <span class="text-sm " style="color: #101010">{{t('stat.spareAmount')}}</span>-->
-          <!--                  <span class="text-lg font-bold">{{ totalMaterialCost }}</span>-->
-          <!--                </div>-->
-          <!--              </div>-->
-          <!--            </el-card>-->
-          <!--          </div>-->
-          <!--          <div ref="sparePartRef" class="h-[330px]"></div>-->
+
           <div class="table-container">
             <div ref="utilizationRef" style="width: 100%; height: 360px"></div>
           </div>
-        </el-card>
-      </el-col>
+        </el-card> -->
+      <!-- 添加两个卡片 -->
+      <!--          <div class="flex justify-between mb-2">-->
+      <!--            <el-card class="stat-card">-->
+      <!--              <div class="flex flex-row justify-evenly">-->
+      <!--                <div>-->
+      <!--                  <Icon icon="fa-solid:award" size="30" color="blue" />-->
+      <!--                </div>-->
+      <!--                <div class="flex flex-col items-center">-->
+      <!--                  <span class="text-sm " style="color: #101010">{{t('stat.spareCount')}}</span>-->
+      <!--                  <span class="text-lg font-bold">{{ totalMaterialCount }}</span>-->
+      <!--                </div>-->
+      <!--              </div>-->
+      <!--            </el-card>-->
+      <!--            <el-card class="stat-card">-->
+      <!--              <div class="flex flex-row justify-evenly">-->
+      <!--                <div>-->
+      <!--                  <Icon icon="fa-solid:yen-sign" size="30" color="orange" />-->
+      <!--                </div>-->
+      <!--                <div class="flex flex-col items-center">-->
+      <!--                  <span class="text-sm " style="color: #101010">{{t('stat.spareAmount')}}</span>-->
+      <!--                  <span class="text-lg font-bold">{{ totalMaterialCost }}</span>-->
+      <!--                </div>-->
+      <!--              </div>-->
+      <!--            </el-card>-->
+      <!--          </div>-->
+      <!--          <div ref="sparePartRef" class="h-[330px]"></div>-->
       <!-- 月度工作量表 -->
       <el-col :span="12" :xs="24">
         <el-card class="chart-card" shadow="never">
@@ -353,6 +360,12 @@
       </el-col>
       <!-- 月度工作量表结束 -->
     </el-row>
+    <ConstructionBriefing />
+    <!-- <el-row :gutter="16" class="mb-4">
+      <el-col :span="24" :xs="24">
+
+      </el-col>
+    </el-row> -->
   </div>
   <el-dialog
     v-model="teamDialogVisible"
@@ -511,6 +524,11 @@ import { CanvasRenderer } from 'echarts/renderers'
 import { IotStatApi } from '@/api/pms/stat'
 import { ref, onMounted, computed, watch, nextTick, reactive } from 'vue'
 import { useLocaleStore } from '@/store/modules/locale'
+import WorkloadChart from './rdkb/workload.vue'
+import UtilizationChart from './rdkb/utilization.vue'
+import AvailabilityChart from './rdkb/availability.vue'
+import ExceptionChart from './rdkb/exception.vue'
+import ConstructionBriefing from './rdkb/constructionBriefing.vue'
 
 /** 会员统计 */
 defineOptions({ name: 'IotRdStat' })
@@ -640,6 +658,14 @@ const fill = ref({
   filledCount: undefined,
   unfilledCount: undefined
 })
+const abnormalDevice = ref({
+  total: undefined,
+  today: undefined
+})
+const outliers = ref({
+  total: undefined,
+  today: undefined
+})
 const inspect = ref({
   finished: 0,
   todo: 0
@@ -1458,27 +1484,27 @@ const projectDataRowClick = (row, column, event) => {
 const domesticData = ref<any[]>([
   {
     index: 1,
-    dept: '公',
+    dept: '公',
     count: 13,
     orig_value: 1506.88,
     net_value: 559.95,
-    orig_ratio: '3%'
+    orig_ratio: '2.77%'
   },
   {
     index: 2,
     dept: '新疆项目部',
     count: 58,
     orig_value: 5118.1,
-    net_value: 1182.91,
-    orig_ratio: '11%'
+    net_value: 1182.49,
+    orig_ratio: '9.40%'
   },
   {
     index: 3,
     dept: '青海项目',
     count: 33,
-    orig_value: 14082.33,
-    net_value: 736.94,
-    orig_ratio: '29%'
+    orig_value: 7004.23,
+    net_value: 1758.8,
+    orig_ratio: '12.86%'
   },
   {
     index: 4,
@@ -1486,55 +1512,55 @@ const domesticData = ref<any[]>([
     count: 49,
     orig_value: 5273.54,
     net_value: 683.58,
-    orig_ratio: '11%'
+    orig_ratio: '9.68%'
   },
   {
     index: 5,
     dept: '西南连油项目部',
     count: 24,
-    orig_value: 4070.12,
-    net_value: 743.99,
-    orig_ratio: '8%'
+    orig_value: 4059.36,
+    net_value: 743.24,
+    orig_ratio: '7.45%'
   },
   {
     index: 6,
     dept: '西南压裂项目部',
     count: 47,
-    orig_value: 14602.44,
-    net_value: 6031.39,
-    orig_ratio: '30'
+    orig_value: 14591.62,
+    net_value: 6030.84,
+    orig_ratio: '26.79%'
   },
   {
     index: 7,
     dept: '伊拉克 哈法亚连油',
     count: 120,
     orig_value: 694.78,
-    net_value: 94.44,
-    orig_ratio: '1%'
+    net_value: 91.26,
+    orig_ratio: '9.07%'
   },
   {
     index: 8,
     dept: '伊拉克 哈法亚压裂',
     count: 132,
     orig_value: 1008.92,
-    net_value: 587.07,
-    orig_ratio: '2%'
+    net_value: 575.91,
+    orig_ratio: '13.17%%'
   },
   {
     index: 9,
     dept: '伊拉克 B9增产',
     count: 27,
     orig_value: 304.72,
-    net_value: 128.63,
-    orig_ratio: '1%'
+    net_value: 124.1,
+    orig_ratio: '3.98%'
   },
   {
     index: 10,
     dept: '利比亚连油8队',
     count: 22,
     orig_value: 2025.52,
-    net_value: 1961.69,
-    orig_ratio: '4%'
+    net_value: 1731.79,
+    orig_ratio: '4.85%'
   }
 ])
 
@@ -1744,11 +1770,29 @@ onMounted(async () => {
 })
 </script>
 <style lang="scss" scoped>
-/*最外层透明*/
+@media (width <= 768px) {
+  .page-container {
+    padding: 10px;
+  }
+}
+
+@media (width <= 520px) {
+  .status-legend-item {
+    min-width: 100%;
+  }
+
+  .status-legend {
+    justify-content: flex-start;
+    max-height: none;
+    overflow: visible;
+  }
+}
+
 ::v-deep .el-table,
 ::v-deep .el-table__expanded-cell {
   background-color: transparent !important;
 }
+
 /* 表格内背景颜色 */
 
 ::v-deep .el-table tr,
@@ -1769,13 +1813,15 @@ onMounted(async () => {
     margin-bottom: 1rem;
   }
 }
+
 .stat-card {
   width: 48%;
 }
+
 .page-container {
-  background-color: #3a6fa3;
   min-height: 100vh;
   padding: 20px;
+  background-color: #3a6fa3;
 }
 
 .summary {
@@ -1783,32 +1829,32 @@ onMounted(async () => {
 }
 
 ::v-deep .chart-card {
-  background-color: rgba(0, 0, 0, 0.3);
+  background-color: rgb(0 0 0 / 30%);
+  border: none;
   border-radius: 8px;
-  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
+  box-shadow: 0 2px 12px rgb(0 0 0 / 50%);
   transition: all 0.3s ease;
-  border: none;
 
   &:hover {
-    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
+    box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
   }
 }
 
 // 安全生产天数卡片样式
 .safety-days-card {
   .safety-days-content {
+    position: relative;
     display: flex;
+    height: 150px;
     flex-direction: column;
     align-items: center;
     justify-content: center;
-    height: 150px;
-    position: relative;
 
     .days-number {
       font-size: 58px;
       font-weight: bold;
-      color: darkorange;
       line-height: 1;
+      color: darkorange;
       transition: all 0.3s ease;
     }
 
@@ -1817,36 +1863,32 @@ onMounted(async () => {
     }
 
     .days-label {
+      margin-top: 8px;
       font-size: 20px;
       color: white;
-      margin-top: 8px;
     }
 
     .safety-desc {
-      font-size: 14px;
-      color: #999;
       position: absolute;
       bottom: 10px;
-      text-align: center;
       width: 90%;
+      font-size: 14px;
+      color: #999;
+      text-align: center;
     }
   }
 }
 
-@media (max-width: 768px) {
-  .page-container {
-    padding: 10px;
-  }
-}
 ::v-deep .el-card__header {
-  border-bottom: none !important;
   padding-bottom: 0;
+  border-bottom: none !important;
 }
+
 .table-container {
-  padding: 16px;
   height: 420px;
-  box-sizing: border-box;
+  padding: 16px;
   overflow: auto;
+  box-sizing: border-box;
 
   // 滚动条样式优化
   &::-webkit-scrollbar {
@@ -1855,7 +1897,7 @@ onMounted(async () => {
   }
 
   &::-webkit-scrollbar-thumb {
-    background-color: rgba(255, 255, 255, 0.2);
+    background-color: rgb(255 255 255 / 20%);
     border-radius: 3px;
   }
 
@@ -1866,63 +1908,71 @@ onMounted(async () => {
 
 // 修复表格hover样式
 ::v-deep .el-table__row:hover > td {
-  background-color: rgba(255, 255, 255, 0.05) !important;
+  background-color: rgb(255 255 255 / 5%) !important;
 }
+
 .custom-scroll-dialog {
   /* 可选:限制对话框整体最大高度(避免超出屏幕) */
   max-height: 90vh;
   overflow: hidden; /* 隐藏整体溢出,避免出现双重滚动条 */
 }
+
 /* 滚动内容容器:核心样式 */
 .dialog-scroll-content {
   max-height: 60vh; /* 固定最大高度(可根据需求调整,如500px) */
-  overflow-y: auto; /* 垂直方向溢出时显示滚动条 */
   padding-right: 8px; /* 避免滚动条遮挡内容(可选) */
+  overflow-y: auto; /* 垂直方向溢出时显示滚动条 */
 }
 
 /* 优化滚动条样式(可选,提升UI体验) */
 .dialog-scroll-content::-webkit-scrollbar {
   width: 6px; /* 滚动条宽度 */
 }
+
 .dialog-scroll-content::-webkit-scrollbar-thumb {
   background-color: #e5e7eb; /* 滚动条滑块颜色 */
   border-radius: 3px; /* 滚动条圆角 */
 }
+
 .dialog-scroll-content::-webkit-scrollbar-thumb:hover {
   background-color: #d1d5db; /*  hover时滑块颜色 */
 }
+
 .custom-table :deep .el-table__row {
   height: 50px !important; /* 高度根据需求调整 */
 }
 
 /* 设备状态图例自适应样式 */
 .status-legend {
-  width: 100%;
   display: flex;
+  width: 100%;
+  max-height: 90px; /* 限制高度,超出显示滚动 */
+  padding: 6px 0;
+  overflow-y: auto;
+  box-sizing: border-box;
   flex-wrap: wrap;
   justify-content: center;
   gap: 8px;
-  padding: 6px 0;
-  box-sizing: border-box;
-  max-height: 90px; /* 限制高度,超出显示滚动 */
-  overflow-y: auto;
 }
+
 .status-legend-item {
   display: flex;
+  max-width: 100%;
+  min-width: 120px;
+  padding: 6px 10px;
+  box-sizing: border-box;
   align-items: center;
   justify-content: space-between;
   gap: 12px;
-  padding: 6px 10px;
-  min-width: 120px;
-  max-width: 100%;
-  box-sizing: border-box;
 }
+
 .status-legend-left {
   display: flex;
   align-items: center;
   gap: 8px;
   min-width: 0;
 }
+
 .status-legend-color {
   display: inline-block;
   width: 12px;
@@ -1930,35 +1980,30 @@ onMounted(async () => {
   border-radius: 50%;
   flex: 0 0 12px;
 }
+
 .status-legend-name {
   max-width: calc(100% - 60px);
   overflow: hidden;
+  color: #fff;
   text-overflow: ellipsis;
   white-space: nowrap;
-  color: #fff;
 }
+
 .status-legend-right {
   display: flex;
   align-items: center;
   gap: 8px;
   flex-shrink: 0;
 }
+
 .status-legend-value {
   font-weight: 700;
   color: #fff;
 }
+
 .status-legend-percent {
   color: #fff;
 }
 
-@media (max-width: 520px) {
-  .status-legend-item {
-    min-width: 100%;
-  }
-  .status-legend {
-    justify-content: flex-start;
-    max-height: none;
-    overflow: visible;
-  }
-}
+/* 最外层透明 */
 </style>

+ 138 - 0
src/views/pms/stat/rdkb/availability.vue

@@ -0,0 +1,138 @@
+<script lang="ts" setup>
+import { ref, onMounted } from 'vue'
+import { IotStatApi } from '@/api/pms/stat'
+
+// 定义数据接口结构
+interface DeviceStat {
+  percent: number
+  total: number
+}
+
+interface ApiData {
+  a: DeviceStat
+  b: DeviceStat
+  c: DeviceStat
+}
+
+// 响应式数据,设置初始默认值防止页面报错
+const stats = ref<ApiData>({
+  a: { percent: 0, total: 0 },
+  b: { percent: 0, total: 0 },
+  c: { percent: 0, total: 0 }
+})
+
+const loading = ref(false)
+
+// 配置项:定义每一项的名称、颜色和对应接口的key
+// 你可以在这里修改 name 为实际业务名称
+const config = [
+  { key: 'a', name: 'A(关键)', color: '#00E5FF' }, // 亮青
+  { key: 'b', name: 'B(重要)', color: '#FFD740' }, // 亮黄
+  { key: 'c', name: 'C(一般)', color: '#69F0AE' } // 荧光绿
+] as const
+
+const fetchData = async () => {
+  loading.value = true
+  try {
+    const res = await IotStatApi.getWhl()
+
+    if (res) {
+      stats.value = res
+    }
+  } catch (error) {
+    console.error('Whl API Error:', error)
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(() => {
+  fetchData()
+})
+</script>
+
+<template>
+  <div
+    class="card size-full rounded-lg p-4 flex flex-col"
+    v-loading="loading"
+    element-loading-background="rgba(0, 0, 0, 0.3)"
+  >
+    <div class="flex items-center gap-2 mb-6">
+      <div class="w-1 h-4 bg-[#00E5FF] rounded-full shadow-[0_0_8px_#00E5FF]"></div>
+      <div class="text-[#e0e0e0] text-lg font-bold">完好率</div>
+    </div>
+
+    <!-- 内容区域:三列布局 -->
+    <div class="flex-1 flex items-center justify-around w-full">
+      <div v-for="item in config" :key="item.key" class="flex flex-col items-center group">
+        <!-- 进度环 -->
+        <!-- stroke-width: 进度条宽度 -->
+        <!-- width: 圆环直径 -->
+        <div class="relative relative-glow">
+          <el-progress
+            type="dashboard"
+            :percentage="stats[item.key]?.percent || 0"
+            :color="item.color"
+            :width="130"
+            :stroke-width="10"
+            stroke-linecap="round"
+          >
+            <!-- 自定义圆环中间的内容 -->
+            <template #default="{ percentage }">
+              <div class="flex flex-col items-center">
+                <span
+                  class="text-2xl font-bold font-mono"
+                  :style="{ color: item.color, textShadow: `0 0 10px ${item.color}40` }"
+                >
+                  {{ percentage }}%
+                </span>
+                <span class="text-[#9ca3af] text-xs mt-1">{{ item.name }}</span>
+              </div>
+            </template>
+          </el-progress>
+        </div>
+
+        <!-- 底部文字:总数 -->
+        <div class="mt-2 text-center">
+          <!-- <div class="text-[#b6c8da] text-sm">在线总数</div> -->
+          <div class="text-xl font-bold text-white mt-1">
+            {{ stats[item.key]?.total || 0 }}
+            <span class="text-xs text-[#6b7280] font-normal">台</span>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+/* 卡片背景 - 保持统一风格 */
+.card {
+  background-color: rgb(0 0 0 / 30%);
+  transition: all 0.3s ease;
+
+  &:hover {
+    box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
+  }
+}
+
+/* 强制修改 Element Progress 的轨道背景色,使其适应深色主题 */
+:deep(.el-progress-circle__track) {
+  stroke: rgb(255 255 255 / 10%) !important;
+}
+
+/* 进度条文字居中修正 */
+:deep(.el-progress__text) {
+  top: 50%;
+  transform: translateY(-50%);
+}
+
+/* 简单的光晕动效 */
+.relative-glow {
+  transition: transform 0.3s ease;
+}
+
+.group:hover .relative-glow {
+  transform: scale(1.05);
+}
+</style>

+ 144 - 0
src/views/pms/stat/rdkb/constructionBriefing.vue

@@ -0,0 +1,144 @@
+<script lang="ts" setup>
+import { IotStatApi } from '@/api/pms/stat'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+
+const loading = ref(false)
+
+interface ConstructionBriefing {
+  projectDeptName: string // 项目部
+  deptName: string // 队伍
+  rdStatus: string // 状态
+  wellName: string // 井号
+  techniques: string //  工艺
+  deviceNames: string // 使用设备
+  cumulativeWorkingLayers: number // 当日施工层
+  cumulativeWorkingWell: string // 当日施工井
+  productionBrief: string //  当日施工简要
+  yesterdayStatus: string //   上一天状态
+  yesterdayWorkingLayers: number // 上一天施工层
+  yesterdayWorkingWell: string //   上一天施工井
+  yesterdayProduct: string //  上一天施工简要
+}
+
+const list = ref<ConstructionBriefing[]>([])
+
+const loadList = async () => {
+  try {
+    loading.value = true
+    const res = await IotStatApi.getConstructionBriefing()
+    list.value = res.list ?? []
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(() => {
+  loadList()
+})
+
+const { ZmTable, ZmTableColumn } = useTableComponents<ConstructionBriefing>()
+</script>
+<template>
+  <div class="card min-h-80 rounded-lg p-4 flex flex-col overflow-hidden" v-loading="loading">
+    <div class="flex justify-between items-center mb-4">
+      <div class="flex items-center gap-2">
+        <!-- 红色装饰条,代表异常/警示 -->
+        <div class="w-1 h-4 bg-[#00E5FF] rounded-full shadow-[0_0_8px_#00E5FF]"></div>
+        <div class="text-[#e0e0e0] text-lg font-bold">施工简报</div>
+      </div>
+    </div>
+    <div class="flex-1 relative w-full">
+      <el-auto-resizer class="absolute">
+        <template #default="{ width, height }">
+          <ZmTable
+            class="custom-dark-table"
+            :data="list"
+            :loading="loading"
+            :width="width"
+            :max-height="height"
+            custom-class
+          >
+            <ZmTableColumn prop="projectDeptName" label="项目部" />
+            <ZmTableColumn prop="deptName" label="队伍" />
+            <ZmTableColumn prop="rdStatus" label="状态" />
+            <ZmTableColumn prop="wellName" label="井号" />
+            <ZmTableColumn prop="techniques" label="工艺" />
+            <ZmTableColumn prop="deviceNames" label="使用设备" />
+            <ZmTableColumn prop="cumulativeWorkingLayers" label="当日施工层" />
+            <ZmTableColumn prop="cumulativeWorkingWell" label="当日施工井" />
+            <ZmTableColumn prop="productionBrief" label="当日施工简要" />
+            <ZmTableColumn prop="yesterdayStatus" label="上一天状态" />
+            <ZmTableColumn prop="yesterdayWorkingLayers" label="上一天施工层" />
+            <ZmTableColumn prop="yesterdayWorkingWell" label="上一天施工井" />
+            <ZmTableColumn prop="yesterdayProduct" label="上一天施工简要" />
+          </ZmTable>
+        </template>
+      </el-auto-resizer>
+    </div>
+  </div>
+</template>
+<style scoped>
+.card {
+  background-color: rgb(0 0 0 / 30%);
+  box-shadow: 0 2px 12px rgb(0 0 0 / 50%);
+  transition: all 0.3s ease;
+
+  &:hover {
+    box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
+  }
+}
+
+/* --- 表格深度定制 --- */
+:deep(.custom-dark-table) {
+  --el-table-border-color: rgb(255 255 255 / 5%);
+  --el-table-header-bg-color: rgb(0 229 255 / 8%);
+  --el-table-row-hover-bg-color: rgb(0 229 255 / 10%);
+  --el-table-tr-bg-color: transparent;
+
+  color: #e5e7eb; /* 文字颜色:灰白 */
+  background-color: transparent !important;
+}
+
+/* 移除表格底部和周围的白线 */
+:deep(.el-table__inner-wrapper::before),
+:deep(.el-table__border-left-patch) {
+  display: none;
+}
+
+/* 表头样式 */
+:deep(.el-table th.el-table__cell) {
+  font-weight: 600;
+  color: #00e5ff; /* 表头文字荧光色 */
+  background-color: var(--el-table-header-bg-color) !important;
+  border-bottom: 1px solid rgb(0 229 255 / 20%);
+}
+
+/* 单元格样式 */
+:deep(.el-table td.el-table__cell) {
+  border-bottom: 1px solid rgb(255 255 255 / 5%);
+}
+
+/* 斑马纹 - 偶数行稍微亮一点点 */
+:deep(.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell) {
+  background-color: rgb(255 255 255 / 2%);
+}
+
+/* Hover 效果 - 这一行亮起 */
+:deep(.el-table--enable-row-hover .el-table__body tr:hover > td.el-table__cell) {
+  background-color: var(--el-table-row-hover-bg-color) !important;
+}
+
+/* 滚动条美化 */
+:deep(.el-scrollbar__bar.is-horizontal),
+:deep(.el-scrollbar__bar.is-vertical) {
+  background-color: rgb(0 0 0 / 30%);
+}
+
+:deep(.el-scrollbar__thumb) {
+  background-color: rgb(0 229 255 / 20%);
+
+  &:hover {
+    background-color: rgb(0 229 255 / 40%);
+  }
+}
+</style>

+ 279 - 0
src/views/pms/stat/rdkb/exception.vue

@@ -0,0 +1,279 @@
+<script lang="ts" setup>
+import { ref, onMounted } from 'vue'
+import dayjs from 'dayjs'
+import CountTo from '@/components/count-to1.vue'
+import { IotStatApi } from '@/api/pms/stat'
+
+// --- 类型定义 ---
+type TimeType = 'quarter' | 'month' | 'year'
+
+// 模拟的生产异常数据接口
+interface ProductionExceptions {
+  standby: number // 井场待命
+  maintenance: number // 维修设备
+  exception: number // 异常/复杂
+  material: number // 物资影响
+  noWorkload: number // 无工作量
+}
+
+// 模拟的巡检异常数据接口
+interface InspectionExceptions {
+  equipment: number // 异常设备
+  point: number // 异常点
+}
+
+// --- 状态管理 ---
+const currentTimeType = ref<TimeType>('month')
+const loading = ref(false)
+
+// 时间选项:日、月、年
+const timeOptions = [
+  { label: '本月', value: 'month' },
+  { label: '本季度', value: 'quarter' },
+  { label: '本年', value: 'year' }
+]
+
+// 数据响应式对象
+const productionData = ref<ProductionExceptions>({
+  standby: 0,
+  maintenance: 0,
+  exception: 0,
+  material: 0,
+  noWorkload: 0
+})
+
+const inspectionData = ref<InspectionExceptions>({
+  equipment: 0,
+  point: 0
+})
+
+// --- 配置项:用于 v-for 循环渲染 ---
+const productionConfig = [
+  { key: 'standby', name: '井场待命' },
+  { key: 'maintenance', name: '维修设备' },
+  { key: 'noWorkload', name: '无工作量' },
+  { key: 'material', name: '物资影响' },
+  { key: 'exception', name: '异常/复杂' }
+]
+
+const inspectionConfig = [
+  { key: 'equipment', name: '异常设备' },
+  { key: 'point', name: '异常点' }
+]
+
+const getDateRange = (type: 'year' | 'quarter' | 'month') => {
+  const now = dayjs()
+  let start: dayjs.Dayjs, end: dayjs.Dayjs
+
+  if (type === 'year') {
+    start = now.startOf('year')
+    end = now.endOf('year')
+  } else if (type === 'quarter') {
+    start = now.startOf('quarter')
+    end = now.endOf('quarter')
+  } else {
+    start = now.startOf('month')
+    end = now.endOf('month')
+  }
+
+  return {
+    start: start.format('YYYY-MM-DD HH:mm:ss'),
+    end: end.format('YYYY-MM-DD HH:mm:ss')
+  }
+}
+
+// --- 模拟 API 请求 ---
+const fetchData = async () => {
+  loading.value = true
+
+  // 模拟获取时间范围
+  const { start, end } = getDateRange(currentTimeType.value as 'year' | 'quarter' | 'month')
+
+  const params = {
+    'createTime[0]': start,
+    'createTime[1]': end
+  }
+
+  const equipmentRes = await IotStatApi.getDeviceException(params)
+  const pointRes = await IotStatApi.getStatusException(params)
+
+  const productionRes = await IotStatApi.getProductionException(params)
+
+  inspectionData.value.equipment = equipmentRes.exceptionNum || 0
+  inspectionData.value.point = pointRes.value || 0
+
+  productionConfig.forEach((item) => {
+    productionData.value[item.key] =
+      productionRes.find((resItem) => resItem.name === item.name)?.count ?? 0
+  })
+
+  console.log('inspectionData :>> ', inspectionData)
+  console.log('productionData :>> ', productionData)
+
+  loading.value = false
+}
+
+const handleTimeChange = () => {
+  fetchData()
+}
+
+onMounted(() => {
+  fetchData()
+})
+</script>
+
+<template>
+  <div
+    class="card size-full rounded-lg p-4 flex flex-col"
+    v-loading="loading"
+    element-loading-background="rgba(0, 0, 0, 0.3)"
+  >
+    <!-- 头部:标题 + 时间切换 -->
+    <div class="flex justify-between items-center mb-4">
+      <div class="flex items-center gap-2">
+        <!-- 红色装饰条,代表异常/警示 -->
+        <div class="w-1 h-4 bg-[#FF5252] rounded-full shadow-[0_0_8px_#FF5252]"></div>
+        <div class="text-[#e0e0e0] text-lg font-bold">异常统计看板</div>
+      </div>
+      <el-segmented
+        size="default"
+        v-model="currentTimeType"
+        :options="timeOptions"
+        @change="handleTimeChange"
+        class="dark-segmented w-50!"
+        block
+      />
+    </div>
+
+    <!-- 内容区域:上下布局 -->
+    <div class="flex-1 flex flex-col gap-4 min-h-0 overflow-y-auto pr-1">
+      <!-- 1. 生产异常模块 -->
+      <div class="section-container">
+        <div class="text-[#FFD740] text-sm font-bold mb-3 flex items-center gap-2">
+          <span class="w-1.5 h-1.5 rounded-full bg-[#FFD740]"></span>
+          生产异常
+        </div>
+
+        <!-- 3x3 网格 -->
+        <div class="grid grid-cols-5 gap-3">
+          <div v-for="item in productionConfig" :key="item.key" class="stat-box group">
+            <div class="text-[#9ca3af] text-xs mb-1">{{ item.name }}</div>
+
+            <CountTo
+              class="text-xl font-bold font-mono text-white group-hover:text-[#FFD740] transition-colors"
+              :start-val="0"
+              :end-val="productionData[item.key as keyof ProductionExceptions]"
+            >
+              <!-- <span class="text-xs leading-8 text-[var(--el-text-color-regular)]">暂无数据</span> -->
+            </CountTo>
+            <!-- <div
+              class="text-xl font-bold font-mono text-white group-hover:text-[#FFD740] transition-colors"
+            >
+              {{ productionData[item.key as keyof ProductionExceptions] }}
+            </div> -->
+            <!-- 底部微光条 -->
+            <div
+              class="absolute bottom-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-[#FFD740] to-transparent opacity-20 group-hover:opacity-100 transition-opacity"
+            ></div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 2. 巡检异常模块 -->
+      <div class="section-container">
+        <div class="text-[#00E5FF] text-sm font-bold mb-3 flex items-center gap-2">
+          <span class="w-1.5 h-1.5 rounded-full bg-[#00E5FF]"></span>
+          巡检异常
+        </div>
+
+        <!-- 2列 网格 -->
+        <div class="grid grid-cols-2 gap-3">
+          <div v-for="item in inspectionConfig" :key="item.key" class="stat-box group">
+            <div class="flex justify-between items-center">
+              <div class="text-[#9ca3af] text-xs">{{ item.name }}</div>
+              <!-- 可以加个小图标占位 -->
+              <div class="w-1 h-1 bg-[#00E5FF] rounded-full opacity-50"></div>
+            </div>
+
+            <CountTo
+              class="text-2xl font-bold font-mono text-white mt-1 group-hover:text-[#00E5FF] transition-colors"
+              :start-val="0"
+              :end-val="inspectionData[item.key as keyof InspectionExceptions]"
+            >
+              <!-- <span class="text-xs leading-8 text-[var(--el-text-color-regular)]">暂无数据</span> -->
+            </CountTo>
+            <!-- <div
+              class="text-2xl font-bold font-mono text-white mt-1 group-hover:text-[#00E5FF] transition-colors"
+            >
+              {{ inspectionData[item.key as keyof InspectionExceptions] }}
+            </div> -->
+            <!-- 底部微光条 -->
+            <div
+              class="absolute bottom-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-[#00E5FF] to-transparent opacity-20 group-hover:opacity-100 transition-opacity"
+            ></div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+/* 主卡片背景 */
+.card {
+  background-color: rgb(0 0 0 / 30%);
+  box-shadow: 0 2px 12px rgb(0 0 0 / 50%);
+  transition: all 0.3s ease;
+
+  &:hover {
+    box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
+  }
+}
+
+/* 子模块容器 */
+.section-container {
+  padding: 12px;
+  background: rgb(255 255 255 / 2%);
+  border: 1px solid rgb(255 255 255 / 3%);
+  border-radius: 8px;
+}
+
+/* 单个数字盒子 */
+.stat-box {
+  position: relative;
+  display: flex;
+  padding: 8px 12px;
+  overflow: hidden;
+  cursor: default;
+  background: rgb(0 0 0 / 20%);
+  border-radius: 6px;
+  transition: all 0.3s ease;
+  flex-direction: column;
+  justify-content: center;
+}
+
+.stat-box:hover {
+  background: rgb(255 255 255 / 5%);
+  transform: translateY(-2px);
+}
+
+/* 分段控制器样式覆盖 */
+.dark-segmented {
+  --el-segmented-item-selected-color: #e5eaf3;
+  --el-border-radius-base: 16px;
+  --el-segmented-color: #cfd3dc;
+  --el-segmented-bg-color: #262727;
+  --el-segmented-item-selected-bg-color: #ff5252;
+  --el-segmented-item-selected-disabled-bg-color: rgb(42 89 138);
+  --el-segmented-item-hover-color: #e5eaf3;
+  --el-segmented-item-hover-bg-color: #39393a;
+  --el-segmented-item-active-bg-color: #424243;
+  --el-segmented-item-disabled-color: #8d9095;
+}
+
+:deep(.el-segmented__item) {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+</style>

+ 218 - 0
src/views/pms/stat/rdkb/utilization.vue

@@ -0,0 +1,218 @@
+<script lang="ts" setup>
+import { ref, onMounted, onUnmounted, nextTick } from 'vue'
+import { IotStatApi } from '@/api/pms/stat'
+import dayjs from 'dayjs'
+import quarterOfYear from 'dayjs/plugin/quarterOfYear'
+import * as echarts from 'echarts'
+
+dayjs.extend(quarterOfYear)
+
+const chartRef = ref(null)
+let myChart: echarts.ECharts | null = null
+const currentTimeType = ref('month')
+
+const timeOptions = [
+  { label: '本月', value: 'month' },
+  { label: '本季度', value: 'quarter' },
+  { label: '本年', value: 'year' }
+]
+
+const getDateRange = (type: 'year' | 'quarter' | 'month') => {
+  const now = dayjs()
+  let start: dayjs.Dayjs, end: dayjs.Dayjs
+
+  if (type === 'year') {
+    start = now.startOf('year')
+    end = now.endOf('year')
+  } else if (type === 'quarter') {
+    start = now.startOf('quarter')
+    end = now.endOf('quarter')
+  } else {
+    start = now.startOf('month')
+    end = now.endOf('month')
+  }
+
+  return {
+    start: start.format('YYYY-MM-DD HH:mm:ss'),
+    end: end.format('YYYY-MM-DD HH:mm:ss')
+  }
+}
+
+const fetchData = async () => {
+  if (myChart) {
+    myChart.showLoading({
+      text: '加载中 ...',
+      color: '#409eff',
+      textColor: '#B6C8DA',
+      maskColor: 'rgba(0, 0, 0, 0.2)'
+    })
+  }
+
+  const { start, end } = getDateRange(currentTimeType.value as 'year' | 'quarter' | 'month')
+
+  const params = {
+    'createTime[0]': start,
+    'createTime[1]': end,
+    timeType: currentTimeType.value
+  }
+
+  try {
+    let list: any[] = []
+
+    const res = await IotStatApi.getUtilization(params)
+    if (res && Array.isArray(res)) list = res
+
+    renderChart(list)
+  } catch (error) {
+    console.error('Workload API Error:', error)
+  } finally {
+    myChart?.hideLoading()
+  }
+}
+
+const renderChart = (data: any[]) => {
+  if (!myChart) return
+
+  const xAxisData = data.map((item) => item.projectDeptName.replace('项目部', ''))
+
+  const seriesData = data.map((item) => {
+    const val = item.utilizationRate
+    if (val === null || val === undefined || isNaN(val)) return 0
+    return parseFloat((val * 100).toFixed(2))
+  })
+
+  const option: echarts.EChartsOption = {
+    tooltip: {
+      trigger: 'axis',
+      backgroundColor: 'rgba(0,0,0,0.7)',
+      borderColor: '#409eff',
+      textStyle: { color: '#fff' },
+      axisPointer: {
+        type: 'shadow',
+        shadowStyle: { color: 'rgba(255, 255, 255, 0.1)' }
+      },
+      // 自定义 Tooltip 内容,加上 %
+      formatter: (params: any) => {
+        const item = params[0]
+        return `${item.name}<br/>
+                <span style="display:inline-block;margin-right:4px;border-radius:10px;width:10px;height:10px;background-color:${item.color.colorStops ? item.color.colorStops[0].color : item.color};"></span>
+                ${item.seriesName}: <span style="font-weight:bold; color: #fff">${item.value}%</span>`
+      }
+    },
+    grid: { top: '15%', left: '3%', right: '3%', bottom: '5%', containLabel: true },
+    xAxis: {
+      data: xAxisData,
+      type: 'category',
+      boundaryGap: true,
+      axisLabel: { color: '#B6C8DA', interval: 0, fontSize: 12 },
+      axisLine: { lineStyle: { color: '#B6C8DA' } },
+      axisTick: { show: false }
+    },
+    yAxis: {
+      type: 'value',
+      name: '利用率 (%)',
+      axisLabel: { color: '#B6C8DA', formatter: '{value}' },
+      nameTextStyle: { color: '#B6C8DA', padding: [0, 0, 0, 10] },
+      axisLine: { lineStyle: { color: '#B6C8DA' } },
+      splitLine: { lineStyle: { color: '#457794', type: 'dashed' } }
+    },
+    series: {
+      name: '设备利用率',
+      type: 'bar',
+      showBackground: true,
+      backgroundStyle: {
+        color: 'rgba(180, 180, 180, 0.1)',
+        borderRadius: [4, 4, 0, 0]
+      },
+      itemStyle: {
+        borderRadius: [4, 4, 0, 0],
+        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+          { offset: 0, color: '#23D0F6' },
+          { offset: 1, color: '#1A7BF8' }
+        ])
+      },
+      emphasis: {
+        itemStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: '#4FFBDF' },
+            { offset: 1, color: '#23D0F6' }
+          ])
+        }
+      },
+      data: seriesData
+    }
+  }
+
+  myChart.setOption(option)
+}
+
+const handleTimeChange = () => {
+  fetchData()
+}
+
+const resizeChart = () => myChart?.resize()
+
+onMounted(() => {
+  nextTick(() => {
+    myChart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
+    fetchData()
+    window.addEventListener('resize', resizeChart)
+  })
+})
+
+onUnmounted(() => {
+  window.removeEventListener('resize', resizeChart)
+  myChart?.dispose()
+})
+</script>
+
+<template>
+  <div class="card size-full rounded-lg p-4 flex flex-col">
+    <div class="flex justify-between items-center mb-4">
+      <div class="flex items-center gap-2 items-center">
+        <div class="w-1 h-4 bg-[#00E5FF] rounded-full shadow-[0_0_8px_#00E5FF]"></div>
+        <div class="text-[#e0e0e0] text-lg font-bold">设备利用率</div>
+      </div>
+      <el-segmented
+        size="default"
+        v-model="currentTimeType"
+        :options="timeOptions"
+        @change="handleTimeChange"
+        class="dark-segmented w-50!"
+        block
+      />
+    </div>
+    <div ref="chartRef" class="flex-1 w-full min-h-0"></div>
+  </div>
+</template>
+
+<style scoped>
+.card {
+  background-color: rgb(0 0 0 / 30%);
+  box-shadow: 0 2px 12px rgb(0 0 0 / 50%);
+  transition: all 0.3s ease;
+
+  &:hover {
+    box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
+  }
+}
+
+.dark-segmented {
+  --el-segmented-item-selected-color: #e5eaf3;
+  --el-border-radius-base: 16px;
+  --el-segmented-color: #cfd3dc;
+  --el-segmented-bg-color: #262727;
+  --el-segmented-item-selected-bg-color: #409eff;
+  --el-segmented-item-selected-disabled-bg-color: rgb(42 89 138);
+  --el-segmented-item-hover-color: #e5eaf3;
+  --el-segmented-item-hover-bg-color: #39393a;
+  --el-segmented-item-active-bg-color: #424243;
+  --el-segmented-item-disabled-color: #8d9095;
+}
+
+:deep(.el-segmented__item) {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+</style>

+ 361 - 0
src/views/pms/stat/rdkb/workload.vue

@@ -0,0 +1,361 @@
+<script lang="ts" setup>
+import { ref, onMounted, onUnmounted, nextTick } from 'vue'
+import { IotStatApi } from '@/api/pms/stat'
+import dayjs from 'dayjs'
+import quarterOfYear from 'dayjs/plugin/quarterOfYear'
+import * as echarts from 'echarts'
+
+dayjs.extend(quarterOfYear)
+
+const chartRef = ref(null)
+let myChart: echarts.ECharts | null = null
+const currentTimeType = ref('month')
+
+const timeOptions = [
+  { label: '本月', value: 'month' },
+  { label: '本季度', value: 'quarter' },
+  { label: '本年', value: 'year' }
+]
+
+const fieldConfig = [
+  { key: 'ylWellCount', name: '压裂井数' },
+  { key: 'lyWellCount', name: '连油井数' },
+  { key: 'cumulativePumpTrips', name: '泵车台次' },
+  { key: 'cumulativeWorkingLayers', name: '压裂层数' }
+]
+
+const getDateRange = (type: 'year' | 'quarter' | 'month') => {
+  const now = dayjs()
+  let start: dayjs.Dayjs, end: dayjs.Dayjs
+
+  if (type === 'year') {
+    start = now.startOf('year')
+    end = now.endOf('year')
+  } else if (type === 'quarter') {
+    start = now.startOf('quarter')
+    end = now.endOf('quarter')
+  } else {
+    start = now.startOf('month')
+    end = now.endOf('month')
+  }
+
+  return {
+    start: start.format('YYYY-MM-DD HH:mm:ss'),
+    end: end.format('YYYY-MM-DD HH:mm:ss')
+  }
+}
+
+const fetchData = async () => {
+  if (myChart) {
+    myChart.showLoading({
+      text: '加载中 ...',
+      color: '#409eff',
+      textColor: '#B6C8DA',
+      maskColor: 'rgba(0, 0, 0, 0.2)'
+    })
+  }
+
+  const { start, end } = getDateRange(currentTimeType.value as 'year' | 'quarter' | 'month')
+
+  const params = {
+    deptId: 163,
+    'createTime[0]': start,
+    'createTime[1]': end,
+    timeType: currentTimeType.value
+  }
+
+  try {
+    let list: any[] = []
+
+    if (currentTimeType.value === 'year') {
+      const res = await IotStatApi.getRdWorkloadYear(params)
+      if (res && Array.isArray(res)) list = res
+    } else {
+      const res = await IotStatApi.getRdWorkload(params)
+      if (res && res.list) list = res.list
+    }
+
+    renderChart(list)
+  } catch (error) {
+    console.error('Workload API Error:', error)
+  } finally {
+    myChart?.hideLoading()
+  }
+}
+
+const renderChart = (data: any[]) => {
+  if (!myChart) return
+  const isYear = currentTimeType.value === 'year'
+
+  // --- 高亮配色方案 ---
+  const colorPalettes = [
+    // 1. 冰蓝霓虹 (压裂井数) - 极亮青色 -> 深蓝
+    {
+      line: '#00E5FF',
+      start: '#00E5FF',
+      end: '#2979FF'
+    },
+    // 2. 日落流金 (连油井数) - 亮黄 -> 亮橙
+    {
+      line: '#FFD740',
+      start: '#FFD740',
+      end: '#FF6D00'
+    },
+    // 3. 赛博紫 (泵车台次) - 亮粉紫 -> 深紫
+    {
+      line: '#EA80FC',
+      start: '#EA80FC',
+      end: '#651FFF'
+    },
+    // 4. 极光绿 (压裂层数) - 荧光绿 -> 青绿
+    {
+      line: '#69F0AE',
+      start: '#69F0AE',
+      end: '#00C853'
+    }
+  ]
+
+  const xAxisData = data.map((item) =>
+    isYear ? item.reportDate : item.projectDeptName.replace('项目部', '')
+  )
+
+  const option: echarts.EChartsOption = {
+    tooltip: {
+      trigger: 'axis',
+      backgroundColor: 'rgba(18, 26, 44, 0.9)', // 更深色的背景,对比更强
+      borderColor: '#409eff',
+      textStyle: { color: '#fff' },
+      padding: [10, 15],
+      axisPointer: {
+        type: isYear ? 'line' : 'shadow',
+        lineStyle: { color: '#fff', type: 'dashed' },
+        shadowStyle: { color: 'rgba(255, 255, 255, 0.1)' }
+      }
+    },
+    legend: {
+      data: fieldConfig.map((item) => item.name),
+      textStyle: { color: '#E0E0E0' }, // 图例文字调亮
+      top: 0,
+      itemWidth: 14,
+      itemHeight: 14
+    },
+    grid: {
+      top: '18%', // 留出更多空间给图例
+      left: '2%',
+      right: '2%',
+      bottom: '2%',
+      containLabel: true
+    },
+    xAxis: {
+      data: xAxisData,
+      type: 'category',
+      boundaryGap: !isYear,
+      axisLabel: {
+        color: '#D1D5DB', // X轴文字调亮
+        interval: 0,
+        fontSize: 12,
+        formatter: (value: string) => {
+          // 如果名字太长换行显示
+          return value.length > 4 ? value.slice(0, 4) + '\n' + value.slice(4) : value
+        }
+      },
+      axisLine: { lineStyle: { color: '#4B5563' } },
+      axisTick: { show: false }
+    },
+    yAxis: {
+      type: 'value',
+      axisLabel: { color: '#D1D5DB' },
+      splitLine: {
+        lineStyle: { color: '#457794', type: 'dashed' } // 网格线稍微亮一点
+      }
+    },
+    series: fieldConfig.map((item, index) => {
+      const palette = colorPalettes[index % colorPalettes.length]
+
+      const seriesBase = {
+        name: item.name,
+        data: data.map((d) => {
+          const val = d[item.key]
+          return val === null || val === undefined || isNaN(val) ? 0 : val
+        })
+      }
+
+      if (isYear) {
+        // --- 折线图 (年) ---
+        return {
+          ...seriesBase,
+          type: 'line',
+          smooth: true,
+          showSymbol: false,
+          symbol: 'circle',
+          symbolSize: 8,
+          // 线条非常亮
+          lineStyle: { width: 3, color: palette.line, shadowColor: palette.line, shadowBlur: 10 },
+          itemStyle: { color: palette.line, borderColor: '#fff', borderWidth: 2 },
+          areaStyle: {
+            opacity: 0.5, // 区域透明度适中
+            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+              { offset: 0, color: palette.start }, // 顶部颜色同线条
+              { offset: 1, color: 'rgba(0,0,0,0)' }
+            ])
+          },
+          emphasis: { focus: 'series' }
+        }
+      } else {
+        // --- 柱状图 (月/季) ---
+        return {
+          ...seriesBase,
+          type: 'bar',
+          barMaxWidth: 14,
+          barGap: '30%',
+          itemStyle: {
+            borderRadius: [4, 4, 0, 0],
+            // 柱子使用实色渐变,不再过度透明,显得“脏”
+            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+              { offset: 0, color: palette.start }, // 100% 亮色
+              { offset: 1, color: palette.end } // 底部颜色,透明度由 hex 决定(这里是实色)
+            ]),
+            // 给柱子加一点光晕
+            shadowColor: palette.end,
+            shadowBlur: 5
+          },
+          emphasis: {
+            focus: 'series',
+            itemStyle: {
+              // 鼠标悬停时变得更亮
+              color: palette.line
+            }
+          }
+        }
+      }
+    }) as any
+  }
+
+  myChart.setOption(option)
+}
+// const renderChart = (data: any[]) => {
+//   if (!myChart) return
+//   const isYear = currentTimeType.value === 'year'
+
+//   const color = ['#5470c6', '#f1d209', '#e14f0f', '#91cc75']
+
+//   const xAxisData = data.map((item) =>
+//     isYear ? item.reportDate : item.projectDeptName.replace('项目部', '')
+//   )
+
+//   const option: echarts.EChartsOption = {
+//     tooltip: {
+//       trigger: 'axis',
+//       axisPointer: {
+//         type: isYear ? 'cross' : 'shadow',
+//         label: { backgroundColor: '#6a7985' }
+//       }
+//     },
+//     legend: {
+//       data: fieldConfig.map((item) => item.name),
+//       textStyle: { color: '#B6C8DA' }
+//     },
+//     grid: { top: '15%', left: '2%', right: '2%', bottom: '2%', containLabel: true },
+//     xAxis: {
+//       data: xAxisData,
+//       type: 'category',
+//       boundaryGap: !isYear,
+//       axisLabel: { color: '#B6C8DA', interval: 0 },
+//       axisLine: { lineStyle: { color: '#B6C8DA' } }
+//     },
+//     yAxis: {
+//       type: 'value',
+//       axisLabel: { color: '#B6C8DA' },
+//       splitLine: { lineStyle: { color: '#457794', type: 'dashed' } },
+//       axisLine: { lineStyle: { color: '#B6C8DA' } }
+//     },
+//     series: fieldConfig.map((item, index) => ({
+//       name: item.name,
+//       type: isYear ? 'line' : 'bar',
+//       smooth: true,
+//       barMaxWidth: 30,
+//       symbol: 'circle',
+//       symbolSize: 8,
+//       itemStyle: { color: color[index] },
+//       emphasis: { focus: 'series' },
+//       lineStyle: { width: 3 },
+//       data: data.map((d) => {
+//         const val = d[item.key]
+//         return val === null || val === undefined || isNaN(val) ? 0 : val
+//       })
+//     }))
+//   }
+
+//   myChart.setOption(option)
+// }
+
+const handleTimeChange = () => {
+  fetchData()
+}
+
+const resizeChart = () => myChart?.resize()
+
+onMounted(() => {
+  nextTick(() => {
+    myChart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
+    fetchData()
+    window.addEventListener('resize', resizeChart)
+  })
+})
+
+onUnmounted(() => {
+  window.removeEventListener('resize', resizeChart)
+  myChart?.dispose()
+})
+</script>
+
+<template>
+  <div class="card size-full rounded-lg p-4 flex flex-col">
+    <div class="flex justify-between items-center mb-4">
+      <div class="flex items-center gap-2 items-center">
+        <div class="w-1 h-4 bg-[#00E5FF] rounded-full shadow-[0_0_8px_#00E5FF]"></div>
+        <div class="text-[#e0e0e0] text-lg font-bold">工作量汇总</div>
+      </div>
+      <el-segmented
+        size="default"
+        v-model="currentTimeType"
+        :options="timeOptions"
+        @change="handleTimeChange"
+        class="dark-segmented w-50!"
+        block
+      />
+    </div>
+    <div ref="chartRef" class="flex-1 w-full min-h-0"></div>
+  </div>
+</template>
+
+<style scoped>
+.card {
+  background-color: rgb(0 0 0 / 30%);
+  box-shadow: 0 2px 12px rgb(0 0 0 / 50%);
+  transition: all 0.3s ease;
+
+  &:hover {
+    box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
+  }
+}
+
+.dark-segmented {
+  --el-segmented-item-selected-color: #e5eaf3;
+  --el-border-radius-base: 16px;
+  --el-segmented-color: #cfd3dc;
+  --el-segmented-bg-color: #262727;
+  --el-segmented-item-selected-bg-color: #409eff;
+  --el-segmented-item-selected-disabled-bg-color: rgb(42 89 138);
+  --el-segmented-item-hover-color: #e5eaf3;
+  --el-segmented-item-hover-bg-color: #39393a;
+  --el-segmented-item-active-bg-color: #424243;
+  --el-segmented-item-disabled-color: #8d9095;
+}
+
+:deep(.el-segmented__item) {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+</style>

+ 36 - 11
src/views/report-statistics/costs.vue

@@ -5,6 +5,7 @@ import CountTo from '@/components/count-to1.vue'
 import { IotReportApi } from '@/api/pms/report'
 import { useDebounceFn } from '@vueuse/core'
 import download from '@/utils/download'
+import { rangeShortcuts } from '@/utils/formatTime'
 
 // 定义时间类型
 type TimeType = 'year' | 'month' | 'day'
@@ -24,7 +25,7 @@ const timeOptions: { label: string; value: TimeType }[] = [
   { label: '日', value: 'day' }
 ]
 
-const activeTimeType = ref<TimeType>('year')
+const activeTimeType = ref<TimeType | undefined>('year')
 const query = ref<Query>({
   pageNo: 1,
   pageSize: 10
@@ -214,6 +215,14 @@ const handleExport = async () => {
     exportLoading.value = false
   }
 }
+
+const handleClear = () => {
+  handleTimeChange('year')
+}
+
+const handleChange = () => {
+  activeTimeType.value = undefined
+}
 </script>
 
 <template>
@@ -303,16 +312,32 @@ const handleExport = async () => {
         </section>
       </div>
       <div class="flex justify-between gap-4">
-        <el-button-group size="default">
-          <el-button
-            v-for="item in timeOptions"
-            :key="item.value"
-            :type="activeTimeType === item.value ? 'primary' : ''"
-            @click="handleTimeChange(item.value)"
-          >
-            {{ item.label }}
-          </el-button>
-        </el-button-group>
+        <div class="flex items-center gap-4">
+          <el-date-picker
+            size="default"
+            v-model="query.createTime"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            type="daterange"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            :shortcuts="rangeShortcuts"
+            class="!w-220px"
+            @clear="handleClear"
+            @change="handleChange"
+            :clearable="false"
+            :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          />
+          <el-button-group size="default">
+            <el-button
+              v-for="item in timeOptions"
+              :key="item.value"
+              :type="activeTimeType === item.value ? 'primary' : ''"
+              @click="handleTimeChange(item.value)"
+            >
+              {{ item.label }}
+            </el-button>
+          </el-button-group>
+        </div>
         <div class="flex items-center gap-2">
           <el-button size="default" @click="handleReset">重置</el-button>
           <el-button

+ 21 - 0
src/views/report-statistics/daily-report.vue

@@ -6,6 +6,7 @@ import dayjs from 'dayjs'
 import { DICT_TYPE, getDictOptions } from '@/utils/dict'
 import { TableColumnCtx } from 'element-plus/es/components/table/src/table-column/defaults'
 import { useUserStore } from '@/store/modules/user'
+import download from '@/utils/download'
 
 defineOptions({ name: 'DailyReport' })
 
@@ -429,6 +430,23 @@ watch(
 const expandRowKeys = computed(() => {
   return list.value.filter((item) => item.lastGroupIdFlag).map((item) => item.id.toString())
 })
+
+const exportLoading = ref(false)
+
+const handleExport = () => {
+  exportLoading.value = true
+  if (tab.value === '井') {
+    IotRhDailyReportApi.exportIotRhDailyReportWell(query.value).then((data) => {
+      download.excel(data, '瑞恒井日报统计.xls')
+      exportLoading.value = false
+    })
+  } else {
+    IotRhDailyReportApi.exportIotRhDailyReportTeam(query.value).then((data) => {
+      download.excel(data, '瑞恒队伍日报统计.xls')
+      exportLoading.value = false
+    })
+  }
+}
 </script>
 
 <template>
@@ -479,6 +497,9 @@ const expandRowKeys = computed(() => {
           <Icon icon="ep:search" class="mr-5px" /> 搜索
         </el-button>
         <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
+        <el-button plain type="success" @click="handleExport" :loading="exportLoading">
+          <Icon icon="ep:download" class="mr-5px" /> 导出
+        </el-button>
       </el-form-item>
     </el-form>
 

+ 31 - 2
src/views/report-statistics/work-order-completion.vue

@@ -5,6 +5,7 @@ import { useDebounceFn } from '@vueuse/core'
 import MiniBarChart from '@/components/WorkOrderCompletionBar/index.vue'
 import CountTo from '@/components/count-to1.vue'
 import { IotReportApi } from '@/api/pms/report'
+import { rangeShortcuts } from '@/utils/formatTime'
 
 // 定义时间类型
 type TimeType = 'year' | 'month' | 'day'
@@ -40,7 +41,7 @@ const timeOptions: { label: string; value: TimeType }[] = [
   { label: '日', value: 'day' }
 ]
 
-const activeTimeType = ref<TimeType>('year')
+const activeTimeType = ref<TimeType | undefined>('year')
 const query = ref<Query>({
   pageNo: 1,
   pageSize: 10
@@ -247,6 +248,14 @@ function handleReset() {
   handleTimeChange('year')
   selectType(undefined)
 }
+
+const handleClear = () => {
+  handleTimeChange('year')
+}
+
+const handleChange = () => {
+  activeTimeType.value = undefined
+}
 </script>
 
 <template>
@@ -346,6 +355,20 @@ function handleReset() {
       </div>
       <div class="flex justify-between">
         <div class="flex gap-4">
+          <el-date-picker
+            size="default"
+            v-model="query.createTime"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            type="daterange"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            :shortcuts="rangeShortcuts"
+            class="!w-220px"
+            @clear="handleClear"
+            @change="handleChange"
+            :clearable="false"
+            :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          />
           <el-button-group size="default">
             <el-button
               v-for="item in timeOptions"
@@ -356,9 +379,15 @@ function handleReset() {
               {{ item.label }}
             </el-button>
           </el-button-group>
+        </div>
+        <div class="flex gap-2">
           <el-button size="default" @click="handleReset">重置</el-button>
+          <!-- @click="handleExport"
+            :loading="exportLoading" -->
+          <el-button size="default" plain type="success">
+            <Icon icon="ep:download" class="mr-5px" /> 导出
+          </el-button>
         </div>
-        <el-button size="default" type="primary">导出</el-button>
       </div>
     </div>
     <div class="bg-white dark:bg-[#1d1e1f] shadow rounded-lg p-4 flex flex-col">