Răsfoiți Sursa

Merge branch 'master' into feature/maotu

Zimo 1 săptămână în urmă
părinte
comite
83e07f6ae7

+ 1 - 0
package.json

@@ -115,6 +115,7 @@
     "vuedraggable": "^4.1.0",
     "vuex": "^4.1.0",
     "web-storage-cache": "^1.1.1",
+    "xlsx": "^0.18.5",
     "xml-js": "^1.6.11"
   },
   "devDependencies": {

Fișier diff suprimat deoarece este prea mare
+ 964 - 354
pnpm-lock.yaml


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

@@ -213,6 +213,16 @@ export const IotStatApi = {
     return await request.get({ url: `/pms/iot-prod-inventory-aging/inventoryStatus`, params })
   },
 
+  // 瑞恒最近6个月的运维成本
+  getMaintainCost: async (params: any) => {
+    return await request.get({ url: `/rq/stat/rh/month-ywcb/rh`, params })
+  },
+
+  // 瑞都看板-最近6个月的运维成本
+  getRdMaintainCost: async (params: any) => {
+    return await request.get({ url: `/rq/stat/rh/month-ywcb/rd`, params })
+  },
+
   // 积压趋势
   getInventoryAging: async (params: any) => {
     return await request.get({ url: `/pms/iot-prod-inventory-aging/inventoryTrend`, params })

+ 15 - 64
src/components/DailyTableColumn/index.vue

@@ -24,16 +24,14 @@
       v-bind="col"
       align="center"
       resizable
-      :show-overflow-tooltip="true"
-    >
+      :show-overflow-tooltip="true">
       <template v-if="company === 'rd'" #default="{ row }">
         <div v-if="row.reportDetails && row.reportDetails.length > 0" class="py-2">
           <el-popover placement="right" trigger="hover" width="320" popper-class="!p-0">
             <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 px-2 py-0.5 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">
                       <div class="i-carbon-calendar mr-1 -translate-y-[0.5px]"></div>
@@ -69,8 +67,7 @@
               <div
                 v-for="(item, index) in row.reportDetails"
                 :key="index"
-                class="flex flex-col bg-gray-200/80 hover:bg-blue-100 transition-colors relative group cursor-pointer p-2 rounded gap-y-2 max-w-80"
-              >
+                class="flex flex-col bg-gray-200/80 hover:bg-blue-100 transition-colors relative group cursor-pointer p-2 rounded gap-y-2 max-w-80">
                 <div class="flex items-center gap-x-2">
                   <div class="flex items-center">
                     <div class="i-carbon-calendar mr-1 -translate-y-[0.5px]"></div>
@@ -92,8 +89,7 @@
                 <div class="flex items-center">
                   <div class="font-medium flex-shrink-0">施工详情:</div>
                   <span
-                    class="font-medium group-hover:text-blue-600 transition-colors break-all whitespace-pre-wrap"
-                  >
+                    class="font-medium group-hover:text-blue-600 transition-colors break-all whitespace-pre-wrap">
                     {{ item.constructionDetail || '-' }}
                   </span>
                 </div>
@@ -110,8 +106,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 px-2 py-0.5 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">
                       <div class="i-carbon-calendar mr-1 -translate-y-[0.5px]"></div>
@@ -171,8 +166,7 @@
               <div
                 v-for="(item, index) in row.reportDetails"
                 :key="index"
-                class="flex flex-col bg-gray-200/80 hover:bg-blue-100 transition-colors relative group cursor-pointer p-2 rounded gap-y-2"
-              >
+                class="flex flex-col bg-gray-200/80 hover:bg-blue-100 transition-colors relative group cursor-pointer p-2 rounded gap-y-2">
                 <div class="flex items-center gap-x-2">
                   <div class="flex items-center">
                     <div class="i-carbon-calendar mr-1 -translate-y-[0.5px]"></div>
@@ -208,8 +202,7 @@
                 <div class="flex items-center">
                   <div class="font-medium flex-shrink-0">详细描述:</div>
                   <span
-                    class="font-medium break-all whitespace-pre-wrap group-hover:text-blue-600 transition-colors"
-                  >
+                    class="font-medium break-all whitespace-pre-wrap group-hover:text-blue-600 transition-colors">
                     {{ item.constructionDetail || '-' }}
                   </span>
                 </div>
@@ -220,13 +213,12 @@
         <span v-else class="text-gray-300">-</span>
       </template>
       <template v-else #default="{ row }">
-        <div v-if="row.reportDetails && row.reportDetails.length > 0" class="py-2">
+        <!-- <div v-if="row.reportDetails && row.reportDetails.length > 0" class="py-2">
           <el-popover placement="right" trigger="hover" width="320" popper-class="p-0!">
             <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 px-2 py-0.5 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">
                       <div class="i-carbon-calendar mr-1 -translate-y-[0.5px]"></div>
@@ -240,45 +232,7 @@
                     </div>
                   </div>
 
-                  <!-- <div class="flex items-center">
-                            <div class="font-medium flex-shrink-0">结束井深(m):</div>
-                            <span
-                              class="font-medium truncate group-hover:text-blue-600 transition-colors"
-                              >{{ row.reportDetails[0].currentDepth || '-' }} m
-                            </span>
-                          </div> -->
-
-                  <!-- <div class="flex items-center">
-                            <div class="font-medium flex-shrink-0">工况:</div>
-                            <el-tooltip
-                              effect="dark"
-                              :content="row.reportDetails[0].currentOperation"
-                              placement="top"
-                              popper-class="max-w-100"
-                            >
-                              <span
-                                class="font-medium truncate group-hover:text-blue-600 transition-colors"
-                              >
-                                {{ row.reportDetails[0].currentOperation || '-' }}
-                              </span>
-                            </el-tooltip>
-                          </div>
-
-                          <div class="flex items-center">
-                            <div class="font-medium flex-shrink-0">详细描述:</div>
-                            <el-tooltip
-                              effect="dark"
-                              :content="row.reportDetails[0].constructionDetail"
-                              placement="top"
-                              popper-class="max-w-100"
-                            >
-                              <span
-                                class="font-medium truncate group-hover:text-blue-600 transition-colors"
-                              >
-                                {{ row.reportDetails[0].constructionDetail || '-' }}
-                              </span>
-                            </el-tooltip>
-                          </div> -->
+      
                 </div>
               </el-badge>
             </template>
@@ -286,8 +240,7 @@
               <div
                 v-for="(item, index) in row.reportDetails"
                 :key="index"
-                class="flex flex-col bg-gray-200/80 hover:bg-blue-100 transition-colors relative group cursor-pointer p-2 rounded gap-y-2"
-              >
+                class="flex flex-col bg-gray-200/80 hover:bg-blue-100 transition-colors relative group cursor-pointer p-2 rounded gap-y-2">
                 <div class="flex items-center gap-x-2">
                   <div class="flex items-center">
                     <div class="i-carbon-calendar mr-1 -translate-y-[0.5px]"></div>
@@ -309,8 +262,7 @@
                 <div class="flex items-center">
                   <div class="font-medium flex-shrink-0">工况:</div>
                   <span
-                    class="font-medium break-all whitespace-pre-wrap group-hover:text-blue-600 transition-colors"
-                  >
+                    class="font-medium break-all whitespace-pre-wrap group-hover:text-blue-600 transition-colors">
                     {{ item.currentOperation || '-' }}
                   </span>
                 </div>
@@ -318,16 +270,15 @@
                 <div class="flex items-center">
                   <div class="font-medium flex-shrink-0">详细描述:</div>
                   <span
-                    class="font-medium break-all whitespace-pre-wrap group-hover:text-blue-600 transition-colors"
-                  >
+                    class="font-medium break-all whitespace-pre-wrap group-hover:text-blue-600 transition-colors">
                     {{ item.constructionDetail || '-' }}
                   </span>
                 </div>
               </div>
             </el-scrollbar>
           </el-popover>
-        </div>
-        <span v-else class="text-gray-300">-</span>
+        </div> -->
+        <span>{{ row.productionStatus }}</span>
       </template>
     </el-table-column>
     <el-table-column v-else v-bind="col" align="center" resizable />

+ 66 - 60
src/layout/components/useRenderLayout.tsx

@@ -33,10 +33,9 @@ export const useRenderLayout = () => {
         {!hideLeftMenu.value ? (
           <div
             class={[
-              'absolute top-0 left-0 h-full layout-border__right',
+              'absolute top-0 left-0 z-20 h-full layout-border__right',
               { '!fixed z-3000': mobile.value }
-            ]}
-          >
+            ]}>
             {logo.value ? (
               <Logo
                 class={[
@@ -47,8 +46,7 @@ export const useRenderLayout = () => {
                     'w-[var(--left-menu-max-width)]': !appStore.getCollapse
                   }
                 ]}
-                style="transition: all var(--transition-time-02);"
-              ></Logo>
+                style="transition: all var(--transition-time-02);"></Logo>
             ) : undefined}
             <Menu class={[{ '!h-[calc(100%-var(--logo-height))]': logo.value }]}></Menu>
           </div>
@@ -66,8 +64,7 @@ export const useRenderLayout = () => {
               'fixed !w-full !left-0': mobile.value
             }
           ]}
-          style="transition: all var(--transition-time-02);"
-        >
+          style="transition: all var(--transition-time-02);">
           <ElScrollbar
             v-loading={pageLoading.value}
             class={[
@@ -78,8 +75,7 @@ export const useRenderLayout = () => {
                 '!h-[calc(100%-var(--tags-view-height))] mt-0':
                   fixedHeader.value && hideTopHeader.value
               }
-            ]}
-          >
+            ]}>
             {!hideTopHeader.value ? (
               <div
                 class={[
@@ -92,16 +88,14 @@ export const useRenderLayout = () => {
                     '!w-full !left-0': hideLeftMenu.value || mobile.value
                   }
                 ]}
-                style="transition: all var(--transition-time-02);"
-              >
+                style="transition: all var(--transition-time-02);">
                 <ToolHeader
                   class={[
                     'bg-[var(--top-header-bg-color)]',
                     {
                       'layout-border__bottom': !tagsView.value
                     }
-                  ]}
-                ></ToolHeader>
+                  ]}></ToolHeader>
 
                 {!hideTopHeader.value && tagsView.value ? (
                   <TagsView class="layout-border__top layout-border__bottom"></TagsView>
@@ -131,9 +125,10 @@ export const useRenderLayout = () => {
             hideTopHeader.value
               ? 'top-0 h-full'
               : 'top-[var(--logo-height)] h-[calc(100%-var(--logo-height))]'
-          ]}
-        >
-          {!hideLeftMenu.value ? <Menu class="relative layout-border__right !h-full"></Menu> : undefined}
+          ]}>
+          {!hideLeftMenu.value ? (
+            <Menu class="relative layout-border__right !h-full"></Menu>
+          ) : undefined}
           <div
             class={[
               `${prefixCls}-content`,
@@ -146,20 +141,17 @@ export const useRenderLayout = () => {
                 'w-full left-0': hideLeftMenu.value
               }
             ]}
-            style="transition: all var(--transition-time-02);"
-          >
+            style="transition: all var(--transition-time-02);">
             <ElScrollbar
               v-loading={pageLoading.value}
-            class={[
-              `${prefixCls}-content-scrollbar`,
-              {
-                '!h-[calc(100%-var(--tags-view-height))] mt-[calc(var(--tags-view-height))]':
-                  fixedHeader.value && tagsView.value && !hideTopHeader.value,
-                '!h-[calc(100%)] mt-0':
-                  fixedHeader.value && hideTopHeader.value
-              }
-            ]}
-            >
+              class={[
+                `${prefixCls}-content-scrollbar`,
+                {
+                  '!h-[calc(100%-var(--tags-view-height))] mt-[calc(var(--tags-view-height))]':
+                    fixedHeader.value && tagsView.value && !hideTopHeader.value,
+                  '!h-[calc(100%)] mt-0': fixedHeader.value && hideTopHeader.value
+                }
+              ]}>
               {!hideTopHeader.value && tagsView.value ? (
                 <TagsView
                   class={[
@@ -167,14 +159,19 @@ export const useRenderLayout = () => {
                     {
                       '!fixed top-0 left-0 z-10': fixedHeader.value && !hideTopHeader.value,
                       'w-[calc(100%-var(--left-menu-min-width))] !left-[var(--left-menu-min-width)] mt-[var(--logo-height)]':
-                        !hideLeftMenu.value && collapse.value && fixedHeader.value && !hideTopHeader.value,
+                        !hideLeftMenu.value &&
+                        collapse.value &&
+                        fixedHeader.value &&
+                        !hideTopHeader.value,
                       'w-[calc(100%-var(--left-menu-max-width))] !left-[var(--left-menu-max-width)] mt-[var(--logo-height)]':
-                        !hideLeftMenu.value && !collapse.value && fixedHeader.value && !hideTopHeader.value,
+                        !hideLeftMenu.value &&
+                        !collapse.value &&
+                        fixedHeader.value &&
+                        !hideTopHeader.value,
                       '!w-full !left-0': hideLeftMenu.value || hideTopHeader.value
                     }
                   ]}
-                  style="transition: width var(--transition-time-02), left var(--transition-time-02);"
-                ></TagsView>
+                  style="transition: width var(--transition-time-02), left var(--transition-time-02);"></TagsView>
               ) : undefined}
 
               <AppView></AppView>
@@ -195,8 +192,7 @@ export const useRenderLayout = () => {
               {
                 'layout-border__bottom': !tagsView.value
               }
-            ]}
-          >
+            ]}>
             {logo.value ? <Logo class="custom-hover"></Logo> : undefined}
             <Menu class="h-[var(--top-tool-height)] flex-1 px-10px"></Menu>
             <ToolHeader></ToolHeader>
@@ -206,8 +202,7 @@ export const useRenderLayout = () => {
           class={[
             `${prefixCls}-content`,
             hideTopHeader.value ? 'w-full h-full' : 'w-full h-[calc(100%-var(--top-tool-height))]'
-          ]}
-        >
+          ]}>
           <ElScrollbar
             v-loading={pageLoading.value}
             class={[
@@ -217,19 +212,18 @@ export const useRenderLayout = () => {
                   fixedHeader.value && tagsView.value && !hideTopHeader.value,
                 '!h-full mt-0': hideTopHeader.value
               }
-            ]}
-          >
+            ]}>
             {!hideTopHeader.value && tagsView.value ? (
               <TagsView
                 class={[
                   'layout-border__bottom layout-border__top relative',
                   {
-                    '!fixed w-full top-[var(--top-tool-height)] left-0': fixedHeader.value && !hideTopHeader.value,
+                    '!fixed w-full top-[var(--top-tool-height)] left-0':
+                      fixedHeader.value && !hideTopHeader.value,
                     '!fixed w-full top-0 left-0': hideTopHeader.value
                   }
                 ]}
-                style="transition: width var(--transition-time-02), left var(--transition-time-02);"
-              ></TagsView>
+                style="transition: width var(--transition-time-02), left var(--transition-time-02);"></TagsView>
             ) : undefined}
 
             <AppView></AppView>
