yanghao 16 цаг өмнө
parent
commit
0861d37d6c

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

@@ -13,6 +13,12 @@ export interface IotProjectTaskVO {
   userId: number // 用户id
   remark: string // 备注
   deptIds: []
+  deptId?: number // 施工队伍id
+}
+
+export interface GenerateReportParams {
+  id: number
+  reportDate: number
 }
 
 // 项目信息任务拆分 API
@@ -39,6 +45,11 @@ export const IotProjectTaskApi = {
     return await request.put({ url: `/rq/iot-project-task/update`, data })
   },
 
+  // 生成日报
+  generateReport: async (data: GenerateReportParams) => {
+    return await request.post({ url: `/rq/iot-project-task/generateReport`, data })
+  },
+
   // 删除项目信息任务拆分
   deleteIotProjectTask: async (id: number) => {
     return await request.delete({ url: `/rq/iot-project-task/delete?id=` + id })

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

@@ -69,6 +69,10 @@ export const IotRhDailyReportApi = {
     return await request.get({ url: `/pms/iot-rh-daily-report/polylineStatistics`, params })
   },
 
+  nptStatistics: async (params: any) => {
+    return await request.get({ url: `/pms/iot-rd-daily-report/nptStatistics`, params })
+  },
+
   // 累计工作量统计
   totalWorkload: async (params: any): Promise<IotRhDailyReportTotalWorkloadVO> => {
     return await request.get({ url: `/pms/iot-rh-daily-report/totalWorkload`, params })

+ 5 - 4
src/components/DailyTableColumn/index.vue

@@ -24,7 +24,7 @@
       v-bind="col"
       align="center"
       resizable
-      :show-overflow-tooltip="false"
+      :show-overflow-tooltip="true"
     >
       <template v-if="company === 'rd'" #default="{ row }">
         <div v-if="row.reportDetails && row.reportDetails.length > 0" class="py-2">
@@ -32,7 +32,7 @@
             <template #reference>
               <el-badge :value="row.reportDetails.length" type="primary" class="max-w-full">
                 <div
-                  class="w-58 flex flex-col bg-gray-200/80 hover:bg-blue-100 transition-colors relative group cursor-pointer p-2 rounded gap-y-2"
+                  class="w-58 flex flex-col bg-gray-200/80 hover:bg-blue-100 transition-colors relative group cursor-pointer px-2 py-0.5 rounded gap-y-2"
                 >
                   <div class="flex items-center gap-x-2">
                     <div class="flex items-center">
@@ -106,6 +106,7 @@
             </el-scrollbar>
           </el-popover>
         </div>
+        <div class="truncate" v-else-if="row.productionStatus">{{ row.productionStatus }}</div>
         <span v-else class="text-gray-300">-</span>
       </template>
       <template v-else-if="company === 'ry'" #default="{ row }">
@@ -114,7 +115,7 @@
             <template #reference>
               <el-badge :value="row.reportDetails.length" type="primary" class="max-w-full">
                 <div
-                  class="w-72 flex flex-col bg-gray-200/80 hover:bg-blue-100 transition-colors relative group cursor-pointer p-2 rounded gap-y-2"
+                  class="w-72 flex flex-col bg-gray-200/80 hover:bg-blue-100 transition-colors relative group cursor-pointer px-2 py-0.5 rounded gap-y-2"
                 >
                   <div class="flex items-center gap-x-2">
                     <div class="flex items-center">
@@ -234,7 +235,7 @@
             <template #reference>
               <el-badge :value="row.reportDetails.length" type="primary" class="max-w-full">
                 <div
-                  class="w-72 flex flex-col bg-gray-200/80 hover:bg-blue-100 transition-colors relative group cursor-pointer p-2 rounded gap-y-2"
+                  class="w-72 flex flex-col bg-gray-200/80 hover:bg-blue-100 transition-colors relative group cursor-pointer px-2 py-0.5 rounded gap-y-2"
                 >
                   <div class="flex items-center gap-x-2">
                     <div class="flex items-center">

+ 1 - 1
src/components/ZmTable/index.vue

@@ -420,7 +420,7 @@ defineExpose({
   --zm-table-hover-bg: #f5f9ff;
   --zm-table-current-bg: #eef6ff;
   --zm-table-cell-height: 38px;
-  --zm-table-cell-padding-x: 12px;
+  --zm-table-cell-padding-x: 8px;
   --zm-table-cell-first-padding-left: 0px;
   --zm-table-cell-last-padding-right: 0px;
   --zm-table-cell-line-height: 18px;

+ 284 - 55
src/views/oli-connection/monitoring-board/chart.vue

@@ -45,10 +45,10 @@ const props = defineProps({
     type: String,
     required: true
   },
-  // isRealTime: {
-  //   type: Boolean,
-  //   default: true
-  // },
+  isRealTime: {
+    type: Boolean,
+    default: true
+  },
   date: {
     type: Array as PropType<Array<string>>,
     required: true
@@ -64,8 +64,14 @@ const selectedDimension = ref<Record<string, boolean>>({})
 
 const { connect, destroy, isConnected, subscribe } = useMqtt()
 
+const REALTIME_WINDOW_MS = 5 * 60 * 1000
+let isProgrammaticDataZoomUpdate = false
+
 const handleMessageUpdate = (_topic: string, data: any) => {
   const valueMap = new Map<string, number>()
+  const nextUpdatedNames = new Set<string>()
+  const wasFollowingFullRange = isFollowingFullRange.value
+  const previousVisibleRange = { ...visibleTimeRange.value }
 
   for (const item of data) {
     const { id: identity, value: logValue, remark } = item
@@ -79,18 +85,46 @@ const handleMessageUpdate = (_topic: string, data: any) => {
     const modelName = dimensions.value.find((item) => item.identifier === identity)?.name
 
     if (modelName && chartData.value[modelName]) {
-      chartData.value[modelName].push({
-        ts: dayjs.unix(remark).valueOf(),
+      const ts = dayjs.unix(remark).valueOf()
+
+      appendChartPoint(modelName, {
+        ts,
         value
       })
 
-      if (isFollowingFullRange.value) {
-        resetVisibleTimeRange()
-      }
-
-      updateSeriesByNames([modelName])
+      nextUpdatedNames.add(modelName)
     }
   }
+
+  if (!nextUpdatedNames.size) return
+
+  const { maxTs } = getGlobalTimeExtent()
+  if (maxTs === null) return
+
+  const fullRange = getRealtimeTimeRange(maxTs)
+  const trimmedNames = trimChartDataToRealtimeWindow(maxTs)
+  const selectedRange = wasFollowingFullRange
+    ? fullRange
+    : clampTimeRangeToFullRange(visibleTimeRange.value, fullRange)
+
+  visibleTimeRange.value = selectedRange
+  isFollowingFullRange.value = isSameTimeRange(selectedRange, fullRange)
+
+  trimmedNames.forEach((name) => nextUpdatedNames.add(name))
+
+  const didVisibleRangeChange =
+    previousVisibleRange.startTs === null ||
+    previousVisibleRange.endTs === null ||
+    Math.abs(previousVisibleRange.startTs - selectedRange.startTs) > 1 ||
+    Math.abs(previousVisibleRange.endTs - selectedRange.endTs) > 1
+
+  applyChartRangeAndSeries(
+    fullRange,
+    selectedRange,
+    wasFollowingFullRange || didVisibleRangeChange
+      ? getSelectedSeriesNames()
+      : Array.from(nextUpdatedNames)
+  )
 }
 
 watch(isConnected, (newVal) => {
@@ -140,8 +174,13 @@ async function loadDimensions() {
   }
 }
 
+interface ChartPoint {
+  ts: number
+  value: number
+}
+
 interface ChartData {
-  [key: Dimensions['name']]: { ts: number; value: number }[]
+  [key: Dimensions['name']]: ChartPoint[]
 }
 
 const chartData = ref<ChartData>({})
@@ -158,6 +197,7 @@ const visibleTimeRange = ref<{
 })
 
 const isFollowingFullRange = ref(true)
+const isRealtimeMode = ref(true)
 
 const TREND_AXIS_MIN = 0
 const TREND_AXIS_MAX = 100
@@ -202,30 +242,187 @@ const getGlobalTimeExtent = () => {
 }
 
 function resetVisibleTimeRange() {
-  const { minTs, maxTs } = getGlobalTimeExtent()
+  const { startTs, endTs } = getFullTimeRange()
 
   visibleTimeRange.value = {
-    startTs: minTs,
-    endTs: maxTs
+    startTs,
+    endTs
   }
 
   isFollowingFullRange.value = true
 }
 
-function syncVisibleTimeRangeFromChart() {
+function getFullTimeRange() {
   const { minTs, maxTs } = getGlobalTimeExtent()
 
-  if (!chart || minTs === null || maxTs === null) {
+  if (isRealtimeMode.value && maxTs !== null) {
+    return getRealtimeTimeRange(maxTs)
+  }
+
+  return {
+    startTs: minTs,
+    endTs: maxTs
+  }
+}
+
+function getRealtimeTimeRange(endTs: number) {
+  return {
+    startTs: endTs - REALTIME_WINDOW_MS,
+    endTs
+  }
+}
+
+function isSameTimeRange(
+  range: { startTs: number | null; endTs: number | null },
+  fullRange: { startTs: number; endTs: number }
+) {
+  if (range.startTs === null || range.endTs === null) return false
+
+  const tolerance = Math.max((fullRange.endTs - fullRange.startTs) * 0.001, 1000)
+
+  return (
+    Math.abs(range.startTs - fullRange.startTs) <= tolerance &&
+    Math.abs(range.endTs - fullRange.endTs) <= tolerance
+  )
+}
+
+function clampTimeRangeToFullRange(
+  range: { startTs: number | null; endTs: number | null },
+  fullRange: { startTs: number; endTs: number }
+) {
+  if (range.startTs === null || range.endTs === null) return fullRange
+
+  const startTs = Math.max(Math.min(range.startTs, range.endTs), fullRange.startTs)
+  const endTs = Math.min(Math.max(range.startTs, range.endTs), fullRange.endTs)
+
+  if (startTs > endTs) return fullRange
+
+  return {
+    startTs,
+    endTs
+  }
+}
+
+function appendChartPoint(name: string, point: ChartPoint) {
+  const dataset = chartData.value[name]
+  const lastPoint = dataset.at(-1)
+
+  if (!lastPoint || point.ts >= lastPoint.ts) {
+    dataset.push(point)
+    return
+  }
+
+  const insertIndex = dataset.findIndex(({ ts }) => ts > point.ts)
+  dataset.splice(insertIndex === -1 ? dataset.length : insertIndex, 0, point)
+}
+
+function trimDatasetToRealtimeWindow(dataset: ChartPoint[], startTs: number, endTs: number) {
+  const boundedDataset = dataset.filter(({ ts }) => Number.isFinite(ts) && ts <= endTs)
+
+  if (!boundedDataset.length) return []
+
+  const firstVisibleIndex = boundedDataset.findIndex(({ ts }) => ts >= startTs)
+
+  if (firstVisibleIndex === -1) {
+    return boundedDataset.slice(-1)
+  }
+
+  return boundedDataset.slice(Math.max(firstVisibleIndex - 1, 0))
+}
+
+function trimChartDataToRealtimeWindow(endTs: number | null) {
+  if (endTs === null || !Number.isFinite(endTs)) return []
+
+  const { startTs } = getRealtimeTimeRange(endTs)
+  const trimmedNames: string[] = []
+
+  Object.entries(chartData.value).forEach(([name, dataset]) => {
+    const nextDataset = trimDatasetToRealtimeWindow(dataset, startTs, endTs)
+
+    if (nextDataset.length !== dataset.length || nextDataset[0] !== dataset[0]) {
+      chartData.value[name] = nextDataset
+      trimmedNames.push(name)
+    }
+  })
+
+  return trimmedNames
+}
+
+function getDataZoomRangeOptions(range: { startTs: number; endTs: number }) {
+  return [
+    {
+      id: 'monitoring-board-inside-zoom',
+      startValue: range.startTs,
+      endValue: range.endTs
+    },
+    {
+      id: 'monitoring-board-slider-zoom',
+      startValue: range.startTs,
+      endValue: range.endTs
+    }
+  ]
+}
+
+function applyChartRangeAndSeries(
+  fullRange: { startTs: number; endTs: number },
+  selectedRange: { startTs: number; endTs: number },
+  names: string[]
+) {
+  if (!chart) render()
+  if (!chart) return
+
+  isProgrammaticDataZoomUpdate = true
+
+  chart.setOption(
+    {
+      xAxis: {
+        min: fullRange.startTs,
+        max: fullRange.endTs
+      },
+      dataZoom: getDataZoomRangeOptions(selectedRange),
+      series: names.map((name) => ({
+        name,
+        data: buildSeriesData(name)
+      }))
+    },
+    {
+      lazyUpdate: true,
+      silent: true
+    }
+  )
+
+  window.setTimeout(() => {
+    isProgrammaticDataZoomUpdate = false
+  })
+}
+
+function getDataZoomPayload(params?: any) {
+  const payload = Array.isArray(params?.batch) ? params.batch[0] : params
+
+  if (
+    payload &&
+    ['startValue', 'endValue', 'start', 'end'].some((key) => payload[key] !== undefined)
+  ) {
+    return payload
+  }
+
+  const dataZoomOptions = chart?.getOption().dataZoom
+  return Array.isArray(dataZoomOptions) ? (dataZoomOptions[0] as any) : undefined
+}
+
+function syncVisibleTimeRangeFromChart(params?: any) {
+  const { startTs: fullStartTs, endTs: fullEndTs } = getFullTimeRange()
+
+  if (!chart || fullStartTs === null || fullEndTs === null) {
     visibleTimeRange.value = {
-      startTs: minTs,
-      endTs: maxTs
+      startTs: fullStartTs,
+      endTs: fullEndTs
     }
     isFollowingFullRange.value = true
     return
   }
 
-  const dataZoomOptions = chart.getOption().dataZoom
-  const primaryDataZoom = Array.isArray(dataZoomOptions) ? (dataZoomOptions[0] as any) : undefined
+  const primaryDataZoom = getDataZoomPayload(params)
 
   if (!primaryDataZoom) {
     resetVisibleTimeRange()
@@ -235,8 +432,8 @@ function syncVisibleTimeRangeFromChart() {
   const startValue = Number(primaryDataZoom.startValue)
   const endValue = Number(primaryDataZoom.endValue)
 
-  let nextStartTs = minTs
-  let nextEndTs = maxTs
+  let nextStartTs = fullStartTs
+  let nextEndTs = fullEndTs
 
   if (Number.isFinite(startValue) && Number.isFinite(endValue)) {
     nextStartTs = Math.min(startValue, endValue)
@@ -244,23 +441,25 @@ function syncVisibleTimeRangeFromChart() {
   } else {
     const startPercent = Number(primaryDataZoom.start ?? 0)
     const endPercent = Number(primaryDataZoom.end ?? 100)
-    const range = maxTs - minTs
+    const range = fullEndTs - fullStartTs
 
-    nextStartTs = minTs + (range * Math.min(startPercent, endPercent)) / 100
-    nextEndTs = minTs + (range * Math.max(startPercent, endPercent)) / 100
+    nextStartTs = fullStartTs + (range * Math.min(startPercent, endPercent)) / 100
+    nextEndTs = fullStartTs + (range * Math.max(startPercent, endPercent)) / 100
   }
 
-  visibleTimeRange.value = {
+  const nextRange = {
     startTs: nextStartTs,
     endTs: nextEndTs
   }
 
-  const tolerance = Math.max((maxTs - minTs) * 0.001, 1)
-  isFollowingFullRange.value =
-    Math.abs(nextStartTs - minTs) <= tolerance && Math.abs(nextEndTs - maxTs) <= tolerance
+  visibleTimeRange.value = nextRange
+  isFollowingFullRange.value = isSameTimeRange(nextRange, {
+    startTs: fullStartTs,
+    endTs: fullEndTs
+  })
 }
 
-const getVisibleDataset = (dataset: { ts: number; value: number }[]) => {
+const getVisibleDataset = (dataset: ChartPoint[]) => {
   const { startTs, endTs } = visibleTimeRange.value
 
   if (startTs === null || endTs === null) {
@@ -270,7 +469,7 @@ const getVisibleDataset = (dataset: { ts: number; value: number }[]) => {
   return dataset.filter(({ ts }) => ts >= startTs && ts <= endTs)
 }
 
-const getTrendStats = (dataset: { ts: number; value: number }[]): TrendStats => {
+const getTrendStats = (dataset: ChartPoint[]): TrendStats => {
   const values = getVisibleDataset(dataset)
     .map(({ value }) => Number(value))
     .filter((value) => Number.isFinite(value))
@@ -375,16 +574,20 @@ function updateSeriesByNames(names: string[]) {
   if (!chart) render()
   if (!chart || !names.length) return
 
-  chart.setOption({
-    series: names.map((name) => ({
-      name,
-      data: buildSeriesData(name)
-    }))
-  })
+  chart.setOption(
+    {
+      series: names.map((name) => ({
+        name,
+        data: buildSeriesData(name)
+      }))
+    },
+    {
+      lazyUpdate: true
+    }
+  )
 }
 
 const refreshVisibleSeries = useDebounceFn(() => {
-  syncVisibleTimeRangeFromChart()
   updateSeriesByNames(getSelectedSeriesNames())
 }, 50)
 
@@ -403,7 +606,10 @@ function chartInit() {
   })
 
   chart.off('datazoom')
-  chart.on('datazoom', () => {
+  chart.on('datazoom', (params: any) => {
+    if (isProgrammaticDataZoomUpdate) return
+
+    syncVisibleTimeRangeFromChart(params)
     refreshVisibleSeries()
   })
 
@@ -420,10 +626,10 @@ function render() {
 
   chart.setOption({
     color: neonColors,
-    animation: true,
-    animationDuration: 200,
+    animation: false,
+    animationDuration: 0,
     animationEasing: 'linear',
-    animationDurationUpdate: 200,
+    animationDurationUpdate: 0,
     animationEasingUpdate: 'linear',
     grid: {
       left: '3%',
@@ -491,8 +697,15 @@ function render() {
       }
     },
     dataZoom: [
-      { type: 'inside', xAxisIndex: 0, filterMode: 'none', throttle: 50 },
       {
+        id: 'monitoring-board-inside-zoom',
+        type: 'inside',
+        xAxisIndex: 0,
+        filterMode: 'none',
+        throttle: 50
+      },
+      {
+        id: 'monitoring-board-slider-zoom',
         type: 'slider',
         xAxisIndex: 0,
         filterMode: 'none',
@@ -585,11 +798,11 @@ function render() {
 
       emphasis: {
         focus: 'series',
-        lineStyle: { width: 4 }
+        lineStyle: { width: 3 }
       },
 
       lineStyle: {
-        width: 3,
+        width: 2,
         shadowColor: 'rgba(0, 0, 0, 0.5)',
         shadowBlur: 10,
         shadowOffsetY: 5
@@ -608,6 +821,7 @@ const chartLoading = ref(false)
 async function initLoadChartData(real_time: boolean = true) {
   if (!dimensions.value.length) return
 
+  isRealtimeMode.value = real_time
   chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
   isFollowingFullRange.value = true
   resetVisibleTimeRange()
@@ -650,6 +864,17 @@ async function initLoadChartData(real_time: boolean = true) {
   }
 
   if (real_time) {
+    const { maxTs } = getGlobalTimeExtent()
+
+    if (maxTs !== null) {
+      trimChartDataToRealtimeWindow(maxTs)
+      const fullRange = getRealtimeTimeRange(maxTs)
+
+      visibleTimeRange.value = fullRange
+      isFollowingFullRange.value = true
+      applyChartRangeAndSeries(fullRange, fullRange, getSelectedSeriesNames())
+    }
+
     connect(`wss://aims.deepoil.cc/mqtt`, { password: props.token }, handleMessageUpdate)
   }
 }
@@ -665,20 +890,23 @@ onMounted(() => {
 })
 
 watch(
-  () => props.date,
-  async (newDate, oldDate) => {
+  () => [props.date, props.isRealTime] as const,
+  async ([newDate, isRealTime], [oldDate, oldIsRealTime]) => {
     if (!newDate || newDate.length !== 2) return
 
-    if (oldDate && newDate[0] === oldDate[0] && newDate[1] === oldDate[1]) return
+    if (
+      oldDate &&
+      newDate[0] === oldDate[0] &&
+      newDate[1] === oldDate[1] &&
+      isRealTime === oldIsRealTime
+    ) {
+      return
+    }
 
     await cancelAllRequests()
 
     destroy()
 
-    const endTime = dayjs(newDate[1])
-    const now = dayjs()
-    const isRealTime = endTime.isAfter(now.subtract(1, 'minute'))
-
     if (chart) chart.clear()
 
     if (isRealTime) initfn(false)
@@ -705,13 +933,14 @@ function handleDetailClick() {
       name: props.deviceName,
       code: props.deviceCode,
       dept: props.deptName,
-      vehicle: props.vehicleName
+      vehicle: props.vehicleName,
+      mqttUrl: props.mqttUrl
     }
   })
 }
 </script>
 <template>
-  <div class="h-100 rounded-lg chart-container flex flex-col">
+  <div class="rounded-lg chart-container flex flex-col">
     <header class="chart-header justify-between">
       <div class="flex items-center">
         <div class="title-icon"></div>

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

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

+ 0 - 716
src/views/oli-connection/monitoring-board/chart2.vue

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

+ 0 - 717
src/views/oli-connection/monitoring-board/chart4.vue

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

+ 501 - 40
src/views/oli-connection/monitoring-board/index.vue

@@ -45,16 +45,22 @@ interface Query {
   time: string[]
 }
 
-const originalQuery: Query = {
-  deviceCodes: [],
-  time: [
+function getRealtimeTimeRange() {
+  return [
     dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
     dayjs().format('YYYY-MM-DD HH:mm:ss')
   ]
-  // time: [...rangeShortcuts[2].value().map((item) => dayjs(item).format('YYYY-MM-DD HH:mm:ss'))]
 }
 
-const query = ref<Query>({ ...originalQuery })
+function getOriginalQuery(): Query {
+  return {
+    deviceCodes: [],
+    time: getRealtimeTimeRange()
+  }
+}
+
+const query = ref<Query>(getOriginalQuery())
+const isRealTime = ref(true)
 
 const deptLoading = ref(false)
 const deptOptions = ref<any[]>([])
@@ -95,12 +101,12 @@ async function loadDeviceOptions() {
   deviceLoading.value = true
   try {
     const data = await IotDeviceApi.getBoardDevice({
-      deptId: deviceQuery.value.deptId,
+      deptId: [deviceQuery.value.deptId].flat().at(-1),
       ifInline: deviceQuery.value.ifInline,
       pageSize: 100
     })
     // const data = await IotDeviceApi.getIotDeviceTdPage({
-    //   deptId: deviceQuery.value.deptId,
+    //   deptId: [deviceQuery.value.deptId].flat().at(-1),
     //   ifInline: deviceQuery.value.ifInline,
     //   pageSize: 100
     // })
@@ -122,6 +128,70 @@ async function loadDeviceOptions() {
 
 const deviceList = ref<DeviceData[]>([])
 const chartOption = ref<EChartsOption>({})
+const pageSize = 5
+const pageIndex = ref(0)
+const activeMainCardId = ref<number | null>(null)
+const boardMotion = ref<'page-prev' | 'page-next' | 'promote' | ''>('')
+let boardMotionTimer: number | undefined
+
+const totalPages = computed(() => Math.max(1, Math.ceil(deviceList.value.length / pageSize)))
+
+const currentPageCards = computed(() => {
+  const start = pageIndex.value * pageSize
+  return deviceList.value.slice(start, start + pageSize)
+})
+
+const mainCard = computed(() => {
+  const matchedCard = currentPageCards.value.find((item) => item.id === activeMainCardId.value)
+  return matchedCard ?? currentPageCards.value[0] ?? null
+})
+
+const sideCards = computed(() => {
+  if (!mainCard.value) return [] as DeviceData[]
+  return currentPageCards.value.filter((item) => item.id !== mainCard.value?.id)
+})
+
+const boardMotionClass = computed(() => (boardMotion.value ? `is-${boardMotion.value}` : ''))
+
+function triggerBoardMotion(type: 'page-prev' | 'page-next' | 'promote') {
+  if (boardMotionTimer) {
+    window.clearTimeout(boardMotionTimer)
+  }
+
+  boardMotion.value = ''
+
+  nextTick(() => {
+    boardMotion.value = type
+    boardMotionTimer = window.setTimeout(() => {
+      boardMotion.value = ''
+    }, 520)
+  })
+}
+
+function syncPageMainCard() {
+  activeMainCardId.value = currentPageCards.value[0]?.id ?? null
+}
+
+function setMainCard(card: DeviceData) {
+  if (activeMainCardId.value === card.id) return
+
+  activeMainCardId.value = card.id
+  triggerBoardMotion('promote')
+}
+
+function goPrevGroup() {
+  if (pageIndex.value <= 0) return
+  pageIndex.value -= 1
+  syncPageMainCard()
+  triggerBoardMotion('page-prev')
+}
+
+function goNextGroup() {
+  if (pageIndex.value >= totalPages.value - 1) return
+  pageIndex.value += 1
+  syncPageMainCard()
+  triggerBoardMotion('page-next')
+}
 
 async function handleDeviceChange(selectedIds: number[]) {
   deviceList.value = deviceList.value.filter((d) => selectedIds.includes(d.id))
@@ -145,6 +215,15 @@ async function handleDeviceChange(selectedIds: number[]) {
       })
     }
   }
+
+  if (!deviceList.value.some((item) => item.id === activeMainCardId.value)) {
+    syncPageMainCard()
+  }
+
+  if (pageIndex.value > totalPages.value - 1) {
+    pageIndex.value = Math.max(totalPages.value - 1, 0)
+    syncPageMainCard()
+  }
 }
 
 onMounted(() => {
@@ -152,10 +231,19 @@ onMounted(() => {
   loadDeviceOptions()
 })
 
+onUnmounted(() => {
+  if (boardMotionTimer) {
+    window.clearTimeout(boardMotionTimer)
+  }
+})
+
 function handleDeptChange() {
-  query.value = { ...originalQuery }
+  query.value = getOriginalQuery()
+  isRealTime.value = true
   deviceList.value = []
   chartOption.value = {}
+  pageIndex.value = 0
+  activeMainCardId.value = null
   loadDeviceOptions()
 }
 
@@ -165,7 +253,10 @@ const { toggle, isFullscreen } = useFullscreen(targetArea)
 
 function handleRest() {
   deviceQuery.value = { ...originalDeviceQuery }
-  query.value = { ...originalQuery }
+  query.value = getOriginalQuery()
+  isRealTime.value = true
+  pageIndex.value = 0
+  activeMainCardId.value = null
   loadDeptOptions()
   loadDeviceOptions()
 }
@@ -174,7 +265,12 @@ function handleInlineChange() {
   loadDeviceOptions()
 }
 
+function handleTimeChange() {
+  isRealTime.value = false
+}
+
 const token = ref('')
+const showSearchDialog = ref(false)
 
 async function getToken() {
   const res = await IotDeviceApi.getToken()
@@ -190,7 +286,7 @@ onMounted(() => {
 <template>
   <div
     ref="targetArea"
-    class="relative w-full rounded-lg bg-[#020408] overflow-hidden min-h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
+    class="relative flex flex-col w-full rounded-lg bg-[#020408] overflow-hidden h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
   >
     <header
       class="relative w-full h-14 flex items-center justify-center select-none bg-[#0b1121] border-b border-white/5 shadow-lg"
@@ -204,13 +300,13 @@ onMounted(() => {
       <div class="absolute bottom-0 left-0 w-full h-[2px] bg-slate-800/50 overflow-hidden z-20">
         <div
           class="absolute top-0 bottom-0 w-[40%] bg-gradient-to-r from-transparent via-[#22d3ee] to-transparent shadow-[0_0_20px_#22d3ee] animate-scan-line"
-        ></div>
+        >
+        </div>
       </div>
 
       <div class="absolute left-6 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-80">
-        <div
-          class="w-1 h-4 bg-cyan-400 skew-x-[-12deg] shadow-[0_0_5px_rgba(34,211,238,0.8)]"
-        ></div>
+        <div class="w-1 h-4 bg-cyan-400 skew-x-[-12deg] shadow-[0_0_5px_rgba(34,211,238,0.8)]">
+        </div>
         <div class="w-1 h-3 bg-cyan-700 skew-x-[-12deg]"></div>
         <div class="w-1 h-2 bg-cyan-900 skew-x-[-12deg]"></div>
       </div>
@@ -230,13 +326,126 @@ onMounted(() => {
           监控看板
         </span>
       </h1>
+
+      <div class="absolute right-16 top-1/2 -translate-y-1/2 z-10 flex items-center gap-2">
+        <el-button size="default" class="custom-btn primary-btn" @click="showSearchDialog = true">
+          筛选
+        </el-button>
+        <el-button
+          size="default"
+          class="custom-btn primary-btn"
+          :type="isFullscreen ? 'info' : 'primary'"
+          :icon="isFullscreen ? Crop : FullScreen"
+          @click="toggle"
+        >
+          {{ isFullscreen ? '退出全屏' : '全屏' }}
+        </el-button>
+      </div>
     </header>
-    <div class="p-4">
-      <el-form size="default" class="search-container grid grid-cols-6 gap-6">
+
+    <div class="px-4 pt-4 pb-4 monitor-board-shell">
+      <div class="monitor-board-toolbar">
+        <div class="monitor-board-status">
+          <span>当前第 {{ pageIndex + 1 }} 组 / 共 {{ totalPages }} 组</span>
+          <span v-if="mainCard">主看板:{{ mainCard.deviceCode }}-{{ mainCard.deviceName }}</span>
+        </div>
+        <div class="flex items-center gap-3">
+          <el-button class="custom-btn reset-btn" :disabled="pageIndex === 0" @click="goPrevGroup">
+            上 5 个
+          </el-button>
+          <el-button
+            class="custom-btn reset-btn"
+            :disabled="pageIndex >= totalPages - 1"
+            @click="goNextGroup"
+          >
+            下 5 个
+          </el-button>
+        </div>
+      </div>
+
+      <div v-if="mainCard" class="monitor-board-scroll-area" :class="boardMotionClass">
+        <div class="monitor-board-layout">
+          <div
+            v-if="sideCards[0]"
+            class="monitor-card-shell monitor-card-side monitor-card-left-top"
+            @click="setMainCard(sideCards[0])"
+          >
+            <chart
+              :key="sideCards[0].id"
+              v-bind="sideCards[0]"
+              :date="query.time"
+              :is-real-time="isRealTime"
+              :token="token"
+            />
+          </div>
+
+          <div
+            v-if="sideCards[1]"
+            class="monitor-card-shell monitor-card-side monitor-card-left-bottom"
+            @click="setMainCard(sideCards[1])"
+          >
+            <chart
+              :key="sideCards[1].id"
+              v-bind="sideCards[1]"
+              :date="query.time"
+              :is-real-time="isRealTime"
+              :token="token"
+            />
+          </div>
+
+          <div class="monitor-card-shell monitor-card-main" @click="setMainCard(mainCard)">
+            <chart
+              :key="mainCard.id"
+              v-bind="mainCard"
+              :date="query.time"
+              :is-real-time="isRealTime"
+              :token="token"
+            />
+          </div>
+
+          <div
+            v-if="sideCards[2]"
+            class="monitor-card-shell monitor-card-side monitor-card-right-top"
+            @click="setMainCard(sideCards[2])"
+          >
+            <chart
+              :key="sideCards[2].id"
+              v-bind="sideCards[2]"
+              :date="query.time"
+              :is-real-time="isRealTime"
+              :token="token"
+            />
+          </div>
+
+          <div
+            v-if="sideCards[3]"
+            class="monitor-card-shell monitor-card-side monitor-card-right-bottom"
+            @click="setMainCard(sideCards[3])"
+          >
+            <chart
+              :key="sideCards[3].id"
+              v-bind="sideCards[3]"
+              :date="query.time"
+              :is-real-time="isRealTime"
+              :token="token"
+            />
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <el-dialog
+      v-model="showSearchDialog"
+      title="筛选条件"
+      width="1120px"
+      class="monitor-search-dialog"
+      destroy-on-close
+    >
+      <el-form size="default" class="search-container grid grid-cols-12 gap-6">
         <div
           class="absolute left-0 top-0 w-[2px] h-full bg-gradient-to-b from-transparent via-cyan-500 to-transparent"
         ></div>
-        <el-form-item class="col-span-1" label="部门">
+        <el-form-item class="col-span-3" label="部门">
           <el-cascader
             v-model="deviceQuery.deptId"
             :options="deptOptions"
@@ -249,7 +458,7 @@ onMounted(() => {
             placeholder="请选择部门"
           />
         </el-form-item>
-        <el-form-item class="col-span-1" label="在线状态">
+        <el-form-item class="col-span-3" label="在线状态">
           <el-select
             v-model="deviceQuery.ifInline"
             placeholder="请选择状态"
@@ -268,7 +477,7 @@ onMounted(() => {
             />
           </el-select>
         </el-form-item>
-        <el-form-item class="col-span-3" label="时间范围">
+        <el-form-item class="col-span-6 time-range-item" label="时间范围">
           <el-date-picker
             v-model="query.time"
             value-format="YYYY-MM-DD HH:mm:ss"
@@ -279,10 +488,11 @@ onMounted(() => {
             :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
             :teleported="false"
             popper-class="poper"
-            class="w-full"
+            class="w-full time-range-picker"
+            @change="handleTimeChange"
           />
         </el-form-item>
-        <el-form-item class="col-span-5 col-start-1" label="设备">
+        <el-form-item class="col-span-12" label="设备">
           <el-select
             v-model="query.deviceCodes"
             :options="deviceOptions"
@@ -296,27 +506,23 @@ onMounted(() => {
             @change="handleDeviceChange"
           />
         </el-form-item>
-        <el-form-item class="col-span-1 flex justify-end">
-          <div class="flex gap-3 w-full justify-end">
-            <el-button
-              class="custom-btn primary-btn"
-              :type="isFullscreen ? 'info' : 'primary'"
-              :icon="isFullscreen ? Crop : FullScreen"
-              @click="toggle"
-            >
-              {{ isFullscreen ? '退出全屏' : '全屏' }}
-            </el-button>
-            <el-button @click="handleRest" class="custom-btn reset-btn">重置</el-button>
-          </div>
-        </el-form-item>
       </el-form>
-    </div>
 
-    <div class="p-4 grid grid-cols-2 3xl:grid-cols-3 gap-6">
-      <template v-for="item in deviceList" :key="item.id">
-        <chart v-bind="item" :date="query.time" :token="token" />
+      <template #footer>
+        <div class="flex justify-end gap-3">
+          <el-button size="default" @click="handleRest" class="custom-btn reset-btn">
+            重置
+          </el-button>
+          <el-button
+            size="default"
+            class="custom-btn primary-btn"
+            @click="showSearchDialog = false"
+          >
+            关闭
+          </el-button>
+        </div>
       </template>
-    </div>
+    </el-dialog>
   </div>
 </template>
 
@@ -358,14 +564,199 @@ onMounted(() => {
   }
 }
 
+@keyframes board-page-next {
+  0% {
+    opacity: 0;
+    transform: translateX(56px) scale(0.985);
+  }
+
+  100% {
+    opacity: 1;
+    transform: translateX(0) scale(1);
+  }
+}
+
+@keyframes board-page-prev {
+  0% {
+    opacity: 0;
+    transform: translateX(-56px) scale(0.985);
+  }
+
+  100% {
+    opacity: 1;
+    transform: translateX(0) scale(1);
+  }
+}
+
+@keyframes card-promote-main {
+  0% {
+    opacity: 0.72;
+    transform: scale(0.94);
+  }
+
+  55% {
+    opacity: 1;
+    transform: scale(1.018);
+  }
+
+  100% {
+    opacity: 1;
+    transform: scale(1);
+  }
+}
+
+@keyframes card-promote-side {
+  0% {
+    opacity: 0.62;
+    transform: scale(1.04);
+  }
+
+  100% {
+    opacity: 1;
+    transform: scale(1);
+  }
+}
+
 .animate-scan-line {
   animation: scan-line 3s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
 }
 
+.monitor-board-shell {
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: 100%;
+  flex: 1;
+  min-height: 0;
+}
+
+.monitor-board-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16px;
+  gap: 16px;
+}
+
+.monitor-board-status {
+  display: flex;
+  font-size: 14px;
+  color: rgb(165 243 252 / 90%);
+  flex-wrap: wrap;
+  gap: 16px;
+}
+
+.monitor-board-layout {
+  display: grid;
+  width: max-content;
+  height: 100%;
+  padding-bottom: 8px;
+  flex: 1;
+  grid-template-columns: 760px 1500px 760px;
+  grid-template-rows: repeat(2, minmax(0, 1fr));
+  gap: 18px;
+}
+
+.monitor-board-scroll-area {
+  height: 100%;
+  min-height: 0;
+  padding: 8px;
+  overflow: auto hidden;
+  flex: 1 1 0;
+}
+
+.monitor-board-scroll-area::-webkit-scrollbar {
+  height: 10px;
+}
+
+.monitor-board-scroll-area::-webkit-scrollbar-thumb {
+  background: rgb(34 211 238 / 70%);
+  border-radius: 999px;
+}
+
+.monitor-board-scroll-area::-webkit-scrollbar-track {
+  background: rgb(255 255 255 / 6%);
+}
+
+.monitor-card-shell {
+  height: 100%;
+  min-width: 0;
+  min-height: 0;
+  cursor: pointer;
+  will-change: transform, opacity;
+  transition:
+    transform 0.25s ease,
+    box-shadow 0.25s ease,
+    border-color 0.25s ease;
+}
+
+.monitor-card-shell:hover {
+  transform: translateY(-2px);
+}
+
+.monitor-board-scroll-area.is-page-next .monitor-board-layout {
+  animation: board-page-next 0.46s cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+.monitor-board-scroll-area.is-page-prev .monitor-board-layout {
+  animation: board-page-prev 0.46s cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+.monitor-board-scroll-area.is-promote .monitor-card-main {
+  animation: card-promote-main 0.48s cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+.monitor-board-scroll-area.is-promote .monitor-card-side {
+  animation: card-promote-side 0.38s cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+.monitor-card-main {
+  grid-column: 2;
+  grid-row: 1 / span 2;
+  flex: 1;
+  height: 100%;
+}
+
+.monitor-card-left-top {
+  grid-column: 1;
+  grid-row: 1;
+}
+
+.monitor-card-left-bottom {
+  grid-column: 1;
+  grid-row: 2;
+}
+
+.monitor-card-right-top {
+  grid-column: 3;
+  grid-row: 1;
+}
+
+.monitor-card-right-bottom {
+  grid-column: 3;
+  grid-row: 2;
+}
+
+.monitor-card-shell :deep(.h-100) {
+  height: 100%;
+}
+
+.monitor-card-shell :deep(.chart-container) {
+  height: 100%;
+}
+
+.monitor-board-layout :deep(.chart-container) {
+  min-width: 0;
+}
+
+.monitor-card-shell :deep(.chart-header) {
+  cursor: pointer;
+}
+
 .search-container {
   position: relative;
   padding: 24px;
-  background: linear-gradient(135deg, rgb(11 17 33 / 90%) 0%, rgb(6 9 18 / 95%) 100%);
+  background: linear-gradient(135deg, rgb(11 17 33 / 96%) 0%, rgb(6 9 18 / 98%) 100%);
   border: 1px solid rgb(34 211 238 / 60%);
   border-radius: 4px;
   box-shadow: 0 10px 40px rgb(0 0 0 / 60%);
@@ -510,3 +901,73 @@ onMounted(() => {
   border-color: rgb(255 255 255 / 50%);
 }
 </style>
+
+<style>
+.monitor-search-dialog.el-dialog {
+  background: linear-gradient(180deg, rgb(6 9 18 / 98%) 0%, rgb(11 17 33 / 98%) 100%);
+  border: 1px solid rgb(34 211 238 / 35%);
+  box-shadow:
+    0 0 0 1px rgb(34 211 238 / 12%),
+    0 24px 80px rgb(0 0 0 / 75%);
+}
+
+.monitor-search-dialog .el-dialog__header {
+  padding: 18px 24px 14px;
+  margin-right: 0;
+  border-bottom: 1px solid rgb(34 211 238 / 16%);
+}
+
+.monitor-search-dialog .el-dialog__title {
+  font-weight: 600;
+  letter-spacing: 0.08em;
+  color: rgb(165 243 252);
+}
+
+.monitor-search-dialog .el-dialog__headerbtn .el-dialog__close {
+  color: rgb(148 163 184);
+}
+
+.monitor-search-dialog .el-dialog__body {
+  padding: 20px 24px 16px;
+  background: transparent;
+}
+
+.monitor-search-dialog .el-dialog__footer {
+  padding: 0 24px 12px;
+  background: transparent;
+}
+
+.monitor-search-dialog .el-form-item__label {
+  white-space: nowrap;
+}
+
+.time-range-item {
+  min-width: 0;
+}
+
+.time-range-picker {
+  min-width: 0;
+}
+
+.monitor-search-dialog .el-date-editor.el-input__wrapper,
+.monitor-search-dialog .el-select__wrapper,
+.monitor-search-dialog .el-cascader .el-input__wrapper {
+  width: 100%;
+  min-width: 0;
+}
+
+.monitor-search-dialog .el-date-editor--datetimerange {
+  width: 100%;
+  min-width: 0;
+}
+
+.monitor-search-dialog .el-date-editor .el-range-separator {
+  flex: 0 0 auto;
+  padding: 0 8px;
+  color: rgb(165 243 252);
+}
+
+.monitor-search-dialog .el-date-editor .el-range-input {
+  min-width: 0;
+}
+</style>

+ 7 - 10
src/views/oli-connection/monitoring/detail.vue

@@ -79,7 +79,7 @@ const handleMessageUpdate = (_topic: string, data: any) => {
 }
 
 watch(isConnected, (newVal) => {
-  if (newVal) {
+  if (newVal && data.value.mqttUrl) {
     subscribe(data.value.mqttUrl as string)
   }
 })
@@ -466,16 +466,12 @@ const chartLoading = ref(false)
 
 const token = ref('')
 
-async function getToken() {
-  const res = await IotDeviceApi.getToken()
+async function ensureToken() {
+  if (token.value) return
 
-  token.value = res
+  token.value = await IotDeviceApi.getToken()
 }
 
-onMounted(() => {
-  getToken()
-})
-
 async function initLoadChartData(real_time: boolean = true) {
   if (!dimensions.value.length) return
 
@@ -516,7 +512,8 @@ async function initLoadChartData(real_time: boolean = true) {
     }
   }
 
-  if (real_time) {
+  if (real_time && data.value.mqttUrl) {
+    await ensureToken()
     connect(`wss://aims.deepoil.cc/mqtt`, { password: token.value }, handleMessageUpdate)
   }
 }
@@ -524,7 +521,7 @@ async function initLoadChartData(real_time: boolean = true) {
 async function initfn(load: boolean = true, real_time: boolean = true) {
   if (load) await loadDimensions()
   render()
-  initLoadChartData(real_time)
+  await initLoadChartData(real_time)
 }
 
 onMounted(() => {

+ 82 - 3
src/views/pms/iotprojecttask/index.vue

@@ -208,6 +208,14 @@
             >
               计划
             </el-button>
+            <el-button
+              v-if="Number(scope.row.deptId) === REPORT_DEPT_ID"
+              link
+              type="primary"
+              @click="openGenerateReportDialog(scope.row)"
+            >
+              生成日报
+            </el-button>
             <el-button
               link
               type="primary"
@@ -312,6 +320,36 @@
       </span>
     </template>
   </el-dialog>
+
+  <!-- 生成日报 Dialog -->
+  <el-dialog v-model="generateReportDialogVisible" title="生成日报" width="420px">
+    <el-form size="default" label-width="80px">
+      <el-form-item label="日报日期" required>
+        <el-date-picker
+          v-model="reportDate"
+          type="date"
+          placeholder="请选择日期"
+          value-format="YYYY-MM-DD"
+          format="YYYY-MM-DD"
+          class="w-full!"
+        />
+      </el-form-item>
+    </el-form>
+
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button size="default" @click="generateReportDialogVisible = false">取消</el-button>
+        <el-button
+          size="default"
+          type="primary"
+          @click="submitGenerateReport"
+          :loading="generateReportLoading"
+        >
+          确定
+        </el-button>
+      </span>
+    </template>
+  </el-dialog>
 </template>
 
 <script setup lang="ts">
@@ -366,6 +404,7 @@ const exportLoading = ref(false) // 导出的加载中
 const { push } = useRouter() // 路由跳转
 
 const COMPLETED_STATUS = 'wg'
+const REPORT_DEPT_ID = 163
 
 // 表格引用
 const tableRef = ref()
@@ -385,7 +424,7 @@ const columnWidths = ref({
   technique: '100px',
   workloadDesign: '100px',
   createTime: '150px',
-  operation: '200px'
+  operation: '260px'
 })
 
 // 计算文本宽度
@@ -418,6 +457,12 @@ const companyDeptList = ref<any[]>([]) // 在公司级部门列表
 const wellTypeDictOptions = ref<any[]>([]) // 井型字典选项
 const technologyDictOptions = ref<any[]>([]) // 施工工艺字典选项
 
+// 生成日报相关状态
+const generateReportDialogVisible = ref(false)
+const generateReportLoading = ref(false)
+const generateReportRow = ref<IotProjectTaskVO | null>(null)
+const reportDate = ref('')
+
 /** 获取井型字典数据 */
 const getWellTypeDictOptions = async () => {
   try {
@@ -571,6 +616,40 @@ const savePlan = async () => {
   }
 }
 
+/** 打开生成日报对话框 */
+const openGenerateReportDialog = (row: IotProjectTaskVO) => {
+  generateReportRow.value = row
+  reportDate.value = ''
+  generateReportDialogVisible.value = true
+}
+
+/** 生成日报 */
+const submitGenerateReport = async () => {
+  if (!generateReportRow.value?.id) {
+    message.error('未找到任务信息')
+    return
+  }
+  if (!reportDate.value) {
+    message.error('请选择日报日期')
+    return
+  }
+
+  try {
+    generateReportLoading.value = true
+    await IotProjectTaskApi.generateReport({
+      id: generateReportRow.value.id,
+      reportDate: dayjs(reportDate.value).valueOf()
+    })
+    message.success('生成日报成功')
+    generateReportDialogVisible.value = false
+  } catch (error) {
+    message.error('生成日报失败')
+    console.error('生成日报失败:', error)
+  } finally {
+    generateReportLoading.value = false
+  }
+}
+
 /** 查询列表 */
 const getList = async () => {
   loading.value = true
@@ -663,8 +742,8 @@ const calculateColumnWidths = () => {
   )
 
   // 操作列固定宽度
-  minWidths.operation = 200
-  totalMinWidth += 200
+  minWidths.operation = 260
+  totalMinWidth += 260
 
   // 2. 计算可伸缩列最终宽度
   const newWidths: Record<string, string> = {}

+ 551 - 0
src/views/pms/iotrddailyreport/components/DailyStatistics.vue

@@ -0,0 +1,551 @@
+<script setup lang="ts">
+import dayjs from 'dayjs'
+import { IotRdDailyReportApi } from '@/api/pms/iotrddailyreport'
+import { useDebounceFn } from '@vueuse/core'
+import CountTo from '@/components/count-to1.vue'
+import * as echarts from 'echarts'
+import { Motion, AnimatePresence } from 'motion-v'
+import download from '@/utils/download'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+
+interface Query {
+  pageNo: number
+  pageSize: number
+  deptId: number
+  contractName?: string
+  taskName?: string
+  createTime: string[]
+}
+
+const props = defineProps<{
+  query: Query
+  deptName: string
+  refreshKey: number
+}>()
+
+const totalWorkKeys: [string, string | undefined, string, string, number][] = [
+  [
+    'cumulativeBridgePlug',
+    undefined,
+    '桥塞',
+    'i-material-symbols:check-circle-outline-rounded text-emerald',
+    0
+  ],
+  [
+    'cumulativeRunCount',
+    undefined,
+    '趟数',
+    'i-material-symbols:check-circle-outline-rounded text-emerald',
+    0
+  ],
+  [
+    'cumulativeWorkingWell',
+    undefined,
+    '井数',
+    'i-material-symbols:check-circle-outline-rounded text-emerald',
+    0
+  ],
+  [
+    'cumulativeWorkingLayers',
+    undefined,
+    '段数',
+    'i-material-symbols:check-circle-outline-rounded text-emerald',
+    0
+  ],
+  [
+    'cumulativeHourCount',
+    undefined,
+    '小时(H)',
+    'i-material-symbols:nest-clock-farsight-analog-outline-rounded text-emerald',
+    2
+  ],
+  [
+    'cumulativeWaterVolume',
+    '方',
+    '水方量',
+    'i-material-symbols:water-drop-outline-rounded text-sky',
+    2
+  ],
+  ['taici', undefined, '台次', 'i-material-symbols:check-circle-outline-rounded text-emerald', 0],
+  [
+    'utilizationRate',
+    '%',
+    '设备利用率',
+    'i-material-symbols:check-circle-outline-rounded text-emerald',
+    0
+  ],
+  [
+    'cumulativeFuels',
+    '万升',
+    '累计油耗',
+    'i-material-symbols:directions-car-outline-rounded text-sky',
+    2
+  ]
+]
+
+const totalWork = ref<Record<string, number>>({
+  cumulativeFuels: 0,
+  taici: 0,
+  cumulativeBridgePlug: 0,
+  cumulativeRunCount: 0,
+  cumulativeWorkingWell: 0,
+  cumulativeWorkingLayers: 0,
+  cumulativeHourCount: 0,
+  cumulativeWaterVolume: 0,
+  utilizationRate: 0
+})
+
+const totalLoading = ref(false)
+
+const getQueryWithoutPage = () => {
+  const { pageNo: _pageNo, pageSize: _pageSize, ...other } = props.query
+
+  void _pageNo
+  void _pageSize
+
+  return other
+}
+
+const getTotal = useDebounceFn(async () => {
+  totalLoading.value = true
+
+  try {
+    const res2 = await IotRdDailyReportApi.totalWorkload(getQueryWithoutPage())
+
+    totalWork.value = {
+      ...totalWork.value,
+      taici: res2.taici || 0,
+      cumulativeBridgePlug: res2.cumulativeBridgePlug || 0,
+      cumulativeRunCount: res2.cumulativeRunCount || 0,
+      cumulativeWorkingWell: res2.cumulativeWorkingWell || 0,
+      cumulativeWorkingLayers: res2.cumulativeWorkingLayers || 0,
+      cumulativeHourCount: res2.cumulativeHourCount || 0,
+      cumulativeWaterVolume: res2.cumulativeWaterVolume || 0,
+      ...res2,
+      cumulativeFuels: (res2.cumulativeFuels || 0) / 10000,
+      utilizationRate: Number(((res2.utilizationRate || 0) * 100).toFixed(2))
+    }
+  } finally {
+    totalLoading.value = false
+  }
+}, 500)
+
+interface List {
+  id: number | null
+  name: string | null
+  type: '1' | '2' | '3'
+  cumulativeBridgePlug: number | null
+  cumulativeRunCount: number | null
+  cumulativeWorkingWell: number | null
+  cumulativeHourCount: number | null
+  totalDailyFuel: number | null
+  cumulativeWaterVolume: number | null
+  cumulativeWorkingLayers: number | null
+  cumulativePumpTrips: number | null
+  cumulativeMixSand: number | null
+  utilizationRate: number | null
+}
+
+const list = ref<List[]>([])
+
+const listLoading = ref(false)
+
+const getList = useDebounceFn(async () => {
+  listLoading.value = true
+  try {
+    const res = await IotRdDailyReportApi.getIotRdDailyReportSummary(props.query)
+    const reslist = res?.list || []
+
+    list.value = reslist.map(
+      ({ projectDeptId, projectDeptName, teamId, teamName, type, ...other }) => {
+        return {
+          id: type === '2' ? projectDeptId : teamId,
+          name: type === '2' ? projectDeptName : teamName,
+          ...other,
+          cumulativeBridgePlug: other.cumulativeBridgePlug || 0,
+          cumulativeRunCount: other.cumulativeRunCount || 0,
+          cumulativeWorkingWell: other.cumulativeWorkingWell || 0,
+          cumulativeHourCount: other.cumulativeHourCount || 0,
+          totalDailyFuel: other.totalDailyFuel || 0,
+          cumulativeWaterVolume: other.cumulativeWaterVolume || 0,
+          cumulativeWorkingLayers: other.cumulativeWorkingLayers || 0,
+          cumulativePumpTrips: other.cumulativePumpTrips || 0,
+          cumulativeMixSand: other.cumulativeMixSand || 0,
+          utilizationRate: other.utilizationRate || 0
+        }
+      }
+    )
+  } finally {
+    listLoading.value = false
+  }
+}, 500)
+
+const tab = ref<'表格' | '看板'>('表格')
+
+const currentTab = ref<'表格' | '看板'>('表格')
+
+const direction = ref<'left' | 'right'>('right')
+
+const handleSelectTab = (val: '表格' | '看板') => {
+  tab.value = val
+  direction.value = val === '看板' ? 'right' : 'left'
+  nextTick(() => {
+    currentTab.value = val
+    setTimeout(() => {
+      render()
+    })
+  })
+}
+
+const chartRef = ref<HTMLDivElement | null>(null)
+let chart: echarts.ECharts | null = null
+
+const xAxisData = ref<string[]>([])
+
+const legend = ref<string[][]>([
+  ['个数', 'cumulativeBridgePlug'],
+  ['井数', 'cumulativeWorkingWell'],
+  ['小时 (H)', 'cumulativeHourCount'],
+  ['油耗 (万升)', 'cumulativeFuels'],
+  ['水方量 (方)', 'cumulativeWaterVolume'],
+  ['台次(泵车)', 'cumulativePumpTrips'],
+  ['段数', 'cumulativeWorkingLayers'],
+  ['台次(仪表/混砂)', 'cumulativeMixSand'],
+  ['设备利用率 (%)', 'utilizationRate']
+])
+
+const chartData = ref<Record<string, number[]>>({
+  cumulativeBridgePlug: [],
+  cumulativeWorkingWell: [],
+  cumulativeHourCount: [],
+  cumulativeFuels: [],
+  cumulativeWaterVolume: [],
+  cumulativeWorkingLayers: [],
+  cumulativePumpTrips: [],
+  cumulativeMixSand: [],
+  utilizationRate: []
+})
+
+let chartLoading = ref(false)
+
+const getChart = useDebounceFn(async () => {
+  chartLoading.value = true
+
+  try {
+    const res = await IotRdDailyReportApi.getIotRdDailyReportSummaryPolyline(props.query)
+
+    chartData.value = {
+      cumulativeBridgePlug: res.map((item) => item.cumulativeBridgePlug || 0),
+      cumulativeWorkingWell: res.map((item) => item.cumulativeWorkingWell || 0),
+      cumulativeHourCount: res.map((item) => item.cumulativeHourCount || 0),
+      cumulativeFuels: res.map((item) => (item.cumulativeFuels || 0) / 10000),
+      cumulativeWaterVolume: res.map((item) => item.cumulativeWaterVolume || 0),
+      cumulativeWorkingLayers: res.map((item) => item.cumulativeWorkingLayers || 0),
+      cumulativePumpTrips: res.map((item) => item.cumulativePumpTrips || 0),
+      cumulativeMixSand: res.map((item) => item.cumulativeMixSand || 0),
+      utilizationRate: res.map((item) => Number(((item.utilizationRate || 0) * 100).toFixed(2)))
+    }
+
+    xAxisData.value = res.map((item) => item.reportDate || '')
+  } finally {
+    chartLoading.value = false
+  }
+}, 500)
+
+const resizer = () => {
+  chart?.resize()
+}
+
+onUnmounted(() => {
+  window.removeEventListener('resize', resizer)
+  chart?.dispose()
+})
+
+const render = () => {
+  if (!chartRef.value) return
+
+  chart?.dispose()
+  chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
+
+  window.removeEventListener('resize', resizer)
+  window.addEventListener('resize', resizer)
+
+  const values: number[] = []
+
+  for (const [_name, key] of legend.value) {
+    values.push(...(chartData.value[key] || []))
+  }
+
+  const maxVal = values.length === 0 ? 10000 : Math.max(...values)
+  const minVal = values.length === 0 ? 0 : Math.min(...values) > 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)
+
+  const maxInterval = interval
+  const minInterval = minDigits
+
+  const intervalArr = [0]
+  for (let i = 1; i <= interval; i++) {
+    intervalArr.push(Math.pow(10, i))
+  }
+
+  chart.setOption({
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'line'
+      },
+      formatter: (params) => {
+        let d = `${params[0].axisValueLabel}<br>`
+        let item = params.map((el) => {
+          const key = legend.value[el.componentIndex]?.[1] || ''
+          const value = chartData.value[key]?.[el.dataIndex] || 0
+
+          return `<div class="flex items-center justify-between mt-1 gap-1">
+            <span>${el.marker} ${el.seriesName}</span>
+            <span>${value.toFixed(2)} ${el.seriesName.split(' ')[1] ?? ''}</span>
+          </div>`
+        })
+
+        return d + item.join('')
+      }
+    },
+    legend: {
+      data: legend.value.map(([name]) => name),
+      show: true
+    },
+    xAxis: {
+      type: 'category',
+      data: xAxisData.value
+    },
+    yAxis: {
+      type: 'value',
+      min: -minInterval,
+      max: maxInterval,
+      interval: 1,
+      axisLabel: {
+        formatter: (v) => {
+          const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
+
+          return num.toLocaleString()
+        }
+      }
+    },
+    series: legend.value.map(([name, key]) => ({
+      name,
+      type: 'line',
+      smooth: true,
+      showSymbol: true,
+      data: (chartData.value[key] || []).map((value) => {
+        if (value === 0) return 0
+
+        const isPositive = value > 0
+        const absItem = Math.abs(value)
+
+        const min_value = Math.max(...intervalArr.filter((v) => v <= absItem))
+        const min_index = intervalArr.findIndex((v) => v === min_value)
+
+        const new_value =
+          (absItem - min_value) / (intervalArr[min_index + 1] - intervalArr[min_index]) + min_index
+
+        return isPositive ? new_value : -new_value
+      })
+    }))
+  })
+}
+
+const handleQuery = () => {
+  getChart().then(() => {
+    nextTick(() => {
+      render()
+    })
+  })
+  getList()
+  getTotal()
+}
+
+watch(
+  () => [
+    props.refreshKey,
+    props.query.deptId,
+    props.query.contractName,
+    props.query.taskName,
+    props.query.createTime?.[0],
+    props.query.createTime?.[1]
+  ],
+  () => {
+    handleQuery()
+  },
+  { immediate: true }
+)
+
+const exportChart = () => {
+  if (!chart) return
+  let img = new Image()
+  img.src = chart.getDataURL({
+    type: 'png',
+    pixelRatio: 1,
+    backgroundColor: '#fff'
+  })
+
+  img.onload = function () {
+    let canvas = document.createElement('canvas')
+    canvas.width = img.width
+    canvas.height = img.height
+    let ctx = canvas.getContext('2d')
+    ctx?.drawImage(img, 0, 0)
+    let dataURL = canvas.toDataURL('image/png')
+
+    let a = document.createElement('a')
+
+    let event = new MouseEvent('click')
+
+    a.href = dataURL
+    a.download = `瑞都日报统计数据.png`
+    a.dispatchEvent(event)
+  }
+}
+
+const exportData = async () => {
+  const res = await IotRdDailyReportApi.exportRdDailyReportStatistics(props.query)
+  download.excel(res, '瑞都日报统计数据.xlsx')
+}
+
+const exportAll = async () => {
+  if (tab.value === '看板') exportChart()
+  else exportData()
+}
+
+const router = useRouter()
+
+const tolist = (id: number | null) => {
+  if (!id) return
+
+  router.push({
+    path: '/iotdayilyreport/IotRdDailyReport',
+    query: {
+      ...getQueryWithoutPage(),
+      deptId: id
+    }
+  })
+}
+
+const { ZmTable, ZmTableColumn } = useTableComponents()
+</script>
+
+<template>
+  <div class="grid grid-rows-[128px_1fr] gap-4 h-full min-h-0">
+    <div class="grid grid-cols-9 gap-8" v-loading="totalLoading">
+      <div
+        v-for="info in totalWorkKeys"
+        :key="info[0]"
+        class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow p-1 flex flex-col items-center justify-center gap-1"
+      >
+        <div class="size-7.5" :class="info[3]"></div>
+        <count-to
+          class="text-2xl font-medium"
+          :start-val="0"
+          :end-val="totalWork[info[0]]"
+          :decimals="info[4]"
+        >
+          <span class="text-xs leading-8 text-[var(--el-text-color-regular)]">暂无数据</span>
+        </count-to>
+        <div class="text-sm font-medium text-[var(--el-text-color-regular)] whitespace-nowrap">
+          {{ info[1] ? info[2] + '(' + info[1] + ')' : info[2] }}
+        </div>
+      </div>
+    </div>
+
+    <div class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow flex flex-col p-4 gap-2 min-h-0">
+      <div class="flex h-12 items-center justify-between">
+        <el-button-group>
+          <el-button
+            size="default"
+            :type="tab === '表格' ? 'primary' : 'default'"
+            @click="handleSelectTab('表格')"
+            >表格
+          </el-button>
+          <el-button
+            size="default"
+            :type="tab === '看板' ? 'primary' : 'default'"
+            @click="handleSelectTab('看板')"
+            >看板
+          </el-button>
+        </el-button-group>
+        <h3 class="text-xl font-medium">{{ `${deptName}-${tab}` }}</h3>
+        <el-button size="default" type="primary" @click="exportAll">导出</el-button>
+      </div>
+      <div class="flex-1 relative min-h-0">
+        <el-auto-resizer class="absolute">
+          <template #default="{ height }">
+            <Motion
+              as="div"
+              :style="{ position: 'relative', overflow: 'hidden' }"
+              :animate="{ height: `${height}px`, width: `100%` }"
+              :transition="{ type: 'spring', stiffness: 200, damping: 25, duration: 0.3 }"
+            >
+              <AnimatePresence :initial="false" mode="sync">
+                <Motion
+                  :key="currentTab"
+                  as="div"
+                  :initial="{ x: direction === 'left' ? '-100%' : '100%', opacity: 0 }"
+                  :animate="{ x: '0%', opacity: 1 }"
+                  :exit="{ x: direction === 'left' ? '50%' : '-50%', opacity: 0 }"
+                  :transition="{ type: 'tween', stiffness: 300, damping: 30, duration: 0.3 }"
+                  :style="{ position: 'absolute', left: 0, right: 0, top: 0 }"
+                >
+                  <div :style="{ width: `100%`, height: `${height}px` }">
+                    <zm-table
+                      v-if="currentTab === '表格'"
+                      :loading="listLoading"
+                      :data="list"
+                      :height="height"
+                      show-border
+                    >
+                      <zm-table-column label="部门" prop="name">
+                        <template #default="{ row }">
+                          <el-button text type="primary" @click.prevent="tolist(row.id)">{{
+                            row.name
+                          }}</el-button>
+                        </template>
+                      </zm-table-column>
+                      <zm-table-column label="油耗(万升)" prop="totalDailyFuel" />
+                      <zm-table-column label="桥塞" prop="cumulativeBridgePlug" />
+                      <zm-table-column label="趟数" prop="cumulativeRunCount" />
+                      <zm-table-column label="井数" prop="cumulativeWorkingWell" />
+                      <zm-table-column label="段数" prop="cumulativeWorkingLayers" />
+                      <zm-table-column label="小时(H)" prop="cumulativeHourCount" />
+                      <zm-table-column label="水方量(方)" prop="cumulativeWaterVolume" />
+                      <zm-table-column label="台次">
+                        <zm-table-column label="泵车" prop="cumulativePumpTrips" />
+                        <zm-table-column label="仪表/混砂" prop="cumulativeMixSand" />
+                      </zm-table-column>
+                      <zm-table-column
+                        label="设备利用率"
+                        prop="utilizationRate"
+                        cover-formatter
+                        :real-value="
+                          (row: List) => (Number(row.utilizationRate ?? 0) * 100).toFixed(2) + '%'
+                        "
+                      />
+                    </zm-table>
+                    <div
+                      ref="chartRef"
+                      v-loading="chartLoading"
+                      :key="dayjs().valueOf()"
+                      v-else
+                      :style="{ width: `100%`, height: `${height}px` }"
+                    >
+                    </div>
+                  </div>
+                </Motion>
+              </AnimatePresence>
+            </Motion>
+          </template>
+        </el-auto-resizer>
+      </div>
+    </div>
+  </div>
+</template>

+ 314 - 0
src/views/pms/iotrddailyreport/components/NonProductionEfficiency.vue

@@ -0,0 +1,314 @@
+<script setup lang="ts">
+import { IotRhDailyReportApi } from '@/api/pms/iotrhdailyreport'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { useDebounceFn } from '@vueuse/core'
+import * as echarts from 'echarts'
+
+interface Query {
+  pageNo: number
+  pageSize: number
+  deptId: number
+  contractName?: string
+  taskName?: string
+  createTime: string[]
+}
+
+const props = defineProps<{
+  query: Query
+  deptName: string
+  refreshKey: number
+}>()
+
+interface ListItem {
+  id?: number
+  teamName?: string
+  projectDeptName?: string
+  accidentTime: number
+  repairTime: number
+  selfStopTime: number
+  complexityTime: number
+  relocationTime: number
+  rectificationTime: number
+  waitingStopTime: number
+  winterBreakTime: number
+  partyaDesign: number
+  partyaPrepare: number
+  partyaResource: number
+  otherNptTime: number
+  nptTotal: number
+  nptRate: number
+  calendarTime: number
+}
+
+const list = ref<ListItem[]>([])
+const loading = ref(false)
+const tab = ref<'表格' | '看板'>('表格')
+const chartRef = ref<HTMLDivElement | null>(null)
+let chart: echarts.ECharts | null = null
+
+const nonProductionTimeFields: [keyof ListItem, string][] = [
+  ['accidentTime', '工程质量'],
+  ['repairTime', '设备故障'],
+  ['selfStopTime', '设备保养'],
+  ['complexityTime', '技术受限'],
+  ['relocationTime', '生产配合'],
+  ['rectificationTime', '生产组织'],
+  ['waitingStopTime', '不可抗力'],
+  ['winterBreakTime', '待命'],
+  ['partyaDesign', '甲方设计'],
+  ['partyaPrepare', '甲方准备'],
+  ['partyaResource', '甲方资源'],
+  ['otherNptTime', '其它']
+]
+
+const getQueryWithoutPage = () => {
+  const { pageNo: _pageNo, pageSize: _pageSize, ...query } = props.query
+
+  void _pageNo
+  void _pageSize
+
+  return query
+}
+
+const normalizeListData = (data: any) => {
+  if (Array.isArray(data)) {
+    return data
+  }
+
+  return data?.list || data?.records || []
+}
+
+const loadList = useDebounceFn(async function () {
+  loading.value = true
+  try {
+    const data = await IotRhDailyReportApi.nptStatistics(getQueryWithoutPage())
+
+    list.value = normalizeListData(data)
+    if (tab.value === '看板') {
+      nextTick(renderChart)
+    }
+  } finally {
+    loading.value = false
+  }
+}, 500)
+
+function handleQuery() {
+  loadList()
+}
+
+watch(
+  () => [
+    props.refreshKey,
+    props.query.deptId,
+    props.query.contractName,
+    props.query.taskName,
+    props.query.createTime?.[0],
+    props.query.createTime?.[1]
+  ],
+  () => {
+    handleQuery()
+  },
+  { immediate: true }
+)
+
+const handleSelectTab = (val: '表格' | '看板') => {
+  tab.value = val
+
+  if (val === '看板') {
+    nextTick(renderChart)
+  } else {
+    chart?.dispose()
+    chart = null
+  }
+}
+
+const formatNumber = (value: unknown) => {
+  const num = Number(value || 0)
+  return Number.isInteger(num) ? `${num}` : num.toFixed(2)
+}
+
+const formatTeamName = (row: ListItem) => {
+  return row.teamName || row.projectDeptName || '-'
+}
+
+const formatRate = (row: ListItem) => {
+  return `${(Number(row.nptRate || 0) * 100).toFixed(2)}%`
+}
+
+const getPieData = () => {
+  return list.value
+    .map((row) => ({
+      name: formatTeamName(row),
+      value: Number(row.nptTotal || 0)
+    }))
+    .filter((item) => item.value > 0)
+}
+
+const resizeChart = () => {
+  chart?.resize()
+}
+
+const renderChart = () => {
+  if (!chartRef.value) return
+
+  chart?.dispose()
+  chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
+
+  window.removeEventListener('resize', resizeChart)
+  window.addEventListener('resize', resizeChart)
+
+  const pieData = getPieData()
+
+  chart.setOption({
+    tooltip: {
+      trigger: 'item',
+      formatter: '{b}<br/>NPT合计: {c} H<br/>占比: {d}%'
+    },
+    legend: {
+      type: 'scroll',
+      orient: 'vertical',
+      right: 24,
+      top: 32,
+      bottom: 24
+    },
+    graphic:
+      pieData.length === 0
+        ? {
+            type: 'text',
+            left: 'center',
+            top: 'middle',
+            style: {
+              text: '暂无NPT数据',
+              fill: '#909399',
+              fontSize: 14
+            }
+          }
+        : undefined,
+    series: [
+      {
+        name: 'NPT合计',
+        type: 'pie',
+        radius: ['42%', '68%'],
+        center: ['50%', '50%'],
+        avoidLabelOverlap: true,
+        itemStyle: {
+          borderRadius: 4,
+          borderColor: '#fff',
+          borderWidth: 2
+        },
+        label: {
+          formatter: '{b}: {d}%'
+        },
+        data: pieData
+      }
+    ]
+  })
+}
+
+onUnmounted(() => {
+  window.removeEventListener('resize', resizeChart)
+  chart?.dispose()
+})
+
+const { ZmTable, ZmTableColumn } = useTableComponents<ListItem>()
+</script>
+
+<template>
+  <div class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow flex flex-col p-4 gap-2 h-full min-h-0">
+    <div class="flex h-12 items-center justify-between">
+      <el-button-group>
+        <el-button
+          size="default"
+          :type="tab === '表格' ? 'primary' : 'default'"
+          @click="handleSelectTab('表格')"
+          >表格
+        </el-button>
+        <el-button
+          size="default"
+          :type="tab === '看板' ? 'primary' : 'default'"
+          @click="handleSelectTab('看板')"
+          >看板
+        </el-button>
+      </el-button-group>
+      <!-- <h3 class="text-xl font-medium">{{ `${deptName}-${tab}` }}</h3> -->
+      <div class="w-80px"></div>
+    </div>
+
+    <div class="flex-1 relative min-h-0">
+      <el-auto-resizer class="absolute">
+        <template #default="{ width, height }">
+          <zm-table
+            v-if="tab === '表格'"
+            :data="list"
+            :loading="loading"
+            :width="width"
+            :max-height="height"
+            :height="height"
+            show-border
+          >
+            <zm-table-column
+              prop="teamName"
+              label="队伍"
+              min-width="120"
+              fixed="left"
+              cover-formatter
+              :real-value="formatTeamName"
+            />
+            <zm-table-column
+              v-for="[prop, label] in nonProductionTimeFields"
+              :key="prop"
+              :prop="prop"
+              :label="label"
+              min-width="92"
+              cover-formatter
+              :real-value="(row: ListItem) => formatNumber(row[prop])"
+            />
+            <zm-table-column label="npt合计" is-parent>
+              <zm-table-column
+                prop="nptTotal"
+                label="时长(H)"
+                min-width="92"
+                cover-formatter
+                :real-value="(row: ListItem) => formatNumber(row.nptTotal)"
+              />
+              <zm-table-column
+                prop="nptRate"
+                label="占比"
+                min-width="92"
+                cover-formatter
+                :real-value="formatRate"
+              />
+            </zm-table-column>
+            <zm-table-column
+              prop="calendarTime"
+              label="自然时间"
+              min-width="92"
+              cover-formatter
+              :real-value="(row: ListItem) => formatNumber(row.calendarTime)"
+            />
+          </zm-table>
+          <div
+            v-else
+            ref="chartRef"
+            v-loading="loading"
+            class="npt-board-container"
+            :style="{ width: `${width}px`, height: `${height}px` }"
+          >
+          </div>
+        </template>
+      </el-auto-resizer>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+:deep(.npt-cell-highlight) {
+  background-color: #fff566 !important;
+}
+
+.npt-board-container {
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 4px;
+}
+</style>

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

@@ -334,7 +334,7 @@ onMounted(() => {
                 prop="reportDetails"
                 label="生产动态"
                 width="320"
-                :show-overflow-tooltip="false"
+                :show-overflow-tooltip="true"
               >
                 <template #default="{ row }">
                   <div v-if="row.reportDetails && row.reportDetails.length > 0" class="py-2">
@@ -420,6 +420,9 @@ onMounted(() => {
                       </el-scrollbar>
                     </el-popover>
                   </div>
+                  <div class="truncate" v-else-if="row.productionStatus">{{
+                    row.productionStatus
+                  }}</div>
                   <span v-else class="text-gray-300">-</span>
                 </template>
               </zm-table-column>

+ 43 - 532
src/views/pms/iotrddailyreport/summary.vue

@@ -1,17 +1,9 @@
 <script setup lang="ts">
 import dayjs from 'dayjs'
-import { IotRdDailyReportApi } from '@/api/pms/iotrddailyreport'
-import { useDebounceFn } from '@vueuse/core'
-import CountTo from '@/components/count-to1.vue'
-import * as echarts from 'echarts'
-
-import { Motion, AnimatePresence } from 'motion-v'
-
 import { rangeShortcuts } from '@/utils/formatTime'
-
 import { useUserStore } from '@/store/modules/user'
-import download from '@/utils/download'
-import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import DailyStatistics from './components/DailyStatistics.vue'
+import NonProductionEfficiency from './components/NonProductionEfficiency.vue'
 
 const deptId = useUserStore().getUser.deptId
 
@@ -26,441 +18,42 @@ interface Query {
 
 const id = deptId
 
-const query = ref<Query>({
+const createDefaultQuery = (): Query => ({
   pageNo: 1,
   pageSize: 10,
   deptId: deptId,
+  contractName: '',
+  taskName: '',
   createTime: [
     ...rangeShortcuts[1].value().map((item) => dayjs(item).format('YYYY-MM-DD HH:mm:ss'))
   ]
 })
 
-const totalWorkKeys: [string, string | undefined, string, string, number][] = [
-  [
-    'cumulativeBridgePlug',
-    undefined,
-    '桥塞',
-    'i-material-symbols:check-circle-outline-rounded text-emerald',
-    0
-  ],
-  [
-    'cumulativeRunCount',
-    undefined,
-    '趟数',
-    'i-material-symbols:check-circle-outline-rounded text-emerald',
-    0
-  ],
-  [
-    'cumulativeWorkingWell',
-    undefined,
-    '井数',
-    'i-material-symbols:check-circle-outline-rounded text-emerald',
-    0
-  ],
-  [
-    'cumulativeWorkingLayers',
-    undefined,
-    '段数',
-    'i-material-symbols:check-circle-outline-rounded text-emerald',
-    0
-  ],
-  [
-    'cumulativeHourCount',
-    undefined,
-    '小时(H)',
-    'i-material-symbols:nest-clock-farsight-analog-outline-rounded text-emerald',
-    2
-  ],
-  [
-    'cumulativeWaterVolume',
-    '方',
-    '水方量',
-    'i-material-symbols:water-drop-outline-rounded text-sky',
-    2
-  ],
-  ['taici', undefined, '台次', 'i-material-symbols:check-circle-outline-rounded text-emerald', 0],
-  [
-    'utilizationRate',
-    '%',
-    '设备利用率',
-    'i-material-symbols:check-circle-outline-rounded text-emerald',
-    0
-  ],
-  [
-    'cumulativeFuels',
-    '万升',
-    '累计油耗',
-    'i-material-symbols:directions-car-outline-rounded text-sky',
-    2
-  ]
-]
-
-const totalWork = ref({
-  cumulativeFuels: 0,
-  taici: 0,
-  cumulativeBridgePlug: 0,
-  cumulativeRunCount: 0,
-  cumulativeWorkingWell: 0,
-  cumulativeWorkingLayers: 0,
-  cumulativeHourCount: 0,
-  cumulativeWaterVolume: 0,
-  utilizationRate: 0
-})
-
-const totalLoading = ref(false)
-
-const getTotal = useDebounceFn(async () => {
-  totalLoading.value = true
-
-  const { pageNo, pageSize, ...other } = query.value
-
-  try {
-    const res2 = await IotRdDailyReportApi.totalWorkload(other)
-
-    totalWork.value = {
-      ...totalWork.value,
-      taici: res2.taici || 0,
-      cumulativeBridgePlug: res2.cumulativeBridgePlug || 0,
-      cumulativeRunCount: res2.cumulativeRunCount || 0,
-      cumulativeWorkingWell: res2.cumulativeWorkingWell || 0,
-      cumulativeWorkingLayers: res2.cumulativeWorkingLayers || 0,
-      cumulativeHourCount: res2.cumulativeHourCount || 0,
-      cumulativeWaterVolume: res2.cumulativeWaterVolume || 0,
-      ...res2,
-      cumulativeFuels: (res2.cumulativeFuels || 0) / 10000,
-      utilizationRate: Number(((res2.utilizationRate || 0) * 100).toFixed(2))
-    }
-  } finally {
-    totalLoading.value = false
-  }
-}, 500)
-
-interface List {
-  id: number | null
-  name: string | null
-  type: '1' | '2' | '3'
-  cumulativeBridgePlug: number | null
-  cumulativeRunCount: number | null
-  cumulativeWorkingWell: number | null
-  cumulativeHourCount: number | null
-  totalDailyFuel: number | null
-  cumulativeWaterVolume: number | null
-  cumulativeWorkingLayers: number | null
-  cumulativePumpTrips: number | null
-  cumulativeMixSand: number | null
-  utilizationRate: number | null
-}
-
-const list = ref<List[]>([])
-
-const type = ref('2')
-
-const listLoading = ref(false)
-
-const getList = useDebounceFn(async () => {
-  listLoading.value = true
-  try {
-    const res = await IotRdDailyReportApi.getIotRdDailyReportSummary(query.value)
-
-    const { list: reslist } = res
-
-    type.value = reslist[0]?.type || '2'
-
-    list.value = reslist.map(
-      ({ id, projectDeptId, projectDeptName, teamId, teamName, sort, taskId, type, ...other }) => {
-        return {
-          id: type === '2' ? projectDeptId : teamId,
-          name: type === '2' ? projectDeptName : teamName,
-          ...other,
-          cumulativeBridgePlug: other.cumulativeBridgePlug || 0,
-          cumulativeRunCount: other.cumulativeRunCount || 0,
-          cumulativeWorkingWell: other.cumulativeWorkingWell || 0,
-          cumulativeHourCount: other.cumulativeHourCount || 0,
-          totalDailyFuel: other.totalDailyFuel || 0,
-          cumulativeWaterVolume: other.cumulativeWaterVolume || 0,
-          cumulativeWorkingLayers: other.cumulativeWorkingLayers || 0,
-          cumulativePumpTrips: other.cumulativePumpTrips || 0,
-          cumulativeMixSand: other.cumulativeMixSand || 0,
-          utilizationRate: other.utilizationRate || 0
-        }
-      }
-    )
-  } finally {
-    listLoading.value = false
-  }
-}, 500)
-
-const tab = ref<'表格' | '看板'>('表格')
-
-const currentTab = ref<'表格' | '看板'>('表格')
-
+const query = ref<Query>(createDefaultQuery())
+const activeTab = ref<'日报统计' | '非生产时效'>('日报统计')
 const deptName = ref('四川瑞都')
-
-const direction = ref<'left' | 'right'>('right')
-
-const handleSelectTab = (val: '表格' | '看板') => {
-  tab.value = val
-  direction.value = val === '看板' ? 'right' : 'left'
-  nextTick(() => {
-    currentTab.value = val
-    setTimeout(() => {
-      render()
-    })
-  })
-}
-
-const chartRef = ref<HTMLDivElement | null>(null)
-let chart: echarts.ECharts | null = null
-
-const xAxisData = ref<string[]>([])
-
-const legend = ref<string[][]>([
-  ['个数', 'cumulativeBridgePlug'],
-  ['井数', 'cumulativeWorkingWell'],
-  ['小时 (H)', 'cumulativeHourCount'],
-  ['油耗 (万升)', 'cumulativeFuels'],
-  ['水方量 (方)', 'cumulativeWaterVolume'],
-  ['台次(泵车)', 'cumulativePumpTrips'],
-  ['段数', 'cumulativeWorkingLayers'],
-  ['台次(仪表/混砂)', 'cumulativeMixSand'],
-  ['设备利用率 (%)', 'utilizationRate']
-])
-
-const chartData = ref<Record<string, number[]>>({
-  cumulativeFuelConsumption: [],
-  cumulativeGasInjection: [],
-  cumulativePowerConsumption: [],
-  cumulativeWaterInjection: [],
-  transitTime: [],
-  utilizationRate: []
-})
-
-let chartLoading = ref(false)
-
-const getChart = useDebounceFn(async () => {
-  chartLoading.value = true
-
-  try {
-    const res = await IotRdDailyReportApi.getIotRdDailyReportSummaryPolyline(query.value)
-
-    chartData.value = {
-      cumulativeBridgePlug: res.map((item) => item.cumulativeBridgePlug || 0),
-      cumulativeWorkingWell: res.map((item) => item.cumulativeWorkingWell || 0),
-      cumulativeHourCount: res.map((item) => item.cumulativeHourCount || 0),
-      cumulativeFuels: res.map((item) => (item.cumulativeFuels || 0) / 10000),
-      cumulativeWaterVolume: res.map((item) => item.cumulativeWaterVolume || 0),
-      cumulativeWorkingLayers: res.map((item) => item.cumulativeWorkingLayers || 0),
-      cumulativePumpTrips: res.map((item) => item.cumulativePumpTrips || 0),
-      cumulativeMixSand: res.map((item) => item.cumulativeMixSand || 0),
-      utilizationRate: res.map((item) => Number(((item.utilizationRate || 0) * 100).toFixed(2)))
-    }
-
-    xAxisData.value = res.map((item) => item.reportDate || '')
-  } finally {
-    chartLoading.value = false
-  }
-}, 500)
-
-const resizer = () => {
-  chart?.resize()
-}
-
-onUnmounted(() => {
-  window.removeEventListener('resize', resizer)
-})
-
-const render = () => {
-  if (!chartRef.value) return
-
-  chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
-
-  window.addEventListener('resize', resizer)
-
-  const values: number[] = []
-
-  for (const [_name, key] of legend.value) {
-    values.push(...(chartData.value[key] || []))
-  }
-
-  const maxVal = values.length === 0 ? 10000 : Math.max(...values)
-  const minVal = values.length === 0 ? 0 : Math.min(...values) > 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)
-
-  const maxInterval = interval
-  const minInterval = minDigits
-
-  const intervalArr = [0]
-  for (let i = 1; i <= interval; i++) {
-    intervalArr.push(Math.pow(10, i))
-  }
-
-  chart.setOption({
-    tooltip: {
-      trigger: 'axis',
-      axisPointer: {
-        type: 'line'
-      },
-      formatter: (params) => {
-        let d = `${params[0].axisValueLabel}<br>`
-        let item = params.map((el) => {
-          return `<div class="flex items-center justify-between mt-1 gap-1">
-            <span>${el.marker} ${el.seriesName}</span>
-            <span>${chartData.value[legend.value[el.componentIndex][1]][el.dataIndex].toFixed(2)} ${el.seriesName.split(' ')[1] ?? ''}</span>
-          </div>`
-        })
-
-        return d + item.join('')
-      }
-    },
-    legend: {
-      data: legend.value.map(([name]) => name),
-      show: true
-    },
-    xAxis: {
-      type: 'category',
-      data: xAxisData.value
-    },
-    yAxis: {
-      type: 'value',
-      min: -minInterval,
-      max: maxInterval,
-      interval: 1,
-      axisLabel: {
-        formatter: (v) => {
-          const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
-
-          return num.toLocaleString()
-        }
-      }
-    },
-    series: legend.value.map(([name, key]) => ({
-      name,
-      type: 'line',
-      smooth: true,
-      showSymbol: true,
-      data: chartData.value[key].map((value) => {
-        if (value === 0) return 0
-
-        const isPositive = value > 0
-        const absItem = Math.abs(value)
-
-        const min_value = Math.max(...intervalArr.filter((v) => v <= absItem))
-        const min_index = intervalArr.findIndex((v) => v === min_value)
-
-        const new_value =
-          (absItem - min_value) / (intervalArr[min_index + 1] - intervalArr[min_index]) + min_index
-
-        return isPositive ? new_value : -new_value
-      })
-    }))
-  })
-}
+const refreshKey = ref(0)
 
 const handleDeptNodeClick = (node: any) => {
   deptName.value = node.name
   handleQuery()
 }
 
-const handleQuery = (setPage = true) => {
-  if (setPage) {
-    query.value.pageNo = 1
-  }
-  getChart().then(() => {
-    render()
-  })
-  getList()
-  getTotal()
+const handleQuery = () => {
+  query.value.pageNo = 1
+  refreshKey.value += 1
 }
 
 const resetQuery = () => {
-  query.value = {
-    pageNo: 1,
-    pageSize: 10,
-    deptId: deptId,
-    contractName: '',
-    taskName: '',
-    createTime: [
-      ...rangeShortcuts[1].value().map((item) => dayjs(item).format('YYYY-MM-DD HH:mm:ss'))
-    ]
-  }
+  query.value = createDefaultQuery()
+  deptName.value = '四川瑞都'
   handleQuery()
 }
-
-watch(
-  () => query.value.createTime,
-  () => {
-    handleQuery(false)
-  }
-)
-
-watch([() => query.value.contractName, () => query.value.taskName], () => {
-  handleQuery(false)
-})
-
-onMounted(() => {
-  handleQuery()
-})
-
-const exportChart = () => {
-  if (!chart) return
-  let img = new Image()
-  img.src = chart.getDataURL({
-    type: 'png',
-    pixelRatio: 1,
-    backgroundColor: '#fff'
-  })
-
-  img.onload = function () {
-    let canvas = document.createElement('canvas')
-    canvas.width = img.width
-    canvas.height = img.height
-    let ctx = canvas.getContext('2d')
-    ctx?.drawImage(img, 0, 0)
-    let dataURL = canvas.toDataURL('image/png')
-
-    let a = document.createElement('a')
-
-    let event = new MouseEvent('click')
-
-    a.href = dataURL
-    a.download = `瑞恒日报统计数据.png`
-    a.dispatchEvent(event)
-  }
-}
-
-const exportData = async () => {
-  const res = await IotRdDailyReportApi.exportRdDailyReportStatistics(query.value)
-  download.excel(res, '瑞都日报统计数据.xlsx')
-}
-
-const exportAll = async () => {
-  if (tab.value === '看板') exportChart()
-  else exportData()
-}
-
-const router = useRouter()
-
-const tolist = (id: number) => {
-  const { pageNo, pageSize, ...rest } = query.value
-
-  router.push({
-    path: '/iotdayilyreport/IotRdDailyReport',
-    query: {
-      ...rest,
-      deptId: id
-    }
-  })
-}
-
-const { ZmTable, ZmTableColumn } = useTableComponents()
 </script>
 
 <template>
   <div
-    class="grid grid-cols-[auto_1fr] grid-rows-[62px_164px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
+    class="grid grid-cols-[auto_1fr] grid-rows-[62px_48px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
   >
     <DeptTreeSelect
       :deptId="id"
@@ -468,9 +61,6 @@ const { ZmTable, ZmTableColumn } = useTableComponents()
       v-model="query.deptId"
       @node-click="handleDeptNodeClick"
     />
-    <!-- <div class="p-4 bg-white dark:bg-[#1d1e1f] shadow rounded-lg row-span-3">
-
-    </div> -->
     <el-form
       size="default"
       class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-8 gap-8 flex items-center justify-between"
@@ -514,115 +104,36 @@ const { ZmTable, ZmTableColumn } = useTableComponents()
         <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
       </el-form-item>
     </el-form>
-    <div class="grid grid-cols-9 gap-8">
-      <div
-        v-for="info in totalWorkKeys"
-        :key="info[0]"
-        class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow p-1 flex flex-col items-center justify-center gap-1"
-      >
-        <div class="size-7.5" :class="info[3]"></div>
-        <count-to
-          class="text-2xl font-medium"
-          :start-val="0"
-          :end-val="totalWork[info[0]]"
-          :decimals="info[4]"
-        >
-          <span class="text-xs leading-8 text-[var(--el-text-color-regular)]">暂无数据</span>
-        </count-to>
-        <div class="text-xs font-medium text-[var(--el-text-color-regular)]">{{ info[1] }}</div>
-        <div class="text-sm font-medium text-[var(--el-text-color-regular)]">{{ info[2] }}</div>
-      </div>
-    </div>
 
-    <div class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow flex flex-col p-4 gap-2">
-      <div class="flex h-12 items-center justify-between">
-        <el-button-group>
-          <el-button
-            size="default"
-            :type="tab === '表格' ? 'primary' : 'default'"
-            @click="handleSelectTab('表格')"
-            >表格
-          </el-button>
-          <el-button
-            size="default"
-            :type="tab === '看板' ? 'primary' : 'default'"
-            @click="handleSelectTab('看板')"
-            >看板
-          </el-button>
-        </el-button-group>
-        <h3 class="text-xl font-medium">{{ `${deptName}-${tab}` }}</h3>
-        <el-button size="default" type="primary" @click="exportAll">导出</el-button>
-      </div>
-      <div class="flex-1 relative">
-        <el-auto-resizer class="absolute">
-          <template #default="{ height }">
-            <Motion
-              as="div"
-              :style="{ position: 'relative', overflow: 'hidden' }"
-              :animate="{ height: `${height}px`, width: `100%` }"
-              :transition="{ type: 'spring', stiffness: 200, damping: 25, duration: 0.3 }"
-            >
-              <AnimatePresence :initial="false" mode="sync">
-                <Motion
-                  :key="currentTab"
-                  as="div"
-                  :initial="{ x: direction === 'left' ? '-100%' : '100%', opacity: 0 }"
-                  :animate="{ x: '0%', opacity: 1 }"
-                  :exit="{ x: direction === 'left' ? '50%' : '-50%', opacity: 0 }"
-                  :transition="{ type: 'tween', stiffness: 300, damping: 30, duration: 0.3 }"
-                  :style="{ position: 'absolute', left: 0, right: 0, top: 0 }"
-                >
-                  <div :style="{ width: `100%`, height: `${height}px` }">
-                    <zm-table
-                      v-if="currentTab === '表格'"
-                      :loading="listLoading"
-                      :data="list"
-                      :height="height"
-                      show-border
-                    >
-                      <zm-table-column label="部门" prop="name">
-                        <template #default="{ row }">
-                          <el-button text type="primary" @click.prevent="tolist(row.id)">{{
-                            row.name
-                          }}</el-button>
-                        </template>
-                      </zm-table-column>
-                      <zm-table-column label="油耗(万升)" prop="totalDailyFuel" />
-                      <zm-table-column label="桥塞" prop="cumulativeBridgePlug" />
-                      <zm-table-column label="趟数" prop="cumulativeRunCount" />
-                      <zm-table-column label="井数" prop="cumulativeWorkingWell" />
-                      <zm-table-column label="段数" prop="cumulativeWorkingLayers" />
-                      <zm-table-column label="小时(H)" prop="cumulativeHourCount" />
-                      <zm-table-column label="水方量(方)" prop="cumulativeWaterVolume" />
-                      <zm-table-column label="台次">
-                        <zm-table-column label="泵车" prop="cumulativePumpTrips" />
-                        <zm-table-column label="仪表/混砂" prop="cumulativeMixSand" />
-                      </zm-table-column>
-                      <zm-table-column
-                        label="设备利用率"
-                        prop="utilizationRate"
-                        cover-formatter
-                        :real-value="
-                          (row: List) => (Number(row.utilizationRate ?? 0) * 100).toFixed(2) + '%'
-                        "
-                      />
-                    </zm-table>
-                    <div
-                      ref="chartRef"
-                      v-loading="chartLoading"
-                      :key="dayjs().valueOf()"
-                      v-else
-                      :style="{ width: `100%`, height: `${height}px` }"
-                    >
-                    </div>
-                  </div>
-                </Motion>
-              </AnimatePresence>
-            </Motion>
-          </template>
-        </el-auto-resizer>
-      </div>
-    </div>
+    <el-button-group class="justify-self-start self-center">
+      <el-button
+        size="default"
+        :type="activeTab === '日报统计' ? 'primary' : 'default'"
+        @click="activeTab = '日报统计'"
+      >
+        日报统计
+      </el-button>
+      <el-button
+        size="default"
+        :type="activeTab === '非生产时效' ? 'primary' : 'default'"
+        @click="activeTab = '非生产时效'"
+      >
+        非生产时效
+      </el-button>
+    </el-button-group>
+
+    <DailyStatistics
+      v-if="activeTab === '日报统计'"
+      :query="query"
+      :dept-name="deptName"
+      :refresh-key="refreshKey"
+    />
+    <NonProductionEfficiency
+      v-else
+      :query="query"
+      :dept-name="deptName"
+      :refresh-key="refreshKey"
+    />
   </div>
 </template>
 

+ 3 - 4
src/views/pms/iotrhdailyreport/summary.vue

@@ -764,7 +764,7 @@ const { ZmTable, ZmTableColumn } = useTableComponents()
 
 <template>
   <div
-    class="grid grid-cols-[auto_1fr] grid-rows-[62px_164px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
+    class="grid grid-cols-[auto_1fr] grid-rows-[62px_128px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
   >
     <DeptTreeSelect
       :deptId="id"
@@ -851,9 +851,8 @@ const { ZmTable, ZmTableColumn } = useTableComponents()
               <span class="text-xs leading-8 text-[var(--el-text-color-regular)]">暂无数据</span>
             </count-to>
 
-            <div class="text-xs font-medium text-[var(--el-text-color-regular)]">{{ info[1] }}</div>
-            <div class="text-sm font-medium text-[var(--el-text-color-regular)]">
-              {{ info[2] }}
+            <div class="text-sm font-medium text-[var(--el-text-color-regular)] whitespace-nowrap">
+              {{ info[1] ? info[2] + '(' + info[1] + ')' : info[2] }}
             </div>
           </div>
         </el-tooltip>

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

@@ -580,7 +580,7 @@ const tolist = (id: number, non: boolean = false) => {
 
 <template>
   <div
-    class="grid grid-cols-[auto_1fr] grid-rows-[62px_164px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
+    class="grid grid-cols-[auto_1fr] grid-rows-[62px_128px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
   >
     <DeptTreeSelect
       :deptId="id"
@@ -648,8 +648,9 @@ const tolist = (id: number, non: boolean = false) => {
         >
           <span class="text-xs leading-8 text-[var(--el-text-color-regular)]">暂无数据</span>
         </count-to>
-        <div class="text-xs font-medium text-[var(--el-text-color-regular)]">{{ info[1] }}</div>
-        <div class="text-sm font-medium text-[var(--el-text-color-regular)]">{{ info[2] }}</div>
+        <div class="text-sm font-medium text-[var(--el-text-color-regular)] whitespace-nowrap">
+          {{ info[1] ? info[2] + '(' + info[1] + ')' : info[2] }}
+        </div>
       </div>
     </div>
 

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

@@ -579,7 +579,7 @@ const tolist = (id: number, non: boolean = false) => {
 
 <template>
   <div
-    class="grid grid-cols-[auto_1fr] grid-rows-[62px_164px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
+    class="grid grid-cols-[auto_1fr] grid-rows-[62px_128px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
   >
     <DeptTreeSelect
       :deptId="id"
@@ -647,8 +647,9 @@ const tolist = (id: number, non: boolean = false) => {
         >
           <span class="text-xs leading-8 text-[var(--el-text-color-regular)]">暂无数据</span>
         </count-to>
-        <div class="text-xs font-medium text-[var(--el-text-color-regular)]">{{ info[1] }}</div>
-        <div class="text-sm font-medium text-[var(--el-text-color-regular)]">{{ info[2] }}</div>
+        <div class="text-sm font-medium text-[var(--el-text-color-regular)] whitespace-nowrap">
+          {{ info[1] ? info[2] + '(' + info[1] + ')' : info[2] }}
+        </div>
       </div>
     </div>
 

+ 47 - 4
src/views/pms/operation-meeting/components/operation-meeting-content.vue

@@ -206,6 +206,17 @@ const getMeetingTableCellStyle: any = ({
   return undefined
 }
 
+const getMeetingTableRowStyle = (): CSSProperties => ({
+  height: 'auto',
+  minHeight: '40px'
+})
+
+const getMeetingTableCellClassName = ({ column }: MeetingTableCellStyleProps) => {
+  const property = column.property as keyof DetailItem | undefined
+
+  return property ? 'meeting-table__cell' : ''
+}
+
 const visible = ref(false)
 </script>
 
@@ -239,7 +250,7 @@ const visible = ref(false)
           />
         </el-form-item>
 
-        <el-form-item label="所属公司" label-position="left" class="mb-0! min-w-0">
+        <el-form-item label="专业公司" label-position="left" class="mb-0! min-w-0">
           <el-input
             :model-value="companyDisplayName"
             class="w-full!"
@@ -315,6 +326,8 @@ const visible = ref(false)
           align="left"
           :show-overflow-tooltip="false"
           :cell-style="getMeetingTableCellStyle"
+          :row-style="getMeetingTableRowStyle"
+          :cell-class-name="getMeetingTableCellClassName"
         >
           <zm-table-column min-width="8%" align="center" label="项目名称" prop="projectName" />
           <zm-table-column min-width="27%" label="本期生产运行情况">
@@ -417,16 +430,16 @@ const visible = ref(false)
 
     <section class="meeting-support-panel">
       <el-form-item
-        label="需集团协调支持的事项"
+        label="其他重点事项及集团协调事项"
         prop="support"
-        class="meeting-form-item mb-0! min-w-0"
+        class="meeting-form-item min-w-0"
       >
         <el-input
           v-model="supportModel"
           type="textarea"
           :rows="4"
           :disabled="type === 'view'"
-          placeholder="请输入需集团协调支持的事项"
+          placeholder="请输入其他重点事项及集团协调事项"
         />
       </el-form-item>
     </section>
@@ -622,6 +635,36 @@ const visible = ref(false)
       text-align: center;
     }
   }
+
+  :deep(.el-table__body .el-table__cell) {
+    height: auto;
+    padding-top: 7px;
+    padding-bottom: 7px;
+    vertical-align: middle;
+
+    .cell {
+      line-height: 1.5;
+      word-break: break-word;
+      white-space: pre-wrap;
+      overflow-wrap: anywhere;
+    }
+  }
+
+  :deep(.el-table__body .el-table__row) {
+    height: auto;
+  }
+
+  :deep(.el-table__body .el-table__row > .el-table__cell) {
+    min-height: 40px;
+  }
+
+  :deep(.meeting-table__cell) {
+    .cell {
+      word-break: break-word;
+      white-space: pre-wrap;
+      overflow-wrap: anywhere;
+    }
+  }
 }
 
 .meeting-table__actions {

+ 2 - 2
src/views/pms/operation-meeting/index.vue

@@ -115,11 +115,11 @@ function formatMeetingDate(row: Pick<OperationMeetingListItem, 'meetingDate'>) {
         <div
           class="operation-meeting-query__fields min-w-0 flex-1 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-[repeat(2,minmax(0,320px))]"
         >
-          <el-form-item label="所属公司" class="operation-meeting-query__item mb-0! min-w-0">
+          <el-form-item label="专业公司" class="operation-meeting-query__item mb-0! min-w-0">
             <el-select
               v-model="query.deptId"
               class="w-full!"
-              placeholder="请选择所属公司"
+              placeholder="请选择专业公司"
               :options="deptOptions"
               clearable
             />

+ 1 - 1
src/views/pms/operation-meeting/meeting-form.vue

@@ -136,7 +136,7 @@ const operationMeetingRules = reactive<FormRules>({
       trigger: ['blur', 'change']
     }
   ],
-  support: requiredTextRule('请输入需集团协调支持的事项')
+  support: requiredTextRule('请输入其他重点事项及集团协调事项')
 })
 
 const handleAddDetailItem = () => {

+ 881 - 25
src/views/pms/operation-meeting/summary-form.vue

@@ -1,8 +1,9 @@
 <script lang="ts" setup>
+import type { CSSProperties } from 'vue'
 import type { DeptOption, DetailItem, OperationMeeting } from './types'
 import MeetingDetailDrawer from './components/meeting-detail-drawer.vue'
-import OperationMeetingContent from './components/operation-meeting-content.vue'
 import { OperationMeetingApi } from '@/api/pms/meeting'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
 import { useWindowSize } from '@vueuse/core'
 
 interface Props {
@@ -24,6 +25,32 @@ interface SummaryMeetingItem {
   details: DetailItem[]
 }
 
+interface SummaryDetailItem extends DetailItem {
+  summaryDetailKey: string
+  meetingKey: string
+  companyName: string
+  companyFilterValue: string
+  deptId?: number
+}
+
+interface CompanyFilterOption {
+  label: string
+  value: string
+}
+
+interface DetailCardField {
+  label: string
+  prop: keyof DetailItem
+  unit?: string
+  numeric?: boolean
+}
+
+interface MeetingTableCellStyleProps {
+  column: {
+    property?: string
+  }
+}
+
 const props = withDefaults(defineProps<Props>(), {
   meetingSeries: '',
   year: '',
@@ -34,6 +61,7 @@ const emits = defineEmits<{
   'update:visible': [visible: boolean]
 }>()
 
+const { ZmTable, ZmTableColumn } = useTableComponents<SummaryDetailItem>()
 const { width } = useWindowSize()
 const drawerSize = computed(() => (width.value <= 768 ? '100%' : '100%'))
 
@@ -41,6 +69,90 @@ const loading = ref(false)
 const summaryMeetings = ref<SummaryMeetingItem[]>([])
 const detailDrawerVisible = ref(false)
 const detailForm = ref<DetailItem>(createDetailItem())
+const filterForm = reactive({
+  companyFilterValue: ''
+})
+
+const detailSummaryFields = [
+  'currentRevenue',
+  'cumulativeRevenue',
+  'currentOnAccount',
+  'cumulativeOnAccount',
+  'currentPayment',
+  'cumulativePayment'
+] as const
+
+type DetailSummaryField = (typeof detailSummaryFields)[number]
+
+const detailSummaryLabelMap: Record<DetailSummaryField, string> = {
+  currentRevenue: '收入-本期',
+  cumulativeRevenue: '收入-累计',
+  currentOnAccount: '挂帐-本期',
+  cumulativeOnAccount: '挂帐-累计',
+  currentPayment: '回款-本期',
+  cumulativePayment: '回款-累计'
+}
+
+const detailSummaryToneMap: Record<DetailSummaryField, 'revenue' | 'account' | 'payment'> = {
+  currentRevenue: 'revenue',
+  cumulativeRevenue: 'revenue',
+  currentOnAccount: 'account',
+  cumulativeOnAccount: 'account',
+  currentPayment: 'payment',
+  cumulativePayment: 'payment'
+}
+
+const detailSummaryIconMap: Record<DetailSummaryField, string> = {
+  currentRevenue: 'i-lucide:badge-japanese-yen',
+  cumulativeRevenue: 'i-lucide:badge-japanese-yen',
+  currentOnAccount: 'i-lucide:badge-alert',
+  cumulativeOnAccount: 'i-lucide:badge-alert',
+  currentPayment: 'i-lucide:badge-check',
+  cumulativePayment: 'i-lucide:badge-check'
+}
+
+const detailCardGroups: { title: string; fields: DetailCardField[] }[] = [
+  {
+    title: '经营情况',
+    fields: [
+      { label: '收入-本期', prop: 'currentRevenue', unit: '万元', numeric: true },
+      { label: '收入-累计', prop: 'cumulativeRevenue', unit: '万元', numeric: true },
+      { label: '挂帐-本期', prop: 'currentOnAccount', unit: '万元', numeric: true },
+      { label: '挂帐-累计', prop: 'cumulativeOnAccount', unit: '万元', numeric: true },
+      { label: '回款-本期', prop: 'currentPayment', unit: '万元', numeric: true },
+      { label: '回款-累计', prop: 'cumulativePayment', unit: '万元', numeric: true }
+    ]
+  },
+  {
+    title: '本期生产运行情况',
+    fields: [
+      { label: '计划工作量', prop: 'plannedWorkload' },
+      { label: '实际完成', prop: 'actualCompletion' },
+      { label: '设备利用率', prop: 'equipmentUtilizationRate', unit: '%', numeric: true }
+    ]
+  },
+  {
+    title: '生产管理情况及重点工作',
+    fields: [
+      { label: '重点工作及完成情况', prop: 'keyWorkCompletion' },
+      { label: '存在问题及分析', prop: 'problemsAnalysis' }
+    ]
+  },
+  {
+    title: '下期工作计划',
+    fields: [
+      { label: '计划工作量', prop: 'nextPlannedWorkload' },
+      { label: '重点工作事项', prop: 'priorityTasks' }
+    ]
+  }
+]
+
+const meetingTableBlueColumns = new Set<keyof DetailItem>([
+  'projectName',
+  'actualCompletion',
+  'keyWorkCompletion',
+  'problemsAnalysis'
+])
 
 function createDetailItem(): DetailItem {
   return {
@@ -94,22 +206,24 @@ const parseMeetingSeries = (value: unknown) => {
   return matched ? Number(matched[0]) : undefined
 }
 
+const normalizeTextValue = (value: unknown) => String(value ?? '').replace(/\\r\\n|\\n/g, '\n')
+
 const normalizeDetailItem = (data?: Record<string, unknown>): DetailItem => ({
   raw: data || {},
-  projectName: String(data?.projectName || ''),
+  projectName: normalizeTextValue(data?.projectName),
   currentRevenue: parseNumberValue(data?.currentRevenue),
   cumulativeRevenue: parseNumberValue(data?.cumulativeRevenue),
   currentOnAccount: parseNumberValue(data?.currentOnAccount),
   cumulativeOnAccount: parseNumberValue(data?.cumulativeOnAccount),
   currentPayment: parseNumberValue(data?.currentPayment),
   cumulativePayment: parseNumberValue(data?.cumulativePayment),
-  plannedWorkload: String(data?.plannedWorkload || ''),
-  actualCompletion: String(data?.actualCompletion || ''),
+  plannedWorkload: normalizeTextValue(data?.plannedWorkload),
+  actualCompletion: normalizeTextValue(data?.actualCompletion),
   equipmentUtilizationRate: parseNumberValue(data?.equipmentUtilizationRate),
-  keyWorkCompletion: String(data?.keyWorkCompletion || ''),
-  problemsAnalysis: String(data?.problemsAnalysis || ''),
-  nextPlannedWorkload: String(data?.nextPlannedWorkload || ''),
-  priorityTasks: String(data?.priorityTasks || '')
+  keyWorkCompletion: normalizeTextValue(data?.keyWorkCompletion),
+  problemsAnalysis: normalizeTextValue(data?.problemsAnalysis),
+  nextPlannedWorkload: normalizeTextValue(data?.nextPlannedWorkload),
+  priorityTasks: normalizeTextValue(data?.priorityTasks)
 })
 
 const normalizeSummaryMeeting = (
@@ -127,14 +241,170 @@ const normalizeSummaryMeeting = (
       companyName: String(data.companyName || ''),
       meetingDate: data.meetingDate as number | string | Date | undefined,
       support: String(data.support || ''),
+      cumulative: data.cumulative as boolean | undefined,
       meetingSeries: parseMeetingSeries(data.meetingSeries)
     },
     details: details.map((item) => normalizeDetailItem(item as Record<string, unknown>))
   }
 }
 
+const getCompanyDisplayName = (meeting: OperationMeetingForm) => {
+  if (meeting.companyName) return meeting.companyName
+
+  if (meeting.deptId) {
+    return props.deptOptions.find((item) => item.value === meeting.deptId)?.label || ''
+  }
+
+  return ''
+}
+
+const getCompanyFilterValue = (meeting: OperationMeetingForm, index: number) => {
+  if (meeting.deptId !== undefined) return `dept:${meeting.deptId}`
+
+  const companyName = getCompanyDisplayName(meeting).trim()
+
+  return companyName ? `company:${companyName}` : `unknown:${index}`
+}
+
+const summaryMeetingMeta = computed<OperationMeetingForm>(
+  () => summaryMeetings.value[0]?.meeting || {}
+)
+
+const companyOptions = computed<CompanyFilterOption[]>(() => {
+  const optionMap = new Map<string, CompanyFilterOption>()
+
+  summaryMeetings.value.forEach((item, index) => {
+    const value = getCompanyFilterValue(item.meeting, index)
+
+    if (optionMap.has(value)) return
+
+    optionMap.set(value, {
+      value,
+      label: getCompanyDisplayName(item.meeting) || '未知公司'
+    })
+  })
+
+  return Array.from(optionMap.values())
+})
+
+const summaryDetailRows = computed<SummaryDetailItem[]>(() =>
+  summaryMeetings.value.flatMap((item, meetingIndex) => {
+    const companyFilterValue = getCompanyFilterValue(item.meeting, meetingIndex)
+    const companyName = getCompanyDisplayName(item.meeting) || '未知公司'
+
+    return item.details.map((detail, detailIndex) => ({
+      ...detail,
+      summaryDetailKey: `${item.key}-${detailIndex}`,
+      meetingKey: item.key,
+      companyName,
+      cumulative: item.meeting.cumulative,
+      companyFilterValue,
+      deptId: item.meeting.deptId
+    }))
+  })
+)
+
+const filteredDetailRows = computed(() => {
+  if (!filterForm.companyFilterValue) return summaryDetailRows.value
+
+  return summaryDetailRows.value.filter(
+    (item) => item.companyFilterValue === filterForm.companyFilterValue
+  )
+})
+
+const currentSummaryScopeName = computed(
+  () =>
+    companyOptions.value.find((item) => item.value === filterForm.companyFilterValue)?.label ||
+    '全部公司'
+)
+
+const currentSummarySupport = computed(() => {
+  if (!filterForm.companyFilterValue) return ''
+
+  return (
+    summaryMeetings.value.find(
+      (item, index) => getCompanyFilterValue(item.meeting, index) === filterForm.companyFilterValue
+    )?.meeting.support || ''
+  )
+})
+
+const formatSummaryNumber = (value: number) =>
+  value.toLocaleString('zh-CN', {
+    maximumFractionDigits: 2,
+    minimumFractionDigits: Number.isInteger(value) ? 0 : 2
+  })
+
+const getDetailSummaryTotal = (field: DetailSummaryField) =>
+  filteredDetailRows.value.reduce((sum, item) => {
+    if (item.companyFilterValue === filterForm.companyFilterValue || (item as any).cumulative) {
+      return sum + Number(item[field] || 0)
+    }
+    return sum
+  }, 0)
+
+const detailSummaryCards = computed(() =>
+  detailSummaryFields.map((field) => ({
+    label: detailSummaryLabelMap[field],
+    value: formatSummaryNumber(getDetailSummaryTotal(field)),
+    tone: detailSummaryToneMap[field],
+    icon: detailSummaryIconMap[field]
+  }))
+)
+
+const formatDetailCardValue = (item: SummaryDetailItem, field: DetailCardField) => {
+  const value = item[field.prop]
+
+  if (value === undefined || value === null || value === '') {
+    return '-'
+  }
+
+  if (!field.numeric) {
+    return String(value)
+  }
+
+  const numericValue = Number(value)
+
+  if (Number.isNaN(numericValue)) {
+    return '-'
+  }
+
+  return `${formatSummaryNumber(numericValue)}${field.unit || ''}`
+}
+
+const formatEquipmentUtilizationRate = (row: SummaryDetailItem) => {
+  if (row.equipmentUtilizationRate === undefined || row.equipmentUtilizationRate === null) {
+    return '-'
+  }
+
+  return `${formatSummaryNumber(Number(row.equipmentUtilizationRate))}%`
+}
+
+const getMeetingTableCellStyle: any = ({
+  column
+}: MeetingTableCellStyleProps): CSSProperties | undefined => {
+  const property = column.property as keyof DetailItem | undefined
+
+  if (property && meetingTableBlueColumns.has(property)) {
+    return { color: '#1b71f6' }
+  }
+
+  return undefined
+}
+
+const getMeetingTableRowStyle = (): CSSProperties => ({
+  height: 'auto',
+  minHeight: '40px'
+})
+
+const getMeetingTableCellClassName = ({ column }: MeetingTableCellStyleProps) => {
+  const property = column.property as keyof DetailItem | undefined
+
+  return property ? 'meeting-table__cell' : ''
+}
+
 const resetForm = () => {
   summaryMeetings.value = []
+  filterForm.companyFilterValue = ''
   loading.value = false
   detailDrawerVisible.value = false
   detailForm.value = createDetailItem()
@@ -169,12 +439,13 @@ const loadSummaryDetails = async () => {
     summaryMeetings.value = meetings.map((item, index) =>
       normalizeSummaryMeeting(item as Record<string, unknown>, index)
     )
+    filterForm.companyFilterValue = ''
   } finally {
     loading.value = false
   }
 }
 
-const handleViewDetail = (row: DetailItem) => {
+const handleViewDetail = (row: SummaryDetailItem) => {
   detailForm.value = cloneDetailItem(row)
   detailDrawerVisible.value = true
 }
@@ -208,23 +479,203 @@ watch(
     <div v-loading="loading" class="summary-form">
       <template v-if="summaryMeetings.length">
         <el-form
-          v-for="(item, index) in summaryMeetings"
-          :key="item.key"
           label-width="auto"
           label-position="top"
           size="default"
-          :model="item.meeting"
-          class="summary-form__meeting"
+          :model="filterForm"
+          class="summary-form__section"
         >
-          <OperationMeetingContent
-            :meeting="item.meeting"
-            :details="item.details"
-            type="view"
-            :dept-options="deptOptions"
-            :loading="loading"
-            :show-meeting-meta="index === 0"
-            @edit-detail="handleViewDetail"
-          />
+          <div class="meeting-section__top">
+            <h2 class="meeting-section__title">生产运营双周例会汇报</h2>
+
+            <div class="meeting-section__grid">
+              <el-form-item label="会议期次" label-position="left" class="mb-0! min-w-0">
+                <el-input-number
+                  :model-value="summaryMeetingMeta.meetingSeries"
+                  class="w-full!"
+                  placeholder="暂无会议期次"
+                  disabled
+                  :controls="false"
+                />
+              </el-form-item>
+
+              <el-form-item
+                label="专业公司"
+                label-position="left"
+                prop="companyFilterValue"
+                class="mb-0! min-w-0"
+              >
+                <el-select
+                  v-model="filterForm.companyFilterValue"
+                  class="w-full!"
+                  placeholder="全部公司"
+                  clearable
+                  filterable
+                >
+                  <el-option
+                    v-for="item in companyOptions"
+                    :key="item.value"
+                    :label="item.label"
+                    :value="item.value"
+                  />
+                </el-select>
+              </el-form-item>
+
+              <el-form-item label="会议日期" label-position="left" class="mb-0! min-w-0">
+                <el-date-picker
+                  :model-value="summaryMeetingMeta.meetingDate"
+                  type="date"
+                  placeholder="暂无会议日期"
+                  disabled
+                  class="w-full!"
+                />
+              </el-form-item>
+            </div>
+          </div>
+
+          <section class="meeting-summary-strip">
+            <div class="meeting-summary-strip__title">
+              <span>{{ currentSummaryScopeName }}</span>
+              <small>经营数据汇总</small>
+            </div>
+            <div class="meeting-summary-strip__grid">
+              <div
+                v-for="item in detailSummaryCards"
+                :key="item.label"
+                :class="[
+                  'meeting-summary-strip__item',
+                  `meeting-summary-strip__item--${item.tone}`
+                ]"
+              >
+                <div :class="item.icon + ' size-5 icon'"></div>
+                <span>{{ item.label }}</span>
+                <strong>{{ item.value }}<em>万元</em></strong>
+              </div>
+            </div>
+          </section>
+
+          <section class="meeting-detail-panel">
+            <div class="meeting-detail-panel__header">
+              <h3 class="text-lg font-bold m-0">会议明细</h3>
+              <el-tag size="small" effect="plain">共 {{ filteredDetailRows.length }} 项</el-tag>
+            </div>
+
+            <div class="meeting-detail-table-view">
+              <ZmTable
+                class="meeting-table"
+                :data="filteredDetailRows"
+                :loading="loading"
+                :max-height="650"
+                align="left"
+                :show-overflow-tooltip="false"
+                :cell-style="getMeetingTableCellStyle"
+                :row-style="getMeetingTableRowStyle"
+                :cell-class-name="getMeetingTableCellClassName"
+              >
+                <zm-table-column
+                  min-width="6%"
+                  align="center"
+                  label="专业公司"
+                  prop="companyName"
+                  fixed="left"
+                />
+                <zm-table-column
+                  min-width="6%"
+                  align="center"
+                  label="项目名称"
+                  prop="projectName"
+                />
+                <zm-table-column min-width="24%" label="本期生产运行情况">
+                  <zm-table-column min-width="9%" label="计划工作量" prop="plannedWorkload" />
+                  <zm-table-column min-width="9%" label="实际完成" prop="actualCompletion" />
+                  <zm-table-column
+                    min-width="6%"
+                    label="设备利用率"
+                    prop="equipmentUtilizationRate"
+                    :formatter="formatEquipmentUtilizationRate"
+                  />
+                </zm-table-column>
+                <zm-table-column min-width="28%" label="生产管理情况及重点工作">
+                  <zm-table-column
+                    min-width="18%"
+                    label="重点工作及完成情况"
+                    prop="keyWorkCompletion"
+                  />
+                  <zm-table-column min-width="10%" label="存在问题及分析" prop="problemsAnalysis" />
+                </zm-table-column>
+                <zm-table-column min-width="27%" label="下期工作计划">
+                  <zm-table-column min-width="9%" label="计划工作量" prop="nextPlannedWorkload" />
+                  <zm-table-column min-width="18%" label="重点工作事项" prop="priorityTasks" />
+                </zm-table-column>
+                <zm-table-column label="操作" min-width="5%" align="center" fixed="right">
+                  <template #default="{ row }">
+                    <div class="meeting-table__actions">
+                      <el-button link size="default" type="primary" @click="handleViewDetail(row)">
+                        查看
+                      </el-button>
+                    </div>
+                  </template>
+                </zm-table-column>
+              </ZmTable>
+            </div>
+
+            <section v-if="currentSummarySupport" class="meeting-support-panel">
+              <el-form-item
+                label="其他重点事项及需要集团协调事项"
+                class="meeting-support-panel__item"
+              >
+                <el-input :model-value="currentSummarySupport" type="textarea" :rows="4" readonly />
+              </el-form-item>
+            </section>
+
+            <div v-loading="loading" class="meeting-detail-card-view">
+              <template v-if="filteredDetailRows.length">
+                <article
+                  v-for="(item, index) in filteredDetailRows"
+                  :key="item.summaryDetailKey"
+                  class="meeting-detail-card"
+                >
+                  <div class="meeting-detail-card__header">
+                    <div class="meeting-detail-card__title">
+                      <span>项目名称</span>
+                      <strong>{{ item.projectName || '-' }}</strong>
+                    </div>
+                    <el-tag size="small" effect="plain">第 {{ index + 1 }} 项</el-tag>
+                  </div>
+
+                  <div class="meeting-detail-card__company">
+                    <span>专业公司</span>
+                    <strong>{{ item.companyName || '-' }}</strong>
+                  </div>
+
+                  <section
+                    v-for="group in detailCardGroups"
+                    :key="group.title"
+                    class="meeting-detail-card__group"
+                  >
+                    <h4>{{ group.title }}</h4>
+                    <div class="meeting-detail-card__fields">
+                      <div
+                        v-for="field in group.fields"
+                        :key="field.prop"
+                        class="meeting-detail-card__field"
+                      >
+                        <span>{{ field.label }}</span>
+                        <strong>{{ formatDetailCardValue(item, field) }}</strong>
+                      </div>
+                    </div>
+                  </section>
+
+                  <div class="meeting-detail-card__actions">
+                    <el-button link size="small" type="primary" @click="handleViewDetail(item)">
+                      查看
+                    </el-button>
+                  </div>
+                </article>
+              </template>
+              <el-empty v-else description="暂无会议明细" :image-size="80" />
+            </div>
+          </section>
         </el-form>
       </template>
 
@@ -250,11 +701,416 @@ watch(
   min-height: 240px;
 }
 
-.summary-form__meeting {
-  margin-bottom: 16px;
+.summary-form__section {
+  padding: 8px;
+  background: #fff;
+  border: 1px solid rgb(229 231 235 / 90%);
+  border-radius: 12px;
+}
+
+.meeting-section__grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 18px 20px;
+}
+
+.meeting-section__top {
+  display: grid;
+  grid-template-columns: max-content minmax(0, 1fr);
+  gap: 240px;
+  align-items: start;
+  margin-bottom: 18px;
+}
+
+.meeting-section__title {
+  padding-top: 4px;
+  margin: 0;
+  font-size: 18px;
+  font-weight: 800;
+  line-height: 32px;
+  color: #1f2937;
+  white-space: nowrap;
+  transform: translateY(-4px);
+}
+
+:deep(.el-form-item__label) {
+  font-weight: 800;
+  color: #1f2937;
+}
+
+.meeting-summary-strip {
+  display: grid;
+  grid-template-columns: 112px minmax(0, 1fr);
+  gap: 8px;
+  align-items: stretch;
+  padding: 6px 8px;
+  background: linear-gradient(180deg, #f8fbff 0%, #fff 100%);
+  border: 1px solid #dbeafe;
+  border-radius: 8px;
+}
+
+.meeting-summary-strip__title {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  min-width: 0;
+  padding-right: 8px;
+  border-right: 1px solid #dbeafe;
+
+  span {
+    overflow: hidden;
+    font-size: 14px;
+    font-weight: 800;
+    line-height: 20px;
+    color: #1f2937;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  small {
+    margin-top: 0;
+    font-size: 12px;
+    font-weight: 800;
+    line-height: 16px;
+    color: #64748b;
+  }
+}
+
+.meeting-summary-strip__grid {
+  display: grid;
+  grid-template-columns: repeat(6, minmax(0, 1fr));
+  gap: 4px;
+}
+
+.meeting-summary-strip__item {
+  display: flex;
+  min-width: 0;
+  align-items: center;
+  justify-content: space-between;
+  gap: 4px;
+  padding: 4px 6px;
+  background: rgb(255 255 255 / 72%);
+  border: 1px solid rgb(219 234 254 / 70%);
+  border-radius: 7px;
+
+  span {
+    display: block;
+    flex: 1;
+    min-width: 0;
+    overflow: hidden;
+    font-size: 12px;
+    font-weight: 800;
+    line-height: 16px;
+    color: #1f2937;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  strong {
+    display: flex;
+    min-width: 0;
+    flex-shrink: 0;
+    align-items: baseline;
+    gap: 3px;
+    margin-top: 0;
+    overflow: hidden;
+    font-size: 15px;
+    font-weight: 800;
+    line-height: 18px;
+    color: #1b71f6;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  em {
+    font-size: 12px;
+    font-style: normal;
+    font-weight: 800;
+    color: #64748b;
+  }
+}
+
+.meeting-summary-strip__item--revenue strong,
+.meeting-summary-strip__item--revenue .icon {
+  color: #1b71f6;
+}
+
+.meeting-summary-strip__item--revenue {
+  background: linear-gradient(180deg, #eff6ff 0%, #fff 100%);
+  border-color: #bfdbfe;
+}
+
+.meeting-summary-strip__item--account strong,
+.meeting-summary-strip__item--account .icon {
+  color: #f59e0b;
+}
+
+.meeting-summary-strip__item--account {
+  background: linear-gradient(180deg, #fffbeb 0%, #fff 100%);
+  border-color: #fde68a;
+}
+
+.meeting-summary-strip__item--payment strong,
+.meeting-summary-strip__item--payment .icon {
+  color: #10b981;
+}
+
+.meeting-summary-strip__item--payment {
+  background: linear-gradient(180deg, #ecfdf5 0%, #fff 100%);
+  border-color: #bbf7d0;
 }
 
-.summary-form__meeting:last-child {
+.meeting-detail-panel {
+  padding-top: 12px;
+  margin-top: 12px;
+  border-top: 1px solid var(--el-border-color-lighter);
+}
+
+.meeting-support-panel {
+  padding-top: 12px;
+  margin-top: 12px;
+  border-top: 1px solid var(--el-border-color-lighter);
+}
+
+.meeting-support-panel__item {
   margin-bottom: 0;
+
+  :deep(.el-textarea__inner) {
+    font-size: 16px;
+    font-weight: 700;
+    color: #24364d;
+  }
+}
+
+.meeting-detail-panel__header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+
+.meeting-table {
+  --zm-table-font-size: 18px;
+  --zm-table-header-font-size: 18px;
+  --zm-table-header-font-weight: 800;
+  --zm-table-row-font-weight: 800;
+  --zm-table-header-text-color: #333;
+  --zm-table-border-color: #cbd5e1;
+  --zm-table-header-border-color: #c2ccda;
+  --zm-table-row-border-color: #d4dce8;
+
+  :deep(.header-wrapper) {
+    height: 20px;
+    justify-content: center !important;
+    text-align: center;
+
+    .truncate {
+      width: 100%;
+      height: 20px;
+      text-align: center;
+    }
+  }
+
+  :deep(.el-table__body .el-table__cell) {
+    height: auto;
+    padding-top: 7px;
+    padding-bottom: 7px;
+    vertical-align: middle;
+
+    .cell {
+      line-height: 1.5;
+      word-break: break-word;
+      white-space: pre-wrap;
+      overflow-wrap: anywhere;
+    }
+  }
+
+  :deep(.el-table__body .el-table__row) {
+    height: auto;
+  }
+
+  :deep(.el-table__body .el-table__row > .el-table__cell) {
+    min-height: 40px;
+  }
+
+  :deep(.meeting-table__cell) {
+    .cell {
+      word-break: break-word;
+      white-space: pre-wrap;
+      overflow-wrap: anywhere;
+    }
+  }
+}
+
+.meeting-table__actions {
+  display: flex;
+  width: 100%;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+
+  :deep(.el-button) {
+    margin-left: 0;
+  }
+}
+
+.meeting-detail-table-view {
+  display: block;
+}
+
+.meeting-detail-card-view {
+  display: none;
+}
+
+.meeting-detail-card {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  padding: 12px;
+  padding-bottom: 4px;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+  box-shadow: 0 2px 8px rgb(15 23 42 / 6%);
+}
+
+.meeting-detail-card__header,
+.meeting-detail-card__actions {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.meeting-detail-card__title {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.meeting-detail-card__title span,
+.meeting-detail-card__company span,
+.meeting-detail-card__field span {
+  font-size: 12px;
+  line-height: 1.4;
+  color: var(--el-text-color-secondary);
+}
+
+.meeting-detail-card__title strong {
+  overflow: hidden;
+  font-size: 16px;
+  line-height: 1.35;
+  color: var(--el-text-color-primary);
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.meeting-detail-card__company {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.meeting-detail-card__company strong {
+  min-width: 0;
+  font-size: 13px;
+  font-weight: 700;
+  line-height: 1.5;
+  color: var(--el-text-color-primary);
+  overflow-wrap: anywhere;
+}
+
+.meeting-detail-card__group {
+  padding-top: 12px;
+  border-top: 1px solid var(--el-border-color-lighter);
+}
+
+.meeting-detail-card__group h4 {
+  margin: 0 0 10px;
+  font-size: 14px;
+  font-weight: 700;
+  color: var(--el-text-color-primary);
+}
+
+.meeting-detail-card__fields {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 10px 12px;
+}
+
+.meeting-detail-card__field {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.meeting-detail-card__field strong {
+  min-width: 0;
+  font-size: 13px;
+  font-weight: 600;
+  line-height: 1.5;
+  color: var(--el-text-color-primary);
+  overflow-wrap: anywhere;
+  white-space: pre-wrap;
+}
+
+.meeting-detail-card__actions {
+  justify-content: flex-end;
+  padding-top: 2px;
+  border-top: 1px solid var(--el-border-color-lighter);
+}
+
+@media (width <= 960px) {
+  .meeting-section__top {
+    grid-template-columns: 1fr;
+    gap: 12px;
+  }
+
+  .meeting-section__title {
+    padding-top: 0;
+    line-height: 24px;
+    transform: translateY(0);
+  }
+
+  .meeting-section__grid {
+    grid-template-columns: 1fr;
+  }
+
+  .meeting-summary-strip {
+    grid-template-columns: 1fr;
+  }
+
+  .meeting-summary-strip__title {
+    padding-right: 0;
+    padding-bottom: 12px;
+    border-right: 0;
+    border-bottom: 1px solid #dbeafe;
+  }
+
+  .meeting-summary-strip__grid {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+}
+
+@media (width <= 768px) {
+  .meeting-detail-table-view {
+    display: none;
+  }
+
+  .meeting-detail-card-view {
+    display: flex;
+    min-height: 160px;
+    flex-direction: column;
+    gap: 12px;
+  }
+
+  .meeting-summary-strip__grid,
+  .meeting-detail-card__fields {
+    grid-template-columns: 1fr;
+  }
 }
 </style>

+ 1 - 0
src/views/pms/operation-meeting/types.ts

@@ -10,6 +10,7 @@ export interface OperationMeeting {
   meetingDate: number
   meetingSeries: string
   support: string
+  cumulative: boolean
 }
 
 export interface DetailItem {