@@ -254,8 +248,7 @@ export const useRenderLayout = () => {
             hideTopHeader.value
               ? 'top-0 h-full'
               : 'top-[var(--logo-height)] h-[calc(100%-var(--logo-height))]'
-          ]}
-        >
+          ]}>
           {!hideLeftMenu.value ? <TabMenu></TabMenu> : undefined}
           <div
             class={[
@@ -273,19 +266,17 @@ export const useRenderLayout = () => {
                 'w-full left-0': hideLeftMenu.value
               }
             ]}
-            style="transition: all var(--transition-time-02);"
-          >
+            style="transition: all var(--transition-time-02);">
             <ElScrollbar
               v-loading={pageLoading.value}
-            class={[
-              `${prefixCls}-content-scrollbar`,
-              {
-                '!h-[calc(100%-var(--tags-view-height))] mt-[calc(var(--tags-view-height))]':
-                  fixedHeader.value && tagsView.value && !hideTopHeader.value,
-                '!h-full mt-0': hideTopHeader.value
-              }
-            ]}
-            >
+              class={[
+                `${prefixCls}-content-scrollbar`,
+                {
+                  '!h-[calc(100%-var(--tags-view-height))] mt-[calc(var(--tags-view-height))]':
+                    fixedHeader.value && tagsView.value && !hideTopHeader.value,
+                  '!h-full mt-0': hideTopHeader.value
+                }
+              ]}>
               {!hideTopHeader.value && tagsView.value ? (
                 <TagsView
                   class={[
@@ -294,17 +285,32 @@ export const useRenderLayout = () => {
                       '!fixed top-0 left-0 z-10': fixedHeader.value && !hideTopHeader.value,
                       '!fixed top-0 left-0 z-10 w-full': hideTopHeader.value,
                       'w-[calc(100%-var(--tab-menu-min-width))] !left-[var(--tab-menu-min-width)] mt-[var(--logo-height)]':
-                        !hideLeftMenu.value && collapse.value && fixedHeader.value && !fixedMenu.value && !hideTopHeader.value,
+                        !hideLeftMenu.value &&
+                        collapse.value &&
+                        fixedHeader.value &&
+                        !fixedMenu.value &&
+                        !hideTopHeader.value,
                       'w-[calc(100%-var(--tab-menu-max-width))] !left-[var(--tab-menu-max-width)] mt-[var(--logo-height)]':
-                        !hideLeftMenu.value && !collapse.value && fixedHeader.value && !fixedMenu.value && !hideTopHeader.value,
+                        !hideLeftMenu.value &&
+                        !collapse.value &&
+                        fixedHeader.value &&
+                        !fixedMenu.value &&
+                        !hideTopHeader.value,
                       'w-[calc(100%-var(--tab-menu-min-width)-var(--left-menu-max-width))] !left-[calc(var(--tab-menu-min-width)+var(--left-menu-max-width))] mt-[var(--logo-height)]':
-                        !hideLeftMenu.value && collapse.value && fixedHeader.value && fixedMenu.value && !hideTopHeader.value,
+                        !hideLeftMenu.value &&
+                        collapse.value &&
+                        fixedHeader.value &&
+                        fixedMenu.value &&
+                        !hideTopHeader.value,
                       'w-[calc(100%-var(--tab-menu-max-width)-var(--left-menu-max-width))] !left-[calc(var(--tab-menu-max-width)+var(--left-menu-max-width))] mt-[var(--logo-height)]':
-                        !hideLeftMenu.value && !collapse.value && fixedHeader.value && fixedMenu.value && !hideTopHeader.value
+                        !hideLeftMenu.value &&
+                        !collapse.value &&
+                        fixedHeader.value &&
+                        fixedMenu.value &&
+                        !hideTopHeader.value
                     }
                   ]}
-                  style="transition: width var(--transition-time-02), left var(--transition-time-02);"
-                ></TagsView>
+                  style="transition: width var(--transition-time-02), left var(--transition-time-02);"></TagsView>
               ) : undefined}
 
               <AppView></AppView>

+ 2 - 1
src/locales/zh-CN.ts

@@ -1201,7 +1201,8 @@ export default {
     AddEquipment: '设备添加',
     EquipmentEditing: '设备编辑',
     EquipmentDetails: '设备详情',
-    MonitoringDetails: '监控详情',
+    MonitoringDetails: '连油详情',
+    Monitoring1Details: '监控详情',
     UploadFile: '资料上传',
     EquipmentBOM: '设备BOM',
     EquipmentResponsiblePerson: '设备责任人',

+ 1 - 1
src/router/modules/remaining.ts

@@ -546,7 +546,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
           hidden: true,
           canTo: true,
           icon: 'ep:info',
-          title: t('rem.MonitoringDetails'),
+          title: t('rem.Monitoring1Details'),
           activeMenu: '/device/info'
         }
       },

+ 136 - 0
src/utils/useAlarmSound.ts

@@ -0,0 +1,136 @@
+import { onMounted, onUnmounted } from 'vue'
+
+const ALARM_REPEAT_INTERVAL_MS = 3000
+const ALARM_BEEP_OFFSETS = [0, 0.22, 0.44]
+
+let audioContext: AudioContext | null = null
+let consumerCount = 0
+let pendingAlarm = false
+let alarmTimer: number | undefined
+const activeAlarmSources = new Set<symbol>()
+
+function getAudioContext() {
+  if (typeof window === 'undefined') return null
+
+  try {
+    audioContext ??= new window.AudioContext()
+  } catch {
+    return null
+  }
+
+  return audioContext
+}
+
+function emitAlarmTone(context: AudioContext) {
+  const startAt = context.currentTime
+
+  ALARM_BEEP_OFFSETS.forEach((offset) => {
+    const oscillator = context.createOscillator()
+    const gain = context.createGain()
+    const beepStart = startAt + offset
+    const beepEnd = beepStart + 0.14
+
+    oscillator.type = 'square'
+    oscillator.frequency.setValueAtTime(880, beepStart)
+    gain.gain.setValueAtTime(0.0001, beepStart)
+    gain.gain.exponentialRampToValueAtTime(0.12, beepStart + 0.015)
+    gain.gain.exponentialRampToValueAtTime(0.0001, beepEnd)
+
+    oscillator.connect(gain)
+    gain.connect(context.destination)
+    oscillator.start(beepStart)
+    oscillator.stop(beepEnd)
+  })
+}
+
+async function playAlarmSound() {
+  const context = getAudioContext()
+  if (!context) return false
+
+  if (context.state === 'suspended') {
+    pendingAlarm = true
+    try {
+      await context.resume()
+    } catch {
+      return false
+    }
+  }
+
+  if (context.state !== 'running') {
+    pendingAlarm = true
+    return false
+  }
+
+  pendingAlarm = false
+  emitAlarmTone(context)
+  return true
+}
+
+function startAlarm(source: symbol) {
+  if (activeAlarmSources.has(source)) return
+
+  activeAlarmSources.add(source)
+  if (activeAlarmSources.size > 1 || typeof window === 'undefined') return
+
+  void playAlarmSound()
+  alarmTimer = window.setInterval(() => {
+    void playAlarmSound()
+  }, ALARM_REPEAT_INTERVAL_MS)
+}
+
+function stopAlarm(source: symbol) {
+  activeAlarmSources.delete(source)
+  if (activeAlarmSources.size || alarmTimer === undefined) return
+
+  window.clearInterval(alarmTimer)
+  alarmTimer = undefined
+  pendingAlarm = false
+}
+
+async function unlockAlarmSound() {
+  const context = getAudioContext()
+  if (!context || context.state !== 'suspended') return
+
+  try {
+    await context.resume()
+    if (pendingAlarm) await playAlarmSound()
+  } catch {
+    // 浏览器会在下一次用户交互时再次尝试解锁。
+  }
+}
+
+function addUnlockListeners() {
+  if (typeof window === 'undefined') return
+
+  window.addEventListener('pointerdown', unlockAlarmSound, { capture: true })
+  window.addEventListener('keydown', unlockAlarmSound, { capture: true })
+}
+
+function removeUnlockListeners() {
+  if (typeof window === 'undefined') return
+
+  window.removeEventListener('pointerdown', unlockAlarmSound, { capture: true })
+  window.removeEventListener('keydown', unlockAlarmSound, { capture: true })
+}
+
+export function useAlarmSound() {
+  const alarmSource = Symbol('threshold-alarm')
+
+  onMounted(() => {
+    consumerCount += 1
+    if (consumerCount === 1) addUnlockListeners()
+  })
+
+  onUnmounted(() => {
+    stopAlarm(alarmSource)
+    consumerCount = Math.max(0, consumerCount - 1)
+    if (consumerCount === 0) removeUnlockListeners()
+  })
+
+  return {
+    setAlarmActive(active: boolean) {
+      if (active) startAlarm(alarmSource)
+      else stopAlarm(alarmSource)
+    }
+  }
+}

+ 9 - 1
src/utils/useSocketBus.ts

@@ -117,6 +117,8 @@ export interface Dimensions {
   isText?: boolean
   minValue?: number
   maxValue?: number
+  axisMin?: number
+  axisMax?: number
   id?: number
 }
 
@@ -153,6 +155,8 @@ export interface IotTdItem {
   alarmSettingId?: number
   maxValue?: unknown
   minValue?: unknown
+  axisMax?: unknown
+  axisMin?: unknown
 }
 
 export function mapToDimension(item: IotTdItem): RawDimension {
@@ -167,7 +171,9 @@ export function mapToDimension(item: IotTdItem): RawDimension {
     response: false,
     id: item.alarmSettingId,
     maxValue: item.alarmSettingId ? normalizeRangeValue(item.maxValue) : undefined,
-    minValue: item.alarmSettingId ? normalizeRangeValue(item.minValue) : undefined
+    minValue: item.alarmSettingId ? normalizeRangeValue(item.minValue) : undefined,
+    axisMax: item.alarmSettingId ? normalizeRangeValue(item.axisMax) : undefined,
+    axisMin: item.alarmSettingId ? normalizeRangeValue(item.axisMin) : undefined
   }
 }
 
@@ -188,4 +194,6 @@ export interface RangeSettingDraft {
   color: string
   minValue?: number
   maxValue?: number
+  axisMin?: number
+  axisMax?: number
 }

+ 341 - 71
src/views/oli-connection/monitoring-board/chart.vue

@@ -2,6 +2,7 @@
 import { IotDeviceApi } from '@/api/pms/device'
 import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
 import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { useAlarmSound } from '@/utils/useAlarmSound'
 import { useMqtt } from '@/utils/useMqtt'
 import {
   Dimensions,
@@ -12,8 +13,8 @@ import {
   RangeSettingDraft,
   withDisplayStyle
 } from '@/utils/useSocketBus'
-import { useDebounceFn } from '@vueuse/core'
 import { Setting } from '@element-plus/icons-vue'
+import { useDebounceFn } from '@vueuse/core'
 import dayjs from 'dayjs'
 import * as echarts from 'echarts'
 
@@ -69,6 +70,10 @@ const props = defineProps({
   token: {
     type: String,
     required: true
+  },
+  active: {
+    type: Boolean,
+    default: true
   }
 })
 
@@ -79,6 +84,31 @@ const emit = defineEmits<{
 const dimensions = ref<Dimensions[]>([])
 const selectedDimension = ref<Record<string, boolean>>({})
 const message = useMessage()
+const { setAlarmActive } = useAlarmSound()
+
+interface ThresholdAlarm {
+  key: string
+  name: string
+  value: number
+  threshold: number
+  direction: 'high' | 'low'
+  suffix?: string
+}
+
+const thresholdAlarms = shallowRef<Record<string, ThresholdAlarm>>({})
+const activeThresholdAlarms = computed(() => Object.values(thresholdAlarms.value))
+
+function getThresholdAlarmText(alarm: ThresholdAlarm) {
+  const directionText = alarm.direction === 'high' ? '高于上限' : '低于下限'
+  const suffix = alarm.suffix || ''
+
+  return `${alarm.name}:当前 ${formatChartValue(alarm.value)}${suffix},${directionText} ${formatChartValue(alarm.threshold)}${suffix}`
+}
+
+function clearThresholdAlarms() {
+  thresholdAlarms.value = {}
+  setAlarmActive(false)
+}
 
 const currentOtherName = ref(props.otherName)
 
@@ -91,7 +121,7 @@ watch(
   }
 )
 
-const { connect, destroy, isConnected, subscribe } = useMqtt()
+const { client, connect, destroy, isConnected, subscribe } = useMqtt()
 
 const REALTIME_WINDOW_MS = 10 * 60 * 1000
 const REALTIME_RIGHT_BLANK_MS = 60 * 1000
@@ -139,12 +169,19 @@ const handleMessageUpdate = (_topic: string, data: any) => {
     }
   }
 
+  updateDimensionValues(valueMap)
+  updateThresholdAlarm(valueMap)
+
   if (!updatedNames.size) return
 
-  updateDimensionValues(valueMap)
   scheduleRealtimeChartUpdate(Array.from(updatedNames))
 }
 
+function connectRealtime() {
+  if (!props.active || client.value) return
+  connect(`wss://aims.deepoil.cc/mqtt`, { password: props.token }, handleMessageUpdate)
+}
+
 watch(isConnected, (newVal) => {
   if (newVal) {
     // subscribe(`/636/${props.deviceCode}/property/post`)
@@ -165,6 +202,8 @@ watch(isConnected, (newVal) => {
 
     subscribe(props.mqttUrl)
     // subscribe('/636/YF649/property/post')
+  } else {
+    clearThresholdAlarms()
   }
 })
 
@@ -194,7 +233,7 @@ interface ChartData {
   [key: Dimensions['name']]: ChartPoint[]
 }
 
-const chartData = ref<ChartData>({})
+const chartData = shallowRef<ChartData>({})
 
 const chartRef = ref<HTMLDivElement | null>(null)
 let chart: echarts.ECharts | null = null
@@ -204,50 +243,52 @@ let chartLoadVersion = 0
 let realtimeUpdateFrame: number | null = null
 const pendingRealtimeSeriesNames = new Set<string>()
 
+watch(
+  () => props.active,
+  async (active) => {
+    if (!active) {
+      destroy()
+      cancelRealtimeChartUpdate()
+      clearThresholdAlarms()
+      return
+    }
+
+    await nextTick()
+    chart?.resize()
+
+    if (chartRealtimeMode.value) {
+      connectRealtime()
+    }
+  }
+)
+
 const TREND_AXIS_MIN = 0
 const TREND_AXIS_MAX = 100
 
 const resizeChart = useDebounceFn(() => {
   chart?.resize()
 }, 100)
+let chartResizeObserver: ResizeObserver | null = null
 
 function getDimensionByName(name: string) {
   return dimensions.value.find((item) => item.name === name)
 }
 
-function getDatasetMaxValue(name: string) {
-  const dataset = chartData.value[name] || []
-  let maxValue = -Infinity
-
-  dataset.forEach(({ value }) => {
-    const numberValue = Number(value)
-    if (Number.isFinite(numberValue) && numberValue > maxValue) {
-      maxValue = numberValue
-    }
-  })
-
-  const currentValue = normalizeRangeValue(getDimensionByName(name)?.value)
-  if (currentValue !== undefined && currentValue > maxValue) {
-    maxValue = currentValue
-  }
-
-  return Number.isFinite(maxValue) ? maxValue : 0
-}
-
 function getSeriesRange(name: string) {
   const dimension = getDimensionByName(name)
-  const configuredMin = normalizeRangeValue(dimension?.minValue)
-  const configuredMax = normalizeRangeValue(dimension?.maxValue)
-  const min = configuredMin ?? 0
-  const max = configuredMax ?? getDatasetMaxValue(name)
-  const normalizedMax = max > min ? max : min + 1
+  const configuredMin = normalizeRangeValue(dimension?.axisMin)
+  const configuredMax = normalizeRangeValue(dimension?.axisMax)
+  const configured =
+    configuredMin !== undefined && configuredMax !== undefined && configuredMin < configuredMax
+  const min = configured ? configuredMin : 0
+  const max = configured ? configuredMax : 100
 
   return {
     min,
-    max: normalizedMax,
+    max,
     labelMin: min,
-    labelMax: normalizedMax,
-    configured: configuredMin !== undefined && configuredMax !== undefined
+    labelMax: max,
+    configured
   }
 }
 
@@ -261,7 +302,8 @@ const chartLegendItems = computed(() =>
       bgHover: item.bgHover,
       selected: selectedDimension.value[item.name] !== false,
       max: formatChartValue(range.labelMax),
-      min: formatChartValue(range.labelMin)
+      min: formatChartValue(range.labelMin),
+      alarm: thresholdAlarms.value[`${item.identifier}|${item.name}`]
     }
   })
 )
@@ -296,16 +338,26 @@ function getLatestTimestamp() {
   let latest: number | null = null
 
   Object.values(chartData.value).forEach((series) => {
-    series.forEach((point) => {
-      if (latest === null || point.ts > latest) {
-        latest = point.ts
-      }
-    })
+    const point = series[series.length - 1]
+    if (point && (latest === null || point.ts > latest)) latest = point.ts
   })
 
   return latest
 }
 
+function findPointIndex(series: ChartPoint[], ts: number) {
+  let low = 0
+  let high = series.length
+
+  while (low < high) {
+    const middle = Math.floor((low + high) / 2)
+    if (series[middle].ts < ts) low = middle + 1
+    else high = middle
+  }
+
+  return low
+}
+
 function getSelectedDateRangeTimestamps() {
   const fallbackEnd = Date.now()
   const startTs = dayjs(props.date[0]).valueOf()
@@ -350,9 +402,9 @@ function trimChartDataToRealtimeWindow(anchorTs = Date.now()) {
 
   Object.keys(chartData.value).forEach((name) => {
     const series = chartData.value[name] || []
-    const windowStartIndex = series.findIndex((point) => point.ts >= startTs)
+    const windowStartIndex = findPointIndex(series, startTs)
 
-    if (windowStartIndex === -1) {
+    if (windowStartIndex === series.length) {
       chartData.value[name] = series.slice(-1)
       return
     }
@@ -365,24 +417,25 @@ function trimChartDataToRealtimeWindow(anchorTs = Date.now()) {
 
 function appendChartPoint(name: string, point: ChartPoint) {
   const series = chartData.value[name] ?? []
-  const samePointIndex = series.findIndex((item) => item.ts === point.ts)
+  const pointIndex = findPointIndex(series, point.ts)
 
-  if (samePointIndex >= 0) {
-    series[samePointIndex] = point
+  if (series[pointIndex]?.ts === point.ts) {
+    series[pointIndex] = point
   } else {
-    series.push(point)
+    series.splice(pointIndex, 0, point)
   }
 
-  series.sort((a, b) => a.ts - b.ts)
   chartData.value[name] = series
 }
 
-function mapChartPoint({ ts, value }: ChartPoint, name: string) {
+type SeriesRange = ReturnType<typeof getSeriesRange>
+
+function mapChartPoint({ ts, value }: ChartPoint, range: SeriesRange) {
   const numberValue = Number(value)
 
   if (!Number.isFinite(numberValue)) return [ts, null, null]
 
-  const { min, max } = getSeriesRange(name)
+  const { min, max } = range
   const denominator = max - min || 1
   const mappedValue = ((numberValue - min) / denominator) * 100
   const displayValue = Math.min(TREND_AXIS_MAX, Math.max(TREND_AXIS_MIN, mappedValue))
@@ -392,8 +445,9 @@ function mapChartPoint({ ts, value }: ChartPoint, name: string) {
 
 function buildSeriesData(name: string) {
   const dataset = chartData.value[name] || []
+  const range = getSeriesRange(name)
 
-  return dataset.map((point) => mapChartPoint(point, name))
+  return dataset.map((point) => mapChartPoint(point, range))
 }
 
 function updateDimensionValues(valueMap: Map<string, number>) {
@@ -405,6 +459,43 @@ function updateDimensionValues(valueMap: Map<string, number>) {
   })
 }
 
+function updateThresholdAlarm(valueMap: Map<string, number>) {
+  const nextAlarms = { ...thresholdAlarms.value }
+
+  dimensions.value.forEach((item) => {
+    const value = valueMap.get(item.identifier)
+    if (value === undefined) return
+
+    const min = normalizeRangeValue(item.minValue)
+    const max = normalizeRangeValue(item.maxValue)
+    const alarmKey = `${item.identifier}|${item.name}`
+    if (max !== undefined && value > max) {
+      nextAlarms[alarmKey] = {
+        key: alarmKey,
+        name: item.name,
+        value,
+        threshold: max,
+        direction: 'high',
+        suffix: item.suffix
+      }
+    } else if (min !== undefined && value < min) {
+      nextAlarms[alarmKey] = {
+        key: alarmKey,
+        name: item.name,
+        value,
+        threshold: min,
+        direction: 'low',
+        suffix: item.suffix
+      }
+    } else {
+      delete nextAlarms[alarmKey]
+    }
+  })
+
+  thresholdAlarms.value = nextAlarms
+  setAlarmActive(activeThresholdAlarms.value.length > 0)
+}
+
 function getChartAnimationOptions() {
   return {
     animation: !chartRealtimeMode.value,
@@ -474,9 +565,11 @@ function flushRealtimeChartUpdate() {
 }
 
 function scheduleRealtimeChartUpdate(names: string[]) {
-  names.forEach((name) => pendingRealtimeSeriesNames.add(name))
+  names.forEach((name) => {
+    if (selectedDimension.value[name]) pendingRealtimeSeriesNames.add(name)
+  })
 
-  if (realtimeUpdateFrame !== null) return
+  if (!pendingRealtimeSeriesNames.size || realtimeUpdateFrame !== null) return
 
   realtimeUpdateFrame = window.requestAnimationFrame(flushRealtimeChartUpdate)
 }
@@ -512,6 +605,12 @@ function chartInit() {
 
   window.removeEventListener('resize', resizeChart)
   window.addEventListener('resize', resizeChart)
+
+  chartResizeObserver?.disconnect()
+  if (chartRef.value) {
+    chartResizeObserver = new ResizeObserver(resizeChart)
+    chartResizeObserver.observe(chartRef.value)
+  }
 }
 
 function render() {
@@ -637,6 +736,7 @@ function render() {
         id: item.name,
         name: item.name,
         type: 'line',
+        sampling: 'lttb',
         smooth: 0.24,
         showSymbol: false,
         connectNulls: true,
@@ -710,13 +810,15 @@ async function initLoadChartData(real_time: boolean = true) {
     } else {
       applySelectedDateWindow()
     }
-    updateSeriesByNames(dimensions.value.map((item) => item.name))
+    updateSeriesByNames(
+      dimensions.value.filter((item) => selectedDimension.value[item.name]).map((item) => item.name)
+    )
   } finally {
     chartLoading.value = false
   }
 
   if (real_time && loadVersion === chartLoadVersion) {
-    connect(`wss://aims.deepoil.cc/mqtt`, { password: props.token }, handleMessageUpdate)
+    connectRealtime()
   }
 }
 
@@ -764,6 +866,8 @@ onUnmounted(() => {
   cancelRealtimeChartUpdate()
 
   window.removeEventListener('resize', resizeChart)
+  chartResizeObserver?.disconnect()
+  chartResizeObserver = null
   chart?.dispose()
   chart = null
 })
@@ -799,7 +903,9 @@ function buildRangeSettingDrafts() {
     identifier: item.identifier,
     color: item.color,
     minValue: normalizeRangeValue(item.minValue),
-    maxValue: normalizeRangeValue(item.maxValue)
+    maxValue: normalizeRangeValue(item.maxValue),
+    axisMin: normalizeRangeValue(item.axisMin),
+    axisMax: normalizeRangeValue(item.axisMax)
   }))
 }
 
@@ -832,36 +938,56 @@ async function saveDeviceName() {
   })
 }
 
-async function saveRangeSetting(item: Dimensions, minValue?: number, maxValue?: number) {
+async function saveRangeSetting(
+  item: Dimensions,
+  minValue?: number,
+  maxValue?: number,
+  axisMinValue?: number,
+  axisMaxValue?: number
+) {
   const min = normalizeRangeValue(minValue)
   const max = normalizeRangeValue(maxValue)
+  const axisMin = normalizeRangeValue(axisMinValue)
+  const axisMax = normalizeRangeValue(axisMaxValue)
 
-  if (min === undefined && max === undefined) {
+  if (min === undefined && max === undefined && axisMin === undefined && axisMax === undefined) {
     if (item.id) {
       await IotDeviceApi.deleteMaxMin({ id: item.id })
     }
 
     item.minValue = undefined
     item.maxValue = undefined
+    item.axisMin = undefined
+    item.axisMax = undefined
     item.id = undefined
     return
   }
 
-  if (min === undefined || max === undefined) {
-    throw new Error(`${item.name} 的最大值和最小值需要同时填写`)
+  if ((min === undefined) !== (max === undefined)) {
+    throw new Error(`${item.name} 的告警上限和告警下限需要同时填写`)
+  }
+
+  if (min !== undefined && max !== undefined && min > max) {
+    throw new Error(`${item.name} 的告警下限不能大于告警上限`)
+  }
+
+  if (min !== undefined && max !== undefined && min === max) {
+    throw new Error(`${item.name} 的告警上限和告警下限不能相等`)
   }
 
-  if (min > max) {
-    throw new Error(`${item.name} 的最小值不能大于最大值`)
+  if ((axisMin === undefined) !== (axisMax === undefined)) {
+    throw new Error(`${item.name} 的 Y 轴上限和 Y 轴下限需要同时填写`)
   }
 
-  if (min === max) {
-    throw new Error(`${item.name} 的最大值和最小值不能相等`)
+  if (axisMin !== undefined && axisMax !== undefined && axisMin >= axisMax) {
+    throw new Error(`${item.name} 的 Y 轴下限必须小于 Y 轴上限`)
   }
 
   const body = {
-    minValue: min,
-    maxValue: max,
+    minValue: min ?? null,
+    maxValue: max ?? null,
+    axisMin: axisMin ?? null,
+    axisMax: axisMax ?? null,
     deviceId: props.id,
     propertyCode: item.identifier,
     alarmProperty: item.name,
@@ -874,6 +1000,8 @@ async function saveRangeSetting(item: Dimensions, minValue?: number, maxValue?:
   if (res.id) item.id = res.id
   item.minValue = min
   item.maxValue = max
+  item.axisMin = axisMin
+  item.axisMax = axisMax
 }
 
 async function handleSettingsSave() {
@@ -885,7 +1013,7 @@ async function handleSettingsSave() {
       const item = dimensions.value.find((dimension) => dimension.identifier === draft.identifier)
       if (!item) continue
 
-      await saveRangeSetting(item, draft.minValue, draft.maxValue)
+      await saveRangeSetting(item, draft.minValue, draft.maxValue, draft.axisMin, draft.axisMax)
     }
 
     updateSeriesByNames(dimensions.value.map((item) => item.name))
@@ -902,13 +1030,17 @@ function handleRangeDialogReset() {
   rangeSettingDrafts.value = rangeSettingDrafts.value.map((item) => ({
     ...item,
     minValue: undefined,
-    maxValue: undefined
+    maxValue: undefined,
+    axisMin: undefined,
+    axisMax: undefined
   }))
 }
 
 function clearRangeDraft(row: RangeSettingDraft) {
   row.minValue = undefined
   row.maxValue = undefined
+  row.axisMin = undefined
+  row.axisMax = undefined
 }
 </script>
 <template>
@@ -917,6 +1049,10 @@ function clearRangeDraft(row: RangeSettingDraft) {
       <div class="flex items-center">
         <div class="title-icon"></div>
         <div>{{ `${props.deviceCode}-${displayDeviceName}` }}</div>
+        <span v-if="activeThresholdAlarms.length" class="chart-header__alarm-count">
+          <i class="i-material-symbols:warning-rounded"></i>
+          {{ activeThresholdAlarms.length }} 项超限
+        </span>
       </div>
       <div class="chart-header__actions">
         <el-button link type="primary" :icon="Setting" @click.stop="openSettingsDialog">
@@ -936,8 +1072,9 @@ function clearRangeDraft(row: RangeSettingDraft) {
           :key="item.name"
           type="button"
           class="chart-legend__item"
-          :class="{ 'is-active': item.selected }"
+          :class="{ 'is-active': item.selected, 'is-alarm': item.alarm }"
           :aria-pressed="item.selected"
+          :title="item.alarm ? getThresholdAlarmText(item.alarm) : undefined"
           :style="{
             '--theme-color': item.color,
             '--theme-bg-hover': item.bgHover
@@ -952,6 +1089,19 @@ function clearRangeDraft(row: RangeSettingDraft) {
       </div>
 
       <div class="chart-stage">
+        <div
+          v-if="activeThresholdAlarms.length"
+          class="chart-alarm-panel"
+          role="alert"
+          aria-live="polite">
+          <span
+            v-for="alarm in activeThresholdAlarms"
+            :key="alarm.key"
+            class="chart-alarm-panel__item">
+            <i class="i-material-symbols:warning-rounded"></i>
+            <span>{{ getThresholdAlarmText(alarm) }}</span>
+          </span>
+        </div>
         <div ref="chartRef" class="chart-canvas"></div>
       </div>
     </main>
@@ -959,7 +1109,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
     <el-dialog
       v-model="settingsDialogVisible"
       title="设备设置"
-      width="900px"
+      width="1100px"
       class="board-settings-dialog"
       modal-class="board-settings-overlay"
       append-to-body
@@ -975,7 +1125,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
       </el-form>
 
       <div class="board-settings-section">
-        <div class="board-settings-section__title">曲线量程设置</div>
+        <div class="board-settings-section__title">告警阈值与 Y 轴范围</div>
         <ZmTable :data="rangeSettingDrafts" :loading="false" :max-height="420" :show-border="true">
           <ZmTableColumn label="曲线" min-width="180">
             <template #default="{ row }">
@@ -987,25 +1137,43 @@ function clearRangeDraft(row: RangeSettingDraft) {
               </div>
             </template>
           </ZmTableColumn>
-          <ZmTableColumn label="最小值" width="180">
+          <ZmTableColumn label="告警下限" width="140">
             <template #default="{ row }">
               <el-input-number
                 v-model="row.minValue"
                 class="!w-full"
                 :controls="false"
-                :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
+                placeholder="未设置" />
             </template>
           </ZmTableColumn>
-          <ZmTableColumn label="最大值" width="180">
+          <ZmTableColumn label="告警上限" width="140">
             <template #default="{ row }">
               <el-input-number
                 v-model="row.maxValue"
                 class="!w-full"
                 :controls="false"
+                placeholder="未设置" />
+            </template>
+          </ZmTableColumn>
+          <ZmTableColumn label="Y 轴下限" width="140">
+            <template #default="{ row }">
+              <el-input-number
+                v-model="row.axisMin"
+                class="!w-full"
+                :controls="false"
+                :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
+            </template>
+          </ZmTableColumn>
+          <ZmTableColumn label="Y 轴上限" width="140">
+            <template #default="{ row }">
+              <el-input-number
+                v-model="row.axisMax"
+                class="!w-full"
+                :controls="false"
                 :placeholder="formatChartValue(getSeriesRange(row.name).labelMax)" />
             </template>
           </ZmTableColumn>
-          <ZmTableColumn label="当前使用范围" width="170">
+          <ZmTableColumn label="当前 Y 轴范围" width="150">
             <template #default="{ row }">
               <span class="board-settings-range-text">
                 {{ formatChartValue(getSeriesRange(row.name).labelMin) }}
@@ -1090,6 +1258,23 @@ function clearRangeDraft(row: RangeSettingDraft) {
   align-items: center;
 }
 
+.chart-header__alarm-count {
+  display: inline-flex;
+  gap: 4px;
+  align-items: center;
+  height: 24px;
+  padding: 0 8px;
+  margin-left: 12px;
+  font-size: 12px;
+  font-weight: 800;
+  color: #fecaca;
+  background: rgb(220 38 38 / 22%);
+  border: 1px solid rgb(248 113 113 / 70%);
+  border-radius: 999px;
+  box-shadow: 0 0 14px rgb(239 68 68 / 28%);
+  animation: alarm-pulse 1.4s ease-in-out infinite;
+}
+
 .title-icon {
   width: 4px;
   height: 16px;
@@ -1182,6 +1367,39 @@ function clearRangeDraft(row: RangeSettingDraft) {
   color: var(--theme-color);
 }
 
+.chart-legend__item.is-alarm {
+  color: #fecaca;
+  background: rgb(220 38 38 / 24%);
+  border-color: rgb(248 113 113 / 85%);
+  opacity: 1;
+  box-shadow:
+    0 0 0 1px rgb(239 68 68 / 20%),
+    0 0 18px rgb(239 68 68 / 38%);
+  animation: alarm-pulse 1.4s ease-in-out infinite;
+}
+
+.chart-legend__item.is-alarm::after {
+  position: absolute;
+  top: 3px;
+  right: 3px;
+  width: 7px;
+  height: 7px;
+  background: #ef4444;
+  border: 1px solid #fecaca;
+  border-radius: 50%;
+  content: '';
+  box-shadow: 0 0 8px #ef4444;
+}
+
+.chart-legend__item.is-alarm .chart-legend__line {
+  background: #ef4444;
+  opacity: 1;
+}
+
+.chart-legend__item.is-alarm .chart-legend__range {
+  color: #fecaca;
+}
+
 .chart-legend__range {
   font-size: 10px;
   line-height: 1;
@@ -1220,6 +1438,58 @@ function clearRangeDraft(row: RangeSettingDraft) {
   border-radius: 8px;
 }
 
+.chart-alarm-panel {
+  position: absolute;
+  top: 8px;
+  right: 8px;
+  left: 8px;
+  z-index: 5;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  max-height: 76px;
+  padding: 7px;
+  overflow: auto;
+  background: rgb(69 10 10 / 88%);
+  border: 1px solid rgb(248 113 113 / 72%);
+  border-radius: 8px;
+  box-shadow: 0 8px 24px rgb(0 0 0 / 36%);
+  backdrop-filter: blur(6px);
+}
+
+.chart-alarm-panel__item {
+  display: inline-flex;
+  gap: 5px;
+  align-items: center;
+  padding: 3px 7px;
+  font-size: 12px;
+  font-weight: 700;
+  line-height: 1.4;
+  color: #fee2e2;
+  white-space: nowrap;
+  background: rgb(220 38 38 / 24%);
+  border: 1px solid rgb(248 113 113 / 40%);
+  border-radius: 999px;
+}
+
+@keyframes alarm-pulse {
+  0%,
+  100% {
+    filter: brightness(1);
+  }
+
+  50% {
+    filter: brightness(1.35);
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .chart-header__alarm-count,
+  .chart-legend__item.is-alarm {
+    animation: none;
+  }
+}
+
 .chart-canvas {
   width: 100%;
   height: 100%;

+ 95 - 225
src/views/oli-connection/monitoring-board/index.vue

@@ -1,15 +1,17 @@
 <script setup lang="ts">
+import { IotDeviceApi } from '@/api/pms/device'
+import * as DeptApi from '@/api/system/dept'
 import { useUserStore } from '@/store/modules/user'
 import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
 import { rangeShortcuts } from '@/utils/formatTime'
-import * as DeptApi from '@/api/system/dept'
 import { handleTree } from '@/utils/tree'
-import { IotDeviceApi } from '@/api/pms/device'
+import { Crop, FullScreen } from '@element-plus/icons-vue'
+import { useFullscreen } from '@vueuse/core'
+import dayjs from 'dayjs'
 import { EChartsOption } from 'echarts'
 import chart from './chart.vue'
-import dayjs from 'dayjs'
-import { useFullscreen } from '@vueuse/core'
-import { Crop, FullScreen } from '@element-plus/icons-vue'
+
+defineOptions({ name: 'MonitoringBoard' })
 
 const userStore = useUserStore()
 const userDeptId = userStore.getUser.deptId
@@ -137,10 +139,9 @@ async function loadDeviceOptions() {
 
 const deviceList = ref<DeviceData[]>([])
 const chartOption = ref<EChartsOption>({})
-const pageSize = 5
+const pageSize = 4
 const pageIndex = ref(0)
-const activeMainCardId = ref<number | null>(null)
-const boardMotion = ref<'page-prev' | 'page-next' | 'promote' | ''>('')
+const boardMotion = ref<'page-prev' | 'page-next' | ''>('')
 let boardMotionTimer: number | undefined
 
 const totalPages = computed(() => Math.max(1, Math.ceil(deviceList.value.length / pageSize)))
@@ -150,19 +151,27 @@ const currentPageCards = computed(() => {
   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}` : ''))
+const visitedPageIndexes = ref([0])
+
+const renderedBoardPages = computed(() =>
+  visitedPageIndexes.value
+    .filter((index) => index < totalPages.value)
+    .map((index) => {
+      const start = index * pageSize
+      return {
+        index,
+        cards: deviceList.value.slice(start, start + pageSize)
+      }
+    })
+)
+
+function markPageVisited(index: number) {
+  if (visitedPageIndexes.value.includes(index)) return
+  visitedPageIndexes.value = [...visitedPageIndexes.value, index]
+}
 
-function triggerBoardMotion(type: 'page-prev' | 'page-next' | 'promote') {
+function triggerBoardMotion(type: 'page-prev' | 'page-next') {
   if (boardMotionTimer) {
     window.clearTimeout(boardMotionTimer)
   }
@@ -177,28 +186,19 @@ function triggerBoardMotion(type: 'page-prev' | 'page-next' | 'promote') {
   })
 }
 
-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()
+  const targetPageIndex = pageIndex.value - 1
+  markPageVisited(targetPageIndex)
+  pageIndex.value = targetPageIndex
   triggerBoardMotion('page-prev')
 }
 
 function goNextGroup() {
   if (pageIndex.value >= totalPages.value - 1) return
-  pageIndex.value += 1
-  syncPageMainCard()
+  const targetPageIndex = pageIndex.value + 1
+  markPageVisited(targetPageIndex)
+  pageIndex.value = targetPageIndex
   triggerBoardMotion('page-next')
 }
 
@@ -226,13 +226,8 @@ 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()
   }
 }
 
@@ -253,7 +248,7 @@ function handleDeptChange() {
   deviceList.value = []
   chartOption.value = {}
   pageIndex.value = 0
-  activeMainCardId.value = null
+  visitedPageIndexes.value = [0]
   loadDeviceOptions()
 }
 
@@ -266,7 +261,7 @@ function handleRest() {
   query.value = getOriginalQuery()
   isRealTime.value = true
   pageIndex.value = 0
-  activeMainCardId.value = null
+  visitedPageIndexes.value = [0]
   loadDeptOptions()
   loadDeviceOptions()
 }
@@ -309,9 +304,9 @@ onMounted(() => {
 <template>
   <div
     ref="targetArea"
-    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))]">
+    class="monitor-board-page relative flex flex-col w-full rounded-lg bg-[#020408] overflow-hidden">
     <header
-      class="relative w-full h-14 flex items-center justify-center select-none bg-[#0b1121] border-b border-white/5 shadow-lg">
+      class="relative w-full h-12 flex items-center justify-center select-none bg-[#0b1121] border-b border-white/5 shadow-lg">
       <div
         class="absolute inset-0 opacity-20"
         style="background-image: radial-gradient(circle at 50% 50%, #083344 0%, transparent 50%)">
@@ -344,7 +339,20 @@ onMounted(() => {
         </span>
       </h1>
 
+      <div class="monitor-board-status absolute left-16 top-1/2 -translate-y-1/2 z-10">
+        当前第 {{ pageIndex + 1 }} 组 / 共 {{ totalPages }} 组
+      </div>
+
       <div class="absolute right-16 top-1/2 -translate-y-1/2 z-10 flex items-center gap-2">
+        <el-button class="custom-btn reset-btn" :disabled="pageIndex === 0" @click="goPrevGroup">
+          上 4 个
+        </el-button>
+        <el-button
+          class="custom-btn reset-btn"
+          :disabled="pageIndex >= totalPages - 1"
+          @click="goNextGroup">
+          下 4 个
+        </el-button>
         <el-button size="default" class="custom-btn primary-btn" @click="showSearchDialog = true">
           筛选
         </el-button>
@@ -359,85 +367,21 @@ onMounted(() => {
       </div>
     </header>
 
-    <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 }}-{{ getDeviceDisplayName(mainCard) }}
-          </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"
-              @other-name-updated="handleOtherNameUpdated" />
-          </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"
-              @other-name-updated="handleOtherNameUpdated" />
-          </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"
-              @other-name-updated="handleOtherNameUpdated" />
-          </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"
-              @other-name-updated="handleOtherNameUpdated" />
-          </div>
-
-          <div
-            v-if="sideCards[3]"
-            class="monitor-card-shell monitor-card-side monitor-card-right-bottom"
-            @click="setMainCard(sideCards[3])">
+    <div class="monitor-board-shell">
+      <div
+        v-if="currentPageCards.length"
+        class="monitor-board-scroll-area"
+        :class="boardMotionClass">
+        <div
+          v-for="boardPage in renderedBoardPages"
+          v-show="boardPage.index === pageIndex"
+          :key="boardPage.index"
+          class="monitor-board-layout"
+          :class="`is-${boardPage.cards.length}-cards`">
+          <div v-for="cardItem in boardPage.cards" :key="cardItem.id" class="monitor-card-shell">
             <chart
-              :key="sideCards[3].id"
-              v-bind="sideCards[3]"
+              v-bind="cardItem"
+              :active="boardPage.index === pageIndex"
               :date="query.time"
               :is-real-time="isRealTime"
               :token="token"
@@ -593,37 +537,20 @@ onMounted(() => {
   }
 }
 
-@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);
-  }
+.animate-scan-line {
+  animation: scan-line 3s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
 }
 
-@keyframes card-promote-side {
-  0% {
-    opacity: 0.62;
-    transform: scale(1.04);
-  }
-
-  100% {
-    opacity: 1;
-    transform: scale(1);
-  }
+.monitor-board-page {
+  height: calc(
+    100vh - 20px - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height)
+  );
+  min-height: 0;
 }
 
-.animate-scan-line {
-  animation: scan-line 3s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
+.monitor-board-page:fullscreen {
+  height: 100vh;
+  min-height: 100vh;
 }
 
 .monitor-board-shell {
@@ -633,70 +560,52 @@ onMounted(() => {
   height: 100%;
   flex: 1;
   min-height: 0;
-}
-
-.monitor-board-toolbar {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-bottom: 16px;
-  gap: 16px;
+  padding: 10px;
 }
 
 .monitor-board-status {
-  display: flex;
   font-size: 14px;
   color: rgb(165 243 252 / 90%);
-  flex-wrap: wrap;
-  gap: 16px;
+  white-space: nowrap;
 }
 
 .monitor-board-layout {
   display: grid;
-  width: max-content;
+  width: 100%;
   height: 100%;
-  padding-bottom: 8px;
+  min-height: 0;
   flex: 1;
-  grid-template-columns: 760px 1500px 760px;
-  grid-template-rows: repeat(2, minmax(0, 1fr));
-  gap: 18px;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
 }
 
-.monitor-board-scroll-area {
-  height: 100%;
-  min-height: 0;
-  padding: 8px;
-  overflow: auto hidden;
-  flex: 1 1 0;
+.monitor-board-layout.is-1-cards {
+  grid-template-columns: minmax(0, 1fr);
+  grid-template-rows: minmax(0, 1fr);
 }
 
-.monitor-board-scroll-area::-webkit-scrollbar {
-  height: 10px;
+.monitor-board-layout.is-2-cards {
+  grid-template-rows: minmax(0, 1fr);
 }
 
-.monitor-board-scroll-area::-webkit-scrollbar-thumb {
-  background: rgb(34 211 238 / 70%);
-  border-radius: 999px;
+.monitor-board-layout.is-3-cards,
+.monitor-board-layout.is-4-cards {
+  grid-template-rows: repeat(2, minmax(0, 1fr));
 }
 
-.monitor-board-scroll-area::-webkit-scrollbar-track {
-  background: rgb(255 255 255 / 6%);
+.monitor-board-scroll-area {
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+  overflow: hidden;
+  flex: 1 1 0;
 }
 
 .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 {
@@ -707,41 +616,6 @@ onMounted(() => {
   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%;
 }
@@ -754,10 +628,6 @@ onMounted(() => {
   min-width: 0;
 }
 
-.monitor-card-shell :deep(.chart-header) {
-  cursor: pointer;
-}
-
 .search-container {
   position: relative;
   padding: 24px;

+ 210 - 71
src/views/oli-connection/monitoring/detail.vue

@@ -20,6 +20,7 @@ import { snapdom } from '@zumer/snapdom'
 import { Crop, DataLine, FullScreen, Odometer, Setting } from '@element-plus/icons-vue'
 import dayjs from 'dayjs'
 import * as echarts from 'echarts'
+import * as XLSX from 'xlsx'
 
 const { query } = useRoute()
 
@@ -118,6 +119,7 @@ const chartRef = ref<HTMLDivElement | null>(null)
 const targetArea = ref<HTMLElement | null>(null)
 const downloadRef = ref<HTMLElement | null>(null)
 const chartLoading = ref(false)
+const exportDataLoading = ref(false)
 const chartRealtimeMode = ref(true)
 const token = ref('')
 let chart: echarts.ECharts | null = null
@@ -135,7 +137,7 @@ interface ChartData {
   [key: Dimensions['name']]: ChartPoint[]
 }
 
-const chartData = ref<ChartData>({})
+const chartData = shallowRef<ChartData>({})
 const resizeChart = () => chart?.resize()
 
 watch(isConnected, (newVal) => {
@@ -195,39 +197,21 @@ function getDimensionByName(name: string) {
   return dimensions.value.find((item) => item.name === name)
 }
 
-function getDatasetMaxValue(name: string) {
-  const dataset = chartData.value[name] || []
-  let maxValue = -Infinity
-
-  dataset.forEach(({ value }) => {
-    const numberValue = Number(value)
-    if (Number.isFinite(numberValue) && numberValue > maxValue) {
-      maxValue = numberValue
-    }
-  })
-
-  const currentValue = normalizeRangeValue(getDimensionByName(name)?.value)
-  if (currentValue !== undefined && currentValue > maxValue) {
-    maxValue = currentValue
-  }
-
-  return Number.isFinite(maxValue) ? maxValue : 0
-}
-
 function getSeriesRange(name: string) {
   const dimension = getDimensionByName(name)
-  const configuredMin = normalizeRangeValue(dimension?.minValue)
-  const configuredMax = normalizeRangeValue(dimension?.maxValue)
-  const min = configuredMin ?? 0
-  const max = configuredMax ?? getDatasetMaxValue(name)
-  const normalizedMax = max > min ? max : min + 1
+  const configuredMin = normalizeRangeValue(dimension?.axisMin)
+  const configuredMax = normalizeRangeValue(dimension?.axisMax)
+  const configured =
+    configuredMin !== undefined && configuredMax !== undefined && configuredMin < configuredMax
+  const min = configured ? configuredMin : 0
+  const max = configured ? configuredMax : 100
 
   return {
     min,
-    max: normalizedMax,
+    max,
     labelMin: min,
-    labelMax: normalizedMax,
-    configured: configuredMin !== undefined && configuredMax !== undefined
+    labelMax: max,
+    configured
   }
 }
 
@@ -290,12 +274,14 @@ function getPointTimestamp(value: unknown) {
   return Number.isFinite(parsedValue) ? parsedValue : Date.now()
 }
 
-function mapChartPoint({ ts, value }: ChartPoint, name: string) {
+type SeriesRange = ReturnType<typeof getSeriesRange>
+
+function mapChartPoint({ ts, value }: ChartPoint, range: SeriesRange) {
   const numberValue = Number(value)
 
   if (!Number.isFinite(numberValue)) return [ts, null, null]
 
-  const { min, max } = getSeriesRange(name)
+  const { min, max } = range
   const denominator = max - min || 1
   const mappedValue = ((numberValue - min) / denominator) * 100
   const displayValue = Math.min(100, Math.max(0, mappedValue))
@@ -303,20 +289,36 @@ function mapChartPoint({ ts, value }: ChartPoint, name: string) {
   return [ts, Number(displayValue.toFixed(2)), numberValue]
 }
 
+function buildSeriesData(name: string) {
+  const range = getSeriesRange(name)
+
+  return (chartData.value[name] || []).map((point) => mapChartPoint(point, range))
+}
+
 function getLatestTimestamp() {
   let latest: number | null = null
 
   Object.values(chartData.value).forEach((series) => {
-    series.forEach((point) => {
-      if (latest === null || point.ts > latest) {
-        latest = point.ts
-      }
-    })
+    const point = series[series.length - 1]
+    if (point && (latest === null || point.ts > latest)) latest = point.ts
   })
 
   return latest
 }
 
+function findPointIndex(series: ChartPoint[], ts: number) {
+  let low = 0
+  let high = series.length
+
+  while (low < high) {
+    const middle = Math.floor((low + high) / 2)
+    if (series[middle].ts < ts) low = middle + 1
+    else high = middle
+  }
+
+  return low
+}
+
 function getSelectedDateRangeTimestamps() {
   const fallbackEnd = Date.now()
   const startTs = dayjs(selectedDate.value[0]).valueOf()
@@ -361,9 +363,9 @@ function trimChartDataToRealtimeWindow(anchorTs = Date.now()) {
 
   Object.keys(chartData.value).forEach((name) => {
     const series = chartData.value[name] || []
-    const windowStartIndex = series.findIndex((point) => point.ts >= startTs)
+    const windowStartIndex = findPointIndex(series, startTs)
 
-    if (windowStartIndex === -1) {
+    if (windowStartIndex === series.length) {
       chartData.value[name] = series.slice(-1)
       return
     }
@@ -376,15 +378,14 @@ function trimChartDataToRealtimeWindow(anchorTs = Date.now()) {
 
 function appendChartPoint(name: string, point: ChartPoint) {
   const series = chartData.value[name] ?? []
-  const samePointIndex = series.findIndex((item) => item.ts === point.ts)
+  const pointIndex = findPointIndex(series, point.ts)
 
-  if (samePointIndex >= 0) {
-    series[samePointIndex] = point
+  if (series[pointIndex]?.ts === point.ts) {
+    series[pointIndex] = point
   } else {
-    series.push(point)
+    series.splice(pointIndex, 0, point)
   }
 
-  series.sort((a, b) => a.ts - b.ts)
   chartData.value[name] = series
 }
 
@@ -411,7 +412,7 @@ function updateSingleSeries(name: string) {
       {
         id: name,
         name,
-        data: chartData.value[name]?.map((item) => mapChartPoint(item, name)) ?? []
+        data: buildSeriesData(name)
       }
     ]
   })
@@ -430,7 +431,7 @@ function updateSeriesByNames(names: string[]) {
     series: names.map((name) => ({
       id: name,
       name,
-      data: chartData.value[name]?.map((item) => mapChartPoint(item, name)) ?? []
+      data: buildSeriesData(name)
     }))
   })
 }
@@ -460,15 +461,17 @@ function flushRealtimeChartUpdate() {
     series: names.map((name) => ({
       id: name,
       name,
-      data: chartData.value[name]?.map((item) => mapChartPoint(item, name)) ?? []
+      data: buildSeriesData(name)
     }))
   })
 }
 
 function scheduleRealtimeChartUpdate(names: string[]) {
-  names.forEach((name) => pendingRealtimeSeriesNames.add(name))
+  names.forEach((name) => {
+    if (selectedDimension.value[name]) pendingRealtimeSeriesNames.add(name)
+  })
 
-  if (realtimeUpdateFrame !== null) return
+  if (!pendingRealtimeSeriesNames.size || realtimeUpdateFrame !== null) return
 
   realtimeUpdateFrame = window.requestAnimationFrame(flushRealtimeChartUpdate)
 }
@@ -581,6 +584,7 @@ function renderChart() {
         id: item.name,
         name: item.name,
         type: 'line',
+        sampling: 'lttb',
         smooth: 0.24,
         showSymbol: false,
         connectNulls: true,
@@ -603,7 +607,7 @@ function renderChart() {
           shadowColor: 'rgba(15, 23, 42, 0.16)'
         },
         color: item.color,
-        data: chartData.value[item.name]?.map((point) => mapChartPoint(point, item.name)) ?? []
+        data: buildSeriesData(item.name)
       }))
     },
     true
@@ -712,7 +716,9 @@ async function initLoadChartData({
     } else {
       applySelectedDateWindow()
     }
-    updateSeriesByNames(dimensions.value.map((item) => item.name))
+    updateSeriesByNames(
+      dimensions.value.filter((item) => selectedDimension.value[item.name]).map((item) => item.name)
+    )
   } finally {
     chartLoading.value = false
   }
@@ -799,6 +805,85 @@ async function exportChart() {
   }
 }
 
+const MAX_XLSX_TIME_COLUMNS = 16_383
+
+function getSelectedExportDimensions() {
+  return dimensions.value.filter((item) => selectedDimension.value[item.name])
+}
+
+function buildExportRows(selectedDimensions: Dimensions[]) {
+  const timestamps = Array.from(
+    new Set(
+      selectedDimensions.flatMap((dimension) =>
+        (chartData.value[dimension.name] || []).map((point) => point.ts)
+      )
+    )
+  ).sort((a, b) => a - b)
+
+  const rows: Array<Array<string | number | Date | null>> = [
+    ['属性名称', ...timestamps.map((timestamp) => new Date(timestamp))]
+  ]
+
+  selectedDimensions.forEach((dimension) => {
+    const valuesByTimestamp = new Map(
+      (chartData.value[dimension.name] || []).map((point) => [point.ts, point.value])
+    )
+
+    rows.push([
+      dimension.name,
+      ...timestamps.map((timestamp) => valuesByTimestamp.get(timestamp) ?? null)
+    ])
+  })
+
+  return { rows, timestamps }
+}
+
+async function exportChartData() {
+  const selectedDimensions = getSelectedExportDimensions()
+
+  if (!selectedDimensions.length) {
+    message.warning('请至少选择一个属性后再导出')
+    return
+  }
+
+  const { rows, timestamps } = buildExportRows(selectedDimensions)
+
+  if (!timestamps.length) {
+    message.warning('所选属性暂无可导出的数据')
+    return
+  }
+
+  if (timestamps.length > MAX_XLSX_TIME_COLUMNS) {
+    message.warning(`时间点共 ${timestamps.length} 个,请缩短时间范围后再导出`)
+    return
+  }
+
+  exportDataLoading.value = true
+  try {
+    const worksheet = XLSX.utils.aoa_to_sheet(rows, { cellDates: true })
+    worksheet['!cols'] = [{ wch: 24 }, ...timestamps.map(() => ({ wch: 20 }))]
+
+    timestamps.forEach((_, index) => {
+      const cell = worksheet[XLSX.utils.encode_cell({ r: 0, c: index + 1 })]
+      if (cell) cell.z = 'yyyy-mm-dd hh:mm:ss'
+    })
+
+    const workbook = XLSX.utils.book_new()
+    XLSX.utils.book_append_sheet(workbook, worksheet, '监控数据')
+    const fileName = `${data.value.deviceName || data.value.deviceCode || '设备'}-监控数据.xlsx`
+
+    XLSX.writeFileXLSX(workbook, fileName.replace(/[\\/:*?"<>|]/g, '-'), {
+      compression: true
+    })
+    message.success('数据导出成功')
+  } catch (error) {
+    console.error(error)
+    message.error('导出数据失败')
+  } finally {
+    exportDataLoading.value = false
+  }
+}
+
 function handleClickSpec(modelName: string) {
   selectedDimension.value[modelName] = !selectedDimension.value[modelName]
   chart?.setOption({
@@ -832,41 +917,63 @@ function openRangeDialog(targetName?: string) {
     identifier: item.identifier,
     color: item.color,
     minValue: normalizeRangeValue(item.minValue),
-    maxValue: normalizeRangeValue(item.maxValue)
+    maxValue: normalizeRangeValue(item.maxValue),
+    axisMin: normalizeRangeValue(item.axisMin),
+    axisMax: normalizeRangeValue(item.axisMax)
   }))
   rangeDialogVisible.value = true
 }
 
-async function saveRangeSetting(item: Dimensions, minValue?: number, maxValue?: number) {
+async function saveRangeSetting(
+  item: Dimensions,
+  minValue?: number,
+  maxValue?: number,
+  axisMinValue?: number,
+  axisMaxValue?: number
+) {
   const min = normalizeRangeValue(minValue)
   const max = normalizeRangeValue(maxValue)
+  const axisMin = normalizeRangeValue(axisMinValue)
+  const axisMax = normalizeRangeValue(axisMaxValue)
 
-  if (min === undefined && max === undefined) {
+  if (min === undefined && max === undefined && axisMin === undefined && axisMax === undefined) {
     if (item.id) {
       await IotDeviceApi.deleteMaxMin({ id: item.id })
     }
 
     item.minValue = undefined
     item.maxValue = undefined
+    item.axisMin = undefined
+    item.axisMax = undefined
     item.id = undefined
     return
   }
 
-  if (min === undefined || max === undefined) {
-    throw new Error(`${item.name} 的最大值和最小值需要同时填写`)
+  if ((min === undefined) !== (max === undefined)) {
+    throw new Error(`${item.name} 的告警上限和告警下限需要同时填写`)
+  }
+
+  if (min !== undefined && max !== undefined && min > max) {
+    throw new Error(`${item.name} 的告警下限不能大于告警上限`)
   }
 
-  if (min > max) {
-    throw new Error(`${item.name} 的最小值不能大于最大值`)
+  if (min !== undefined && max !== undefined && min === max) {
+    throw new Error(`${item.name} 的告警上限和告警下限不能相等`)
   }
 
-  if (min === max) {
-    throw new Error(`${item.name} 的最大值和最小值不能相等`)
+  if ((axisMin === undefined) !== (axisMax === undefined)) {
+    throw new Error(`${item.name} 的 Y 轴上限和 Y 轴下限需要同时填写`)
   }
-  12
+
+  if (axisMin !== undefined && axisMax !== undefined && axisMin >= axisMax) {
+    throw new Error(`${item.name} 的 Y 轴下限必须小于 Y 轴上限`)
+  }
+
   const body = {
-    minValue: min,
-    maxValue: max,
+    minValue: min ?? null,
+    maxValue: max ?? null,
+    axisMin: axisMin ?? null,
+    axisMax: axisMax ?? null,
     deviceId: query.id,
     propertyCode: item.identifier,
     alarmProperty: item.name,
@@ -879,6 +986,8 @@ async function saveRangeSetting(item: Dimensions, minValue?: number, maxValue?:
   if (res.id) item.id = res.id
   item.minValue = min
   item.maxValue = max
+  item.axisMin = axisMin
+  item.axisMax = axisMax
 }
 
 async function handleRangeDialogSave() {
@@ -887,7 +996,7 @@ async function handleRangeDialogSave() {
       const item = dimensions.value.find((dimension) => dimension.identifier === draft.identifier)
       if (!item) continue
 
-      await saveRangeSetting(item, draft.minValue, draft.maxValue)
+      await saveRangeSetting(item, draft.minValue, draft.maxValue, draft.axisMin, draft.axisMax)
     }
 
     updateSeriesByNames(dimensions.value.map((item) => item.name))
@@ -902,13 +1011,17 @@ function handleRangeDialogReset() {
   rangeSettingDrafts.value = rangeSettingDrafts.value.map((item) => ({
     ...item,
     minValue: undefined,
-    maxValue: undefined
+    maxValue: undefined,
+    axisMin: undefined,
+    axisMax: undefined
   }))
 }
 
 function clearRangeDraft(row: RangeSettingDraft) {
   row.minValue = undefined
   row.maxValue = undefined
+  row.axisMin = undefined
+  row.axisMax = undefined
 }
 
 onMounted(() => {
@@ -1012,13 +1125,13 @@ onUnmounted(() => {
                       class="dimension-card__ranges">
                       <span v-if="item.maxValue !== undefined" class="range-pill range-pill--max">
                         <i class="i-material-symbols:arrow-upward-alt-rounded"></i>
-                        <span>MAX</span>
+                        <span>告警上限</span>
                         <strong>{{ formatChartValue(item.maxValue) }}</strong>
                       </span>
 
                       <span v-if="item.minValue !== undefined" class="range-pill range-pill--min">
                         <i class="i-material-symbols:arrow-downward-alt-rounded"></i>
-                        <span>MIN</span>
+                        <span>告警下限</span>
                         <strong>{{ formatChartValue(item.minValue) }}</strong>
                       </span>
                     </span>
@@ -1040,6 +1153,14 @@ onUnmounted(() => {
           <span>数据趋势</span>
         </h3>
         <div class="chart-panel__actions">
+          <el-button
+            type="success"
+            size="default"
+            :loading="exportDataLoading"
+            :disabled="chartLoading"
+            @click="exportChartData">
+            导出数据
+          </el-button>
           <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
           <el-button size="default" @click="resetChart">重置</el-button>
           <el-date-picker
@@ -1108,8 +1229,8 @@ onUnmounted(() => {
 
     <el-dialog
       v-model="rangeDialogVisible"
-      title="曲线量程设置"
-      width="900px"
+      title="告警阈值与 Y 轴范围"
+      width="1100px"
       append-to-body
       destroy-on-close>
       <ZmTable :data="rangeSettingDrafts" :loading="false" :max-height="520" :show-border="true">
@@ -1123,25 +1244,43 @@ onUnmounted(() => {
             </div>
           </template>
         </ZmTableColumn>
-        <ZmTableColumn label="最小值" width="180">
+        <ZmTableColumn label="告警下限" width="140">
           <template #default="{ row }">
             <el-input-number
               v-model="row.minValue"
               class="!w-full"
               :controls="false"
-              :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
+              placeholder="未设置" />
           </template>
         </ZmTableColumn>
-        <ZmTableColumn label="最大值" width="180">
+        <ZmTableColumn label="告警上限" width="140">
           <template #default="{ row }">
             <el-input-number
               v-model="row.maxValue"
               class="!w-full"
               :controls="false"
+              placeholder="未设置" />
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn label="Y 轴下限" width="140">
+          <template #default="{ row }">
+            <el-input-number
+              v-model="row.axisMin"
+              class="!w-full"
+              :controls="false"
+              :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn label="Y 轴上限" width="140">
+          <template #default="{ row }">
+            <el-input-number
+              v-model="row.axisMax"
+              class="!w-full"
+              :controls="false"
               :placeholder="formatChartValue(getSeriesRange(row.name).labelMax)" />
           </template>
         </ZmTableColumn>
-        <ZmTableColumn label="当前使用范围" width="170">
+        <ZmTableColumn label="当前 Y 轴范围" width="150">
           <template #default="{ row }">
             <span class="text-xs text-slate-500">
               {{ formatChartValue(getSeriesRange(row.name).labelMin) }}

+ 23 - 31
src/views/pms/iotrddailyreport/FillDailyReportForm.vue

@@ -1,15 +1,15 @@
 <script lang="ts" setup>
-import { IotRdDailyReportApi } from '@/api/pms/iotrddailyreport'
-import { FormInstance, FormRules } from 'element-plus'
-import { CircleCheck, Plus, Delete } from '@element-plus/icons-vue'
-import { calculateDuration, formatDateNoTime, formatT } from '@/utils/formatTime'
-import { getStrDictOptions } from '@/utils/dict'
 import { IotDailyReportAttrsApi } from '@/api/pms/iotdailyreportattrs'
+import { IotRdDailyReportApi } from '@/api/pms/iotrddailyreport'
 import { useTableComponents } from '@/components/ZmTable/useTableComponents'
-import dayjs from 'dayjs'
+import { getStrDictOptions } from '@/utils/dict'
+import { calculateDuration, formatDateNoTime, formatT } from '@/utils/formatTime'
+import { CircleCheck, Delete, Plus } from '@element-plus/icons-vue'
 import { useDebounceFn } from '@vueuse/core'
-import { cloneDeep } from 'lodash-es'
+import dayjs from 'dayjs'
+import { FormInstance, FormRules } from 'element-plus'
 import { Base64 } from 'js-base64'
+import { cloneDeep } from 'lodash-es'
 
 const NON_PROD_FIELDS = [
   { key: 'repairTime', label: '设备故障' },
@@ -238,33 +238,25 @@ function initPlatformData(reportId: number, sourceData: any) {
 // const dailyFuel = ref(0)
 
 const initDailyFuel = () => {
-  const propVal = data.value.dailyFuel
-
-  const hasPropValue = propVal !== undefined && propVal !== null && !isNaN(propVal)
-
-  if (hasPropValue) {
-    // dailyFuel.value = propVal
-    form.value.dailyFuel = propVal
-  } else {
-    const list1 = data.value.reportFuels || []
-    const list2 = data.value.reportedFuels || []
-
-    const validList = list1.length > 0 ? list1 : list2.length > 0 ? list2 : []
-
-    form.value.reportFuels = validList.map((v) => ({
+  if (formType.value === 'edit') {
+    form.value.reportFuels = (data.value.reportFuels || []).map((v) => ({
       ...v,
-      customFuel: Number(
-        Number(true ? (v.customFuel ?? 0) : (v.customFuel ?? v.zhbdFuel ?? 0)).toFixed(2)
-      )
+      customFuel: Number(Number(v.zhbdFuel ?? 0).toFixed(2))
     }))
-
-    let total = 0
-    form.value.reportFuels.forEach((item) => {
-      total += item.customFuel
-    })
-    // dailyFuel.value = total
-    form.value.dailyFuel = total
+    form.value.dailyFuel = form.value.reportFuels.reduce(
+      (total, item) => total + item.customFuel,
+      0
+    )
+    return
   }
+
+  form.value.reportFuels = data.value.reportedFuels || []
+
+  const propVal = data.value.dailyFuel
+  const hasPropValue = propVal !== undefined && propVal !== null && !isNaN(propVal)
+  form.value.dailyFuel = hasPropValue
+    ? propVal
+    : form.value.reportFuels.reduce((total, item) => total + Number(item.customFuel || 0), 0)
 }
 
 function getReportDetailDuration(row: ReportDetail) {

+ 10 - 0
src/views/pms/stat/rdkb.vue

@@ -26,11 +26,14 @@ const pageTabs = [
 
 const wrapperRef = ref<HTMLDivElement>()
 const scale = ref(1)
+const initialDevicePixelRatio = window.devicePixelRatio || 1
+const browserZoom = ref(1)
 
 let resizeObserver: ResizeObserver | null = null
 let resizeRaf = 0
 
 provide('rdKbScale', scale)
+provide('rdBrowserZoom', browserZoom)
 
 const targetWrapperStyle = computed(() => ({
   width: `${DESIGN_WIDTH * scale.value}px`,
@@ -66,13 +69,19 @@ function updateScale() {
   })
 }
 
+function updateBrowserZoom() {
+  browserZoom.value = (window.devicePixelRatio || initialDevicePixelRatio) / initialDevicePixelRatio
+}
+
 onMounted(() => {
   nextTick(updateScale)
+  updateBrowserZoom()
   resizeObserver = new ResizeObserver(updateScale)
   if (wrapperRef.value) {
     resizeObserver.observe(wrapperRef.value)
   }
   window.addEventListener('resize', updateScale)
+  window.addEventListener('resize', updateBrowserZoom)
 })
 
 watch(activePage, () => {
@@ -82,6 +91,7 @@ watch(activePage, () => {
 onUnmounted(() => {
   resizeObserver?.disconnect()
   window.removeEventListener('resize', updateScale)
+  window.removeEventListener('resize', updateBrowserZoom)
   cancelAnimationFrame(resizeRaf)
 })
 </script>

+ 70 - 9
src/views/pms/stat/rdkb/rd-cost.vue

@@ -7,16 +7,26 @@ import {
   createTooltip,
   FONT_FAMILY,
   formatDateLabel,
+  formatMonthLabel,
   THEME
 } from '@/utils/kb'
 import { IotStatApi } from '@/api/pms/stat'
 
+type CostRange = 'week' | 'sixMonth'
+
 const chartData = ref<ChartItem[]>([])
+const activeRange = ref<CostRange>('week')
+const loading = ref(false)
+const rangeOptions: Array<{ label: string; value: CostRange }> = [
+  { label: '近一周', value: 'week' },
+  { label: '近六个月', value: 'sixMonth' }
+]
 
 const router = useRouter()
 const chartRef = ref<HTMLDivElement>()
 let chart: echarts.ECharts | null = null
 let chartClickBound = false
+let loadRequestId = 0
 
 function getChartOption(data: ChartItem[]): echarts.EChartsOption {
   const names = data.map((item) => item.name)
@@ -32,6 +42,9 @@ function getChartOption(data: ChartItem[]): echarts.EChartsOption {
         shadowStyle: {
           color: THEME.split
         }
+      },
+      valueFormatter(value: number) {
+        return activeRange.value === 'sixMonth' ? `${Number(value).toFixed(2)} 万元` : `${value}`
       }
     }),
     xAxis: {
@@ -49,13 +62,13 @@ function getChartOption(data: ChartItem[]): echarts.EChartsOption {
         fontWeight: 500,
         fontFamily: FONT_FAMILY,
         formatter(value: string) {
-          return formatDateLabel(value)
+          return activeRange.value === 'sixMonth' ? formatMonthLabel(value) : formatDateLabel(value)
         }
       }
     },
     yAxis: {
       type: 'value',
-      name: '运维成本(元)',
+      name: activeRange.value === 'sixMonth' ? '运维成本(万元)' : '运维成本(元)',
       nameTextStyle: {
         color: THEME.text.regular,
         fontSize: 13,
@@ -108,7 +121,10 @@ function getChartOption(data: ChartItem[]): echarts.EChartsOption {
           color: THEME.color.orange.strong,
           fontSize: 16,
           fontWeight: 700,
-          fontFamily: FONT_FAMILY
+          fontFamily: FONT_FAMILY,
+          formatter(params: any) {
+            return activeRange.value === 'sixMonth' ? Number(params.value).toFixed(2) : params.value
+          }
         },
         emphasis: {
           itemStyle: {
@@ -175,21 +191,41 @@ function destroyChart() {
   }
 }
 
-async function loadChart() {
+async function loadChart(range: CostRange = activeRange.value) {
+  const requestId = ++loadRequestId
+  loading.value = true
+
   try {
-    const res = await IotStatApi.getOrderYwcb('rd')
+    const res =
+      range === 'sixMonth'
+        ? await IotStatApi.getRdMaintainCost({})
+        : await IotStatApi.getOrderYwcb('rd')
+    if (requestId !== loadRequestId) return
+
     chartData.value = res.xAxis.map((item, index) => ({
       name: item,
-      value: res.series[index]
+      value:
+        range === 'sixMonth'
+          ? Number((Number(res.series[index] || 0) / 10000).toFixed(2))
+          : res.series[index]
     }))
     renderChart()
   } catch (error) {
+    if (requestId !== loadRequestId) return
     console.error('运维成本:', error)
     chartData.value = []
     renderChart()
+  } finally {
+    if (requestId === loadRequestId) {
+      loading.value = false
+    }
   }
 }
 
+watch(activeRange, (range) => {
+  loadChart(range)
+})
+
 onMounted(() => {
   initChart()
   chart?.getZr().on('click', handleChartClick)
@@ -208,17 +244,42 @@ onUnmounted(() => {
 
 <template>
   <div class="panel flex flex-col">
-    <div class="panel-title">
+    <div class="panel-title flex items-center justify-between">
       <div class="icon-decorator">
         <span></span>
         <span></span>
       </div>
-      运维成本
+      <div class="kb-panel-title-text">运维成本</div>
+      <el-segmented
+        v-model="activeRange"
+        :options="rangeOptions"
+        size="small"
+        class="cost-range-switch" />
     </div>
-    <div ref="chartRef" class="flex-1 min-h-0"></div>
+    <div ref="chartRef" v-loading="loading" class="flex-1 min-h-0"></div>
   </div>
 </template>
 
 <style lang="scss" scoped>
 @import url('@/styles/kb.scss');
+
+.cost-range-switch {
+  --el-segmented-item-selected-color: #03409b;
+  --el-segmented-item-selected-bg-color: rgb(255 255 255 / 86%);
+  --el-segmented-bg-color: rgb(31 91 184 / 10%);
+  --el-segmented-item-hover-bg-color: rgb(255 255 255 / 56%);
+
+  min-height: calc(26px * var(--kb-scale, 1));
+  padding: calc(2px * var(--kb-scale, 1));
+  border: 1px solid rgb(31 91 184 / 12%);
+  transform: translateY(calc(-2px * var(--kb-scale, 1)));
+
+  :deep(.el-segmented__item) {
+    min-height: calc(22px * var(--kb-scale, 1));
+    padding: 0 calc(7px * var(--kb-scale, 1));
+    font-size: calc(13px * var(--kb-scale, 1));
+    font-weight: 600;
+    color: #29527f;
+  }
+}
 </style>

+ 36 - 21
src/views/pms/stat/rdkb/rdProductionBriefs.vue

@@ -42,6 +42,7 @@ const loading = ref(false)
 const list = ref<RdProductionBriefRow[]>([])
 const router = useRouter()
 const kbScale = inject<Ref<number>>('rdKbScale', ref(1))
+const browserZoom = inject<Ref<number>>('rdBrowserZoom', ref(1))
 const tableHeight = computed<number | string>(() =>
   props.pageMode === 'full' ? '100%' : Math.round(TABLE_HEIGHT * kbScale.value)
 )
@@ -167,6 +168,7 @@ onMounted(() => {
 <template>
   <div
     class="panel device-list-panel production-brief-panel w-full min-h-0 flex flex-col"
+    :style="{ '--rd-production-brief-browser-zoom': browserZoom }"
     :class="{ 'production-brief-panel--full': props.pageMode === 'full' }">
     <div class="panel-title device-list-panel__title flex items-center justify-between">
       <div class="kb-panel-title-text flex items-center">
@@ -201,7 +203,9 @@ onMounted(() => {
         @row-click="handleRowClick">
         <el-table-column prop="projectName" label="项目" min-width="150" align="center">
           <template #default="{ row }">
-            {{ formatText(row.projectName) }}
+            <div class="production-brief-table__summary">
+              {{ formatText(row.projectName) }}
+            </div>
           </template>
         </el-table-column>
         <el-table-column prop="deptName" label="队伍" min-width="110" align="center">
@@ -216,12 +220,16 @@ onMounted(() => {
         </el-table-column>
         <el-table-column prop="taskName" label="井号" min-width="120" align="center">
           <template #default="{ row }">
-            {{ formatText(row.taskName) }}
+            <div class="production-brief-table__summary">
+              {{ formatText(row.taskName) }}
+            </div>
           </template>
         </el-table-column>
         <el-table-column prop="techniqueNames" label="工艺" min-width="130" align="center">
           <template #default="{ row }">
-            {{ formatText(row.techniqueNames) }}
+            <div class="production-brief-table__summary">
+              {{ formatText(row.techniqueNames) }}
+            </div>
           </template>
         </el-table-column>
         <el-table-column
@@ -304,42 +312,49 @@ onMounted(() => {
   width: 100%;
 
   :deep(.el-table__header-wrapper th.el-table__cell) {
-    font-size: calc(16px * var(--kb-scale, 1));
+    font-size: calc(16px * var(--rd-production-brief-browser-zoom, 1));
     line-height: 1.2;
-    color: #10233c;
-    background: #b5cde7;
-    border-color: #fff;
+    color: #0e3f8a;
+    background: #7fb5ff;
+    border-color: #a6c4ec;
+    font-weight: 600;
   }
 
   :deep(.el-table__body td.el-table__cell) {
-    padding: calc(7px * var(--kb-scale, 1)) 0;
-    font-size: calc(14px * var(--kb-scale, 1));
-    color: #07192c;
-    background: #89b3de;
-    border-color: #fff;
+    font-size: calc(16px * var(--rd-production-brief-browser-zoom, 1));
   }
 
-  :deep(.el-table__body tr:nth-child(2n) td.el-table__cell) {
-    background: #b8cee5;
-  }
+  // :deep(.el-table__body td.el-table__cell) {
+  //   padding: calc(7px * var(--kb-scale, 1)) 0;
+  //   font-size: calc(14px * var(--kb-scale, 1));
+  //   color: #07192c;
+  //   background: #89b3de;
+  //   border-color: #fff;
+  // }
 
-  :deep(.el-table__body tr) {
-    cursor: pointer;
-  }
+  // :deep(.el-table__body tr:nth-child(2n) td.el-table__cell) {
+  //   background: #b8cee5;
+  // }
 
-  :deep(.el-table__row:hover > td.el-table__cell) {
-    background: #75a5d6 !important;
-  }
+  // :deep(.el-table__body tr) {
+  //   cursor: pointer;
+  // }
+
+  // :deep(.el-table__row:hover > td.el-table__cell) {
+  //   background: #75a5d6 !important;
+  // }
 }
 
 .production-brief-table__device-names {
   display: block;
+  font-size: calc(16px * var(--rd-production-brief-browser-zoom, 1));
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
 }
 
 .production-brief-table__summary {
+  font-size: calc(16px * var(--rd-production-brief-browser-zoom, 1));
   line-height: 1.5;
   white-space: pre-wrap;
   overflow-wrap: anywhere;

+ 10 - 0
src/views/pms/stat/rhkb.vue

@@ -27,11 +27,14 @@ const pageTabs = [
 
 const wrapperRef = ref<HTMLDivElement>()
 const scale = ref(1)
+const initialDevicePixelRatio = window.devicePixelRatio || 1
+const browserZoom = ref(1)
 
 let resizeObserver: ResizeObserver | null = null
 let resizeRaf = 0
 
 provide('rhKbScale', scale)
+provide('rhBrowserZoom', browserZoom)
 
 const targetWrapperStyle = computed(() => ({
   width: `${DESIGN_WIDTH * scale.value}px`,
@@ -65,13 +68,19 @@ function updateScale() {
   })
 }
 
+function updateBrowserZoom() {
+  browserZoom.value = (window.devicePixelRatio || initialDevicePixelRatio) / initialDevicePixelRatio
+}
+
 onMounted(() => {
   nextTick(updateScale)
+  updateBrowserZoom()
   resizeObserver = new ResizeObserver(updateScale)
   if (wrapperRef.value) {
     resizeObserver.observe(wrapperRef.value)
   }
   window.addEventListener('resize', updateScale)
+  window.addEventListener('resize', updateBrowserZoom)
 })
 
 watch(activePage, () => {
@@ -83,6 +92,7 @@ watch(activePage, () => {
 onUnmounted(() => {
   resizeObserver?.disconnect()
   window.removeEventListener('resize', updateScale)
+  window.removeEventListener('resize', updateBrowserZoom)
   cancelAnimationFrame(resizeRaf)
 })
 </script>

+ 28 - 13
src/views/pms/stat/rhkb/deviceList.vue

@@ -50,6 +50,7 @@ const teamLoading = ref(false)
 const currentProjectDeptName = ref('')
 const teamList = ref<RhTeamRateRow[]>([])
 const kbScale = inject<Ref<number>>('rhKbScale', ref(1))
+const browserZoom = inject<Ref<number>>('rhBrowserZoom', ref(1))
 
 const tableData = computed(() => list.value)
 const createTime = computed(() => [
@@ -134,6 +135,7 @@ onMounted(() => {
 <template>
   <div
     class="panel device-list-panel w-full min-h-0 flex flex-col"
+    :style="{ '--rh-device-list-browser-zoom': browserZoom }"
     :class="{ 'device-list-panel--full': props.pageMode === 'full' }">
     <div class="panel-title device-list-panel__title flex items-center justify-between">
       <div class="kb-panel-title-text flex items-center">
@@ -168,7 +170,11 @@ onMounted(() => {
         class="device-list-table"
         :class="{ 'device-list-table--full': props.pageMode === 'full' }"
         @row-click="handleRowClick">
-        <el-table-column prop="projectDeptName" label="项目部" width="140" align="center" />
+        <el-table-column prop="projectDeptName" label="项目部" width="140" align="center">
+          <template #default="{ row }">
+            <div class="device-list-table__summary">{{ row.projectDeptName || '--' }}</div>
+          </template>
+        </el-table-column>
         <el-table-column prop="teamCount" label="队伍总数" width="124" align="center" />
         <el-table-column prop="dmTeamCount" label="待命" width="124" align="center" />
         <el-table-column prop="zbTeamCount" label="施工准备" width="124" align="center" />
@@ -272,34 +278,43 @@ onMounted(() => {
 
 .device-list-table {
   :deep(.el-table__header-wrapper th.el-table__cell) {
-    color: #10233c;
-    background: #b5cde7;
-    border-color: #fff;
+    font-size: calc(16px * var(--rh-device-list-browser-zoom, 1));
+    line-height: 1.2;
+    color: #0e3f8a;
+    background: #7fb5ff;
+    border-color: #a6c4ec;
+    font-weight: 600;
   }
 
   :deep(.el-table__body td.el-table__cell) {
-    color: #07192c;
-    background: #89b3de;
-    border-color: #fff;
+    font-size: calc(16px * var(--rh-device-list-browser-zoom, 1));
   }
 
-  :deep(.el-table__body tr:nth-child(2n) td.el-table__cell) {
-    background: #b8cee5;
-  }
+  // :deep(.el-table__body td.el-table__cell) {
+  //   color: #07192c;
+  //   background: rgba(137, 179, 222);
+  //   border-color: #fff;
+  // }
 
-  :deep(.el-table__row:hover > td.el-table__cell) {
-    background: #75a5d6 !important;
-  }
+  // :deep(.el-table__body tr:nth-child(2n) td.el-table__cell) {
+  //   background: #b8cee5;
+  // }
+
+  // :deep(.el-table__row:hover > td.el-table__cell) {
+  //   background: #75a5d6 !important;
+  // }
 }
 
 .device-list-table__multi-value {
   display: flex;
   flex-direction: column;
   gap: calc(4px * var(--kb-scale, 1));
+  font-size: calc(16px * var(--rh-device-list-browser-zoom, 1));
   line-height: 1.35;
 }
 
 .device-list-table__summary {
+  font-size: calc(16px * var(--rh-device-list-browser-zoom, 1));
   line-height: 1.5;
   white-space: pre-wrap;
   overflow-wrap: anywhere;

+ 70 - 9
src/views/pms/stat/rhkb/operation.vue

@@ -7,16 +7,26 @@ import {
   createTooltip,
   FONT_FAMILY,
   formatDateLabel,
+  formatMonthLabel,
   THEME
 } from '@/utils/kb'
 import { IotStatApi } from '@/api/pms/stat'
 
+type CostRange = 'week' | 'sixMonth'
+
 const chartData = ref<ChartItem[]>([])
+const activeRange = ref<CostRange>('week')
+const loading = ref(false)
+const rangeOptions: Array<{ label: string; value: CostRange }> = [
+  { label: '近一周', value: 'week' },
+  { label: '近六个月', value: 'sixMonth' }
+]
 
 const router = useRouter()
 const chartRef = ref<HTMLDivElement>()
 let chart: echarts.ECharts | null = null
 let chartClickBound = false
+let loadRequestId = 0
 
 function getChartOption(data: ChartItem[]): echarts.EChartsOption {
   const names = data.map((item) => item.name)
@@ -32,6 +42,9 @@ function getChartOption(data: ChartItem[]): echarts.EChartsOption {
         shadowStyle: {
           color: THEME.split
         }
+      },
+      valueFormatter(value: number) {
+        return activeRange.value === 'sixMonth' ? `${Number(value).toFixed(2)} 万元` : `${value}`
       }
     }),
     xAxis: {
@@ -49,13 +62,13 @@ function getChartOption(data: ChartItem[]): echarts.EChartsOption {
         fontWeight: 500,
         fontFamily: FONT_FAMILY,
         formatter(value: string) {
-          return formatDateLabel(value)
+          return activeRange.value === 'sixMonth' ? formatMonthLabel(value) : formatDateLabel(value)
         }
       }
     },
     yAxis: {
       type: 'value',
-      name: '运维成本(元)',
+      name: activeRange.value === 'sixMonth' ? '运维成本(万元)' : '运维成本(元)',
       nameTextStyle: {
         color: THEME.text.regular,
         fontSize: 13,
@@ -108,7 +121,10 @@ function getChartOption(data: ChartItem[]): echarts.EChartsOption {
           color: THEME.color.orange.strong,
           fontSize: 16,
           fontWeight: 700,
-          fontFamily: FONT_FAMILY
+          fontFamily: FONT_FAMILY,
+          formatter(params: any) {
+            return activeRange.value === 'sixMonth' ? Number(params.value).toFixed(2) : params.value
+          }
         },
         emphasis: {
           itemStyle: {
@@ -175,21 +191,41 @@ function destroyChart() {
   }
 }
 
-async function loadChart() {
+async function loadChart(range: CostRange = activeRange.value) {
+  const requestId = ++loadRequestId
+  loading.value = true
+
   try {
-    const res = await IotStatApi.getOrderYwcb('rh')
+    const res =
+      range === 'sixMonth'
+        ? await IotStatApi.getMaintainCost({})
+        : await IotStatApi.getOrderYwcb('rh')
+    if (requestId !== loadRequestId) return
+
     chartData.value = res.xAxis.map((item, index) => ({
       name: item,
-      value: res.series[index]
+      value:
+        range === 'sixMonth'
+          ? Number((Number(res.series[index] || 0) / 10000).toFixed(2))
+          : res.series[index]
     }))
     renderChart()
   } catch (error) {
+    if (requestId !== loadRequestId) return
     console.error('运维成本:', error)
     chartData.value = []
     renderChart()
+  } finally {
+    if (requestId === loadRequestId) {
+      loading.value = false
+    }
   }
 }
 
+watch(activeRange, (range) => {
+  loadChart(range)
+})
+
 onMounted(() => {
   initChart()
   chart?.getZr().on('click', handleChartClick)
@@ -208,17 +244,42 @@ onUnmounted(() => {
 
 <template>
   <div class="panel flex flex-col">
-    <div class="panel-title">
+    <div class="panel-title flex items-center justify-between">
       <div class="icon-decorator">
         <span></span>
         <span></span>
       </div>
-      运维成本
+      <div class="kb-panel-title-text">运维成本</div>
+      <el-segmented
+        v-model="activeRange"
+        :options="rangeOptions"
+        size="small"
+        class="cost-range-switch" />
     </div>
-    <div ref="chartRef" class="flex-1 min-h-0"></div>
+    <div ref="chartRef" v-loading="loading" class="flex-1 min-h-0"></div>
   </div>
 </template>
 
 <style lang="scss" scoped>
 @import url('@/styles/kb.scss');
+
+.cost-range-switch {
+  --el-segmented-item-selected-color: #03409b;
+  --el-segmented-item-selected-bg-color: rgb(255 255 255 / 86%);
+  --el-segmented-bg-color: rgb(31 91 184 / 10%);
+  --el-segmented-item-hover-bg-color: rgb(255 255 255 / 56%);
+
+  min-height: calc(26px * var(--kb-scale, 1));
+  padding: calc(2px * var(--kb-scale, 1));
+  border: 1px solid rgb(31 91 184 / 12%);
+  transform: translateY(calc(-2px * var(--kb-scale, 1)));
+
+  :deep(.el-segmented__item) {
+    min-height: calc(22px * var(--kb-scale, 1));
+    padding: 0 calc(7px * var(--kb-scale, 1));
+    font-size: calc(13px * var(--kb-scale, 1));
+    font-weight: 600;
+    color: #29527f;
+  }
+}
 </style>

+ 10 - 0
src/views/pms/stat/rykb.vue

@@ -26,11 +26,14 @@ const pageTabs = [
 
 const wrapperRef = ref<HTMLDivElement>()
 const scale = ref(1)
+const initialDevicePixelRatio = window.devicePixelRatio || 1
+const browserZoom = ref(1)
 
 let resizeObserver: ResizeObserver | null = null
 let resizeRaf = 0
 
 provide('ryKbScale', scale)
+provide('ryBrowserZoom', browserZoom)
 
 const targetWrapperStyle = computed(() => ({
   width: `${DESIGN_WIDTH * scale.value}px`,
@@ -64,13 +67,19 @@ function updateScale() {
   })
 }
 
+function updateBrowserZoom() {
+  browserZoom.value = (window.devicePixelRatio || initialDevicePixelRatio) / initialDevicePixelRatio
+}
+
 onMounted(() => {
   nextTick(updateScale)
+  updateBrowserZoom()
   resizeObserver = new ResizeObserver(updateScale)
   if (wrapperRef.value) {
     resizeObserver.observe(wrapperRef.value)
   }
   window.addEventListener('resize', updateScale)
+  window.addEventListener('resize', updateBrowserZoom)
 })
 
 watch(activePage, () => {
@@ -82,6 +91,7 @@ watch(activePage, () => {
 onUnmounted(() => {
   resizeObserver?.disconnect()
   window.removeEventListener('resize', updateScale)
+  window.removeEventListener('resize', updateBrowserZoom)
   cancelAnimationFrame(resizeRaf)
 })
 </script>

+ 31 - 19
src/views/pms/stat/rykb/ryProductionBriefs.vue

@@ -44,6 +44,7 @@ const selectedDate = ref(DEFAULT_DATE)
 const loading = ref(false)
 const list = ref<RyProductionBriefRow[]>([])
 const kbScale = inject<Ref<number>>('ryKbScale', ref(1))
+const browserZoom = inject<Ref<number>>('ryBrowserZoom', ref(1))
 const tableHeight = computed<number | string>(() =>
   props.pageMode === 'full' ? '100%' : Math.round(TABLE_HEIGHT * kbScale.value)
 )
@@ -179,6 +180,7 @@ onMounted(() => {
 <template>
   <div
     class="panel device-list-panel production-brief-panel w-full min-h-0 flex flex-col"
+    :style="{ '--production-brief-browser-zoom': browserZoom }"
     :class="{ 'production-brief-panel--full': props.pageMode === 'full' }">
     <div class="panel-title device-list-panel__title flex items-center justify-between">
       <div class="kb-panel-title-text flex items-center">
@@ -211,8 +213,16 @@ onMounted(() => {
         class="device-list-table production-brief-table"
         :class="{ 'device-list-table--full': props.pageMode === 'full' }">
         <el-table-column prop="projectClassification" label="公司" min-width="72" align="center" />
-        <el-table-column prop="projectName" label="项目" min-width="150" align="center" />
-        <el-table-column prop="deptName" label="队伍" min-width="94" align="center" />
+        <el-table-column prop="projectName" label="项目" min-width="150" align="center">
+          <template #default="{ row }">
+            <div class="production-brief-table__summary">{{ row.projectName || '--' }}</div>
+          </template>
+        </el-table-column>
+        <el-table-column prop="deptName" label="队伍" min-width="94" align="center">
+          <template #default="{ row }">
+            <div class="production-brief-table__summary">{{ row.deptName || '--' }}</div>
+          </template>
+        </el-table-column>
         <el-table-column prop="taskName" label="生产任务" min-width="130" align="center" />
         <el-table-column
           prop="constructionStatusName"
@@ -220,7 +230,6 @@ onMounted(() => {
           min-width="88"
           align="center" />
 
-        <el-table-column prop="nextPlan" label="下步任务" min-width="130" align="center" />
         <el-table-column
           prop="constructionBrief"
           label="当日生产简况"
@@ -231,6 +240,12 @@ onMounted(() => {
           </template>
         </el-table-column>
 
+        <el-table-column prop="nextPlan" label="下步任务" min-width="130" align="center">
+          <template #default="{ row }">
+            <div class="production-brief-table__summary">{{ row.nextPlan || '--' }}</div>
+          </template>
+        </el-table-column>
+
         <el-table-column label="当日进尺(m)/当日井次" min-width="150" align="center">
           <template #default="{ row }">
             {{ formatFootageOrWell(row) }}
@@ -294,32 +309,29 @@ onMounted(() => {
   width: 100%;
 
   :deep(.el-table__header-wrapper th.el-table__cell) {
-    font-size: calc(16px * var(--kb-scale, 1));
+    font-size: calc(16px * var(--production-brief-browser-zoom, 1));
     line-height: 1.2;
-    color: #10233c !important;
-    background: #b5cde7 !important;
-    border-color: #fff !important;
+    color: #0e3f8a;
+    background: #7fb5ff;
+    border-color: #a6c4ec;
+    font-weight: 600;
   }
 
   :deep(.el-table__body td.el-table__cell) {
-    height: calc(58px * var(--kb-scale, 1));
-    padding: calc(7px * var(--kb-scale, 1)) 0;
-    font-size: calc(14px * var(--kb-scale, 1));
-    color: #07192c !important;
-    background: #89b3de !important;
-    border-color: #fff !important;
+    font-size: calc(16px * var(--production-brief-browser-zoom, 1));
   }
 
-  :deep(.el-table__body tr:nth-child(2n) td.el-table__cell) {
-    background: #b8cee5 !important;
-  }
+  // :deep(.el-table__body tr:nth-child(2n) td.el-table__cell) {
+  //   background: #b8cee5 !important;
+  // }
 
-  :deep(.el-table__row:hover > td.el-table__cell) {
-    background: #75a5d6 !important;
-  }
+  // :deep(.el-table__row:hover > td.el-table__cell) {
+  //   background: #75a5d6 !important;
+  // }
 }
 
 .production-brief-table__summary {
+  font-size: calc(16px * var(--production-brief-browser-zoom, 1));
   line-height: 1.5;
   white-space: pre-wrap;
   overflow-wrap: anywhere;

+ 161 - 7
src/views/pms/video_center/haikang/index.vue

@@ -18,10 +18,8 @@ import {
 } from '@/utils/hikvisionH5Player'
 import { Loading, Location, Search, VideoCamera } from '@element-plus/icons-vue'
 import type { LoadFunction } from 'element-plus/es/components/tree/src/tree.type'
-import { IotDeviceApi } from '@/api/pms/device'
-import { IotOpeationFillApi } from '@/api/pms/iotopeationfill'
 
-defineOptions({ name: 'HikvisionVideoCenter' })
+defineOptions({ name: 'HaiKang' })
 
 const layoutTriggerRef = ref<HTMLElement>()
 const playerContainerRef = ref<HTMLElement>()
@@ -57,6 +55,9 @@ const activeWindowIndex = ref(0)
 const hoveredWindowIndex = ref<number>()
 // 鼠标进入页面叠加按钮后 SDK 会触发 windowEventOut,单独记录按钮悬浮以保持栏可见。
 const hoveredControlWindowIndex = ref<number>()
+// 拖拽期间才展示投放层,避免常驻透明层拦截 H5Player 的选窗、双击和电子放大事件。
+const draggingCamera = ref<HikvisionCameraNode>()
+const dragOverWindowIndex = ref<number>()
 const splitPopoverVisible = ref(false)
 const splitControlLoading = ref(false)
 // 云台 Popover 的全局展示状态和所属窗口,用于确保弹层打开时对应视口工具栏保持可见。
@@ -758,14 +759,16 @@ function initPlayer(): Promise<HikvisionPlayerInstance> {
   return playerInitPromise
 }
 
-async function playCameraPreview(camera: HikvisionCameraNode) {
+async function playCameraPreview(
+  camera: HikvisionCameraNode,
+  windowIndex = activeWindowIndex.value
+) {
   if (stopAllLoading.value || splitControlLoading.value) {
     showPreviewError('播放器正在切换状态,请稍后再选择监控点')
     return
   }
 
-  // 点击监控点时固定目标窗口,之后用户切换选中窗口不会改变本次请求的播放位置。
-  const windowIndex = activeWindowIndex.value
+  // 开始请求时固定目标窗口,之后用户切换选中窗口不会改变本次请求的播放位置。
   const requestId = beginWindowPreviewRequest(windowIndex)
   clearPreviewError()
 
@@ -778,6 +781,13 @@ async function playCameraPreview(camera: HikvisionCameraNode) {
     if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
     if (!previewUrl) throw new Error('预览地址为空')
 
+    // 首次操作就是拖拽时播放器可能刚完成初始化,需要补一次 SDK 选窗;请求期间若用户已经
+    // 选择其他视口,则保持用户的新选择,不把当前窗口强行切回本次请求的目标视口。
+    if (activeWindowIndex.value === windowIndex) {
+      await currentPlayer.JS_SelectWnd(windowIndex).catch(() => undefined)
+      if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
+    }
+
     // keepDecoder 为 0 时切换监控点前先停止旧流,确保旧解码资源得到回收。
     // 替换摄像机前停止旧监控点的云台动作,停止请求必须使用旧 indexCode。
     await stopActivePtzControl(windowIndex)
@@ -1263,6 +1273,46 @@ function handleNodeClick(data: HikvisionTreeNode) {
   void playCameraPreview(data)
 }
 
+function isCameraDraggable(data: HikvisionTreeNode): data is HikvisionCameraNode {
+  return data.nodeType === 'camera' && data.available !== false && data.onlineStatus !== false
+}
+
+function clearCameraDragState() {
+  draggingCamera.value = undefined
+  dragOverWindowIndex.value = undefined
+}
+
+function handleCameraDragStart(event: DragEvent, data: HikvisionTreeNode) {
+  if (!isCameraDraggable(data)) {
+    event.preventDefault()
+    return
+  }
+
+  draggingCamera.value = data
+  dragOverWindowIndex.value = undefined
+  if (!event.dataTransfer) return
+
+  event.dataTransfer.effectAllowed = 'copy'
+  // Firefox 要求设置 dataTransfer 数据后才会真正启动原生拖拽。
+  event.dataTransfer.setData('text/plain', data.indexCode)
+}
+
+function handleWindowDragOver(event: DragEvent, windowIndex: number) {
+  if (!draggingCamera.value) return
+  dragOverWindowIndex.value = windowIndex
+  if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
+}
+
+function handleCameraDrop(windowIndex: number) {
+  const camera = draggingCamera.value
+  clearCameraDragState()
+  if (!camera) return
+
+  // 投放后同步页面和 SDK 的当前窗口;JS_Play 仍使用显式下标,不依赖回调更新时序。
+  activeWindowIndex.value = windowIndex
+  void playCameraPreview(camera, windowIndex)
+}
+
 async function resizePlayer() {
   playerResizeFrame = undefined
   const currentPlayer = player
@@ -1487,6 +1537,7 @@ onMounted(() => {
 onBeforeUnmount(() => {
   componentUnmounted = true
   treeRequestId += 1
+  clearCameraDragState()
   document.removeEventListener('fullscreenchange', handleFullscreenChange)
   // 页面离开时补发停止;即使开始请求仍在途中,停止流程也会等待并保持正确顺序。
   void stopActivePtzControl()
@@ -1532,8 +1583,13 @@ onBeforeUnmount(() => {
               class="tree-node"
               :class="{
                 'is-camera': data.nodeType === 'camera',
+                'is-draggable': isCameraDraggable(data),
                 'is-unavailable': data.available === false
-              }">
+              }"
+              :draggable="isCameraDraggable(data)"
+              :title="isCameraDraggable(data) ? `${data.name}:可拖到右侧视口播放` : undefined"
+              @dragstart.stop="handleCameraDragStart($event, data)"
+              @dragend.stop="clearCameraDragState">
               <el-icon class="node-icon">
                 <Location v-if="data.nodeType === 'region'" />
                 <VideoCamera v-else />
@@ -1592,6 +1648,28 @@ onBeforeUnmount(() => {
           ref="playerContainerRef"
           class="player-container"
           @dblclick.capture="handlePlayerDoubleClick"></div>
+        <div
+          v-if="draggingCamera"
+          class="window-drop-layer"
+          :style="{
+            gridTemplateColumns: `repeat(${currentSplitColumns}, minmax(0, 1fr))`,
+            gridTemplateRows: `repeat(${currentSplitRows}, minmax(0, 1fr))`
+          }">
+          <div
+            v-for="windowIndex in currentSplitWindowIndexes"
+            :key="windowIndex"
+            class="window-drop-cell"
+            :class="{ 'is-drag-over': dragOverWindowIndex === windowIndex }"
+            @dragenter.prevent="dragOverWindowIndex = windowIndex"
+            @dragover.prevent="handleWindowDragOver($event, windowIndex)"
+            @drop.prevent.stop="handleCameraDrop(windowIndex)">
+            <div class="window-drop-hint">
+              <i class="i-lucide:monitor-down size-6"></i>
+              <span>投放到视口 {{ windowIndex + 1 }}</span>
+              <small>{{ draggingCamera.name }}</small>
+            </div>
+          </div>
+        </div>
         <div
           class="window-loading-layer"
           :style="{
@@ -2056,6 +2134,14 @@ onBeforeUnmount(() => {
   color: #334155;
 }
 
+.tree-node.is-draggable {
+  cursor: grab;
+}
+
+.tree-node.is-draggable:active {
+  cursor: grabbing;
+}
+
 .tree-node.is-camera .node-icon {
   color: #2563eb;
 }
@@ -2185,6 +2271,17 @@ onBeforeUnmount(() => {
   inset: 0 !important;
 }
 
+/*
+ * 单窗口全屏会把 SDK 的 sub-wnd 从 flex 改为 block。draw-window 未设置坐标时会按静态位置
+ * 排到视频下方一整屏,导致电子放大的框选层无法命中;将它固定回目标视口左上角。
+ */
+.video-stage.is-single-window-fullscreen
+  .player-container
+  :deep(.sub-wnd.is-single-fullscreen-target > .draw-window) {
+  top: 0 !important;
+  left: 0 !important;
+}
+
 .video-stage.is-single-window-fullscreen .window-loading-layer {
   grid-template-rows: minmax(0, 1fr) !important;
   grid-template-columns: minmax(0, 1fr) !important;
@@ -2218,6 +2315,63 @@ onBeforeUnmount(() => {
   box-sizing: border-box !important;
 }
 
+.window-drop-layer {
+  position: absolute;
+  z-index: 10;
+  display: grid;
+  background: rgb(2 6 23 / 24%);
+  inset: 0 0 var(--video-toolbar-height);
+}
+
+.window-drop-cell {
+  position: relative;
+  display: flex;
+  min-width: 0;
+  min-height: 0;
+  color: #bfdbfe;
+  background: rgb(15 23 42 / 46%);
+  border: 2px dashed rgb(147 197 253 / 62%);
+  align-items: center;
+  justify-content: center;
+  transition:
+    color 0.15s ease,
+    background-color 0.15s ease,
+    border-color 0.15s ease;
+}
+
+.window-drop-cell.is-drag-over {
+  color: #fff;
+  background: rgb(37 99 235 / 48%);
+  border-color: #60a5fa;
+  box-shadow: inset 0 0 24px rgb(59 130 246 / 28%);
+}
+
+.window-drop-hint {
+  display: flex;
+  max-width: calc(100% - 24px);
+  padding: 12px 18px;
+  font-size: 14px;
+  font-weight: 600;
+  text-align: center;
+  pointer-events: none;
+  background: rgb(2 6 23 / 68%);
+  border: 1px solid rgb(147 197 253 / 38%);
+  border-radius: 8px;
+  box-shadow: 0 8px 24px rgb(2 6 23 / 28%);
+  align-items: center;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.window-drop-hint small {
+  max-width: 100%;
+  overflow: hidden;
+  font-weight: 400;
+  color: #cbd5e1;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .window-loading-layer {
   position: absolute;
   z-index: 2;

+ 19 - 2
src/views/report-statistics/costs.vue

@@ -133,6 +133,20 @@ const defaultStats: StatItem[] = [
     value: 0,
     tb: 0,
     hb: 0
+  },
+  {
+    key: 'total',
+    title: '合计',
+    type: '',
+    icon: 'i-material-symbols:payments-outline-rounded',
+    class: {
+      bg1: 'bg-[var(--el-color-danger-light-7)]',
+      bg2: 'bg-[var(--el-color-danger-light-5)]',
+      text: 'text-[var(--el-color-danger)]'
+    },
+    value: 0,
+    tb: 0,
+    hb: 0
   }
 ]
 
@@ -159,6 +173,9 @@ const loadData = useDebounceFn(async function () {
     byFee.value = res[byFee.key].total || 0
     byFee.tb = Number(((res[byFee.key].tb || 0) * 100).toFixed(0))
     byFee.hb = Number(((res[byFee.key].hb || 0) * 100).toFixed(0))
+
+    const total = statList.value.find((item) => item.key === 'total')!
+    total.value = repair.value + byFee.value + out.value
   } catch (error) {
     console.log('error :>> ', error)
   } finally {
@@ -274,14 +291,14 @@ const handleChange = () => {
       class="row-span-2" />
     <!-- <div class="p-4 bg-white dark:bg-[#1d1e1f] shadow rounded-lg row-span-2"> </div> -->
     <div class="grid grid-rows-[1fr_32px] gap-4">
-      <div class="grid grid-cols-3 gap-4" v-loading="dataLoading">
+      <div class="grid grid-cols-4 gap-4" v-loading="dataLoading">
         <!-- 使用 v-for 循环渲染 -->
         <section
           v-for="item in statList"
           :key="item.key"
           class="flex flex-col items-center gap-y-4 rounded-lg shadow p-4 transition-transform hover:scale-105 duration-500"
           :class="{ [item.class.bg1]: true, 'scale-105': item.type === query.type }"
-          @click="selectType(item.type)">
+          @click="item.key !== 'total' && selectType(item.type)">
           <!-- 头部:图标 + 标题 -->
           <div class="flex items-center gap-x-3">
             <div class="rounded-2 p-2" :class="[item.class.text, item.class.bg2]">

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff