Parcourir la source

多语言继续处理

yanghao il y a 1 semaine
Parent
commit
3ae3867f6c
87 fichiers modifiés avec 10008 ajouts et 1586 suppressions
  1. 2 0
      package.json
  2. 254 6
      pnpm-lock.yaml
  3. 4 0
      src/api/pms/iotrhdailyreport/index.ts
  4. 9 1
      src/assets/icons/grid-16.svg
  5. 5 1
      src/assets/icons/grid-four.svg
  6. 7 1
      src/assets/icons/grid-nine.svg
  7. 3 1
      src/assets/icons/grid-one.svg
  8. 1214 0
      src/components/custom-components/hikvision-video-vue/index.vue
  9. 66 0
      src/components/custom-components/status-vue/index.vue
  10. 94 41
      src/components/custom-components/text-vue/index.vue
  11. 29 12
      src/components/mt-dzr/index.vue
  12. 16 0
      src/components/mt-dzr/utils/drag-position.ts
  13. 8 0
      src/components/mt-dzr/utils/move-completion.ts
  14. 399 62
      src/components/mt-edit/components/done-tree/index.vue
  15. 32 16
      src/components/mt-edit/components/draw-line-render/index.vue
  16. 5 1
      src/components/mt-edit/components/group-render/index.vue
  17. 687 283
      src/components/mt-edit/components/layout/header-panel/index.vue
  18. 892 139
      src/components/mt-edit/components/layout/left-aside/index.vue
  19. 295 46
      src/components/mt-edit/components/layout/main-panel/index.vue
  20. 268 0
      src/components/mt-edit/components/layout/right-aside/hikvision-camera-select.vue
  21. 224 23
      src/components/mt-edit/components/layout/right-aside/index.vue
  22. 183 22
      src/components/mt-edit/components/layout/right-aside/page-setting.vue
  23. 687 136
      src/components/mt-edit/components/layout/right-aside/select-item-event-setting/index.vue
  24. 192 46
      src/components/mt-edit/components/layout/right-aside/select-item-props-setting.vue
  25. 241 49
      src/components/mt-edit/components/layout/right-aside/select-item-setting.vue
  26. 85 0
      src/components/mt-edit/components/layout/right-aside/status-color-rules-editor.vue
  27. 51 23
      src/components/mt-edit/components/line-render/index.vue
  28. 14 5
      src/components/mt-edit/components/render-core/index.vue
  29. 15 4
      src/components/mt-edit/components/render-item/index.vue
  30. 5 1
      src/components/mt-edit/composables/index.ts
  31. 43 36
      src/components/mt-edit/composables/sys-line.ts
  32. 140 37
      src/components/mt-edit/index.vue
  33. 202 0
      src/components/mt-edit/store/__tests__/cache.spec.ts
  34. 74 0
      src/components/mt-edit/store/__tests__/hikvision-video.spec.ts
  35. 127 14
      src/components/mt-edit/store/cache.ts
  36. 182 8
      src/components/mt-edit/store/config.ts
  37. 2 2
      src/components/mt-edit/store/context-menu.ts
  38. 39 2
      src/components/mt-edit/store/types.ts
  39. 39 0
      src/components/mt-edit/utils/__tests__/canvas-pan.spec.ts
  40. 60 0
      src/components/mt-edit/utils/__tests__/canvas-scale.spec.ts
  41. 30 0
      src/components/mt-edit/utils/__tests__/editor-document.spec.ts
  42. 12 0
      src/components/mt-edit/utils/__tests__/keyboard-shortcut.spec.ts
  43. 49 0
      src/components/mt-edit/utils/__tests__/line-interaction.spec.ts
  44. 36 0
      src/components/mt-edit/utils/__tests__/move-completion.spec.ts
  45. 21 0
      src/components/mt-edit/utils/__tests__/text-format.spec.ts
  46. 14 0
      src/components/mt-edit/utils/canvas-pan.ts
  47. 58 0
      src/components/mt-edit/utils/canvas-scale.ts
  48. 20 0
      src/components/mt-edit/utils/editor-document.ts
  49. 23 3
      src/components/mt-edit/utils/index.ts
  50. 46 0
      src/components/mt-edit/utils/line-interaction.ts
  51. 40 0
      src/components/mt-edit/utils/text-format.ts
  52. 5 4
      src/locales/en.ts
  53. 3 1
      src/locales/zh-CN.ts
  54. 2 0
      src/plugins/echarts/index.ts
  55. 38 0
      src/utils/thresholdAlarmNoticeTracker.ts
  56. 49 0
      src/utils/thresholdAlarmWindow.ts
  57. 8 0
      src/utils/useSocketBus.ts
  58. 827 106
      src/views/maotu/components/DeviceBindPanel.vue
  59. 151 6
      src/views/maotu/edit.vue
  60. 29 10
      src/views/maotu/preview.vue
  61. 418 88
      src/views/oli-connection/monitoring-board/chart.vue
  62. 31 0
      src/views/oli-connection/monitoring/__tests__/excel-date.spec.ts
  63. 342 130
      src/views/oli-connection/monitoring/detail.vue
  64. 20 0
      src/views/oli-connection/monitoring/excelDate.ts
  65. 9 1
      src/views/pms/device/DeviceBasicInfo.vue
  66. 75 38
      src/views/pms/device/IotDeviceForm.vue
  67. 1 1
      src/views/pms/iotrddailyreport/FillDailyReportForm.vue
  68. 1 3
      src/views/pms/iotrddailyreport/summary.vue
  69. 120 28
      src/views/pms/iotrhdailyreport/components/DailyStatistics.vue
  70. 1 1
      src/views/pms/qhse/case/index.vue
  71. 71 21
      src/views/pms/qhse/certificate.vue
  72. 39 13
      src/views/pms/qhse/credential/index.vue
  73. 109 27
      src/views/pms/qhse/factor/index.vue
  74. 2 2
      src/views/pms/qhse/index.vue
  75. 61 16
      src/views/pms/qhse/industry/index.vue
  76. 11 0
      src/views/pms/stat/rykb/ryProductionBriefs.vue
  77. 37 9
      src/views/pms/stat/rykb/rydeviceList.vue
  78. 6 1
      src/views/pms/video_center/haikang/index.vue
  79. 1 1
      src/views/report-statistics/device_book/index2.vue
  80. 1 1
      src/views/report-statistics/inspection_order/index.vue
  81. 3 3
      src/views/report-statistics/work-order-completion.vue
  82. 34 54
      src/views/supplier/certificate/index.vue
  83. 9 0
      src/views/video-center/index.vue
  84. 42 0
      tests/hikvisionSplitIcons.test.mjs
  85. 59 0
      tests/thresholdAlarmNoticeTracker.test.mjs
  86. 133 0
      tests/thresholdAlarmWindow.test.mjs
  87. 17 0
      vitest.config.mts

+ 2 - 0
package.json

@@ -8,6 +8,7 @@
     "i": "pnpm install",
     "i": "pnpm install",
     "dev": "vite --mode env.local",
     "dev": "vite --mode env.local",
     "dev-server": "vite --mode dev",
     "dev-server": "vite --mode dev",
+    "test:unit": "vitest run --config vitest.config.mts",
     "ts:check": "vue-tsc --noEmit",
     "ts:check": "vue-tsc --noEmit",
     "build:local": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build",
     "build:local": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build",
     "build:dev": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode dev",
     "build:dev": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode dev",
@@ -174,6 +175,7 @@
     "vite-plugin-svg-icons": "^2.0.1",
     "vite-plugin-svg-icons": "^2.0.1",
     "vite-plugin-svg-icons-ng": "^1.3.1",
     "vite-plugin-svg-icons-ng": "^1.3.1",
     "vite-plugin-top-level-await": "^1.4.4",
     "vite-plugin-top-level-await": "^1.4.4",
+    "vitest": "1.6.1",
     "vue-eslint-parser": "^9.3.2",
     "vue-eslint-parser": "^9.3.2",
     "vue-tsc": "^1.8.27"
     "vue-tsc": "^1.8.27"
   },
   },

+ 254 - 6
pnpm-lock.yaml

@@ -450,6 +450,9 @@ importers:
       vite-plugin-top-level-await:
       vite-plugin-top-level-await:
         specifier: ^1.4.4
         specifier: ^1.4.4
         version: 1.4.4(rollup@4.27.4)(vite@5.1.4(@types/node@20.17.9)(less@4.6.4)(sass@1.81.0)(terser@5.36.0))
         version: 1.4.4(rollup@4.27.4)(vite@5.1.4(@types/node@20.17.9)(less@4.6.4)(sass@1.81.0)(terser@5.36.0))
+      vitest:
+        specifier: 1.6.1
+        version: 1.6.1(@types/node@20.17.9)(less@4.6.4)(sass@1.81.0)(terser@5.36.0)
       vue-eslint-parser:
       vue-eslint-parser:
         specifier: ^9.3.2
         specifier: ^9.3.2
         version: 9.4.3(eslint@8.57.1)
         version: 9.4.3(eslint@8.57.1)
@@ -2347,6 +2350,21 @@ packages:
       vite: ^5.0.0 || ^6.0.0
       vite: ^5.0.0 || ^6.0.0
       vue: ^3.2.25
       vue: ^3.2.25
 
 
+  '@vitest/expect@1.6.1':
+    resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==}
+
+  '@vitest/runner@1.6.1':
+    resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==}
+
+  '@vitest/snapshot@1.6.1':
+    resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==}
+
+  '@vitest/spy@1.6.1':
+    resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==}
+
+  '@vitest/utils@1.6.1':
+    resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==}
+
   '@volar/language-core@1.11.1':
   '@volar/language-core@1.11.1':
     resolution: {integrity: sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==}
     resolution: {integrity: sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==}
 
 
@@ -2570,6 +2588,10 @@ packages:
     peerDependencies:
     peerDependencies:
       acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
       acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
 
 
+  acorn-walk@8.3.5:
+    resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
+    engines: {node: '>=0.4.0'}
+
   acorn@8.14.0:
   acorn@8.14.0:
     resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==}
     resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==}
     engines: {node: '>=0.4.0'}
     engines: {node: '>=0.4.0'}
@@ -2668,6 +2690,9 @@ packages:
     resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
     resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
     engines: {node: '>= 0.4'}
     engines: {node: '>= 0.4'}
 
 
+  assertion-error@1.1.0:
+    resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==}
+
   assign-symbols@1.0.0:
   assign-symbols@1.0.0:
     resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==}
     resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==}
     engines: {node: '>=0.10.0'}
     engines: {node: '>=0.10.0'}
@@ -2880,6 +2905,10 @@ packages:
     resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
     resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
     engines: {node: '>=0.8'}
     engines: {node: '>=0.8'}
 
 
+  chai@4.5.0:
+    resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==}
+    engines: {node: '>=4'}
+
   chalk@1.1.3:
   chalk@1.1.3:
     resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==}
     resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==}
     engines: {node: '>=0.10.0'}
     engines: {node: '>=0.10.0'}
@@ -2892,6 +2921,9 @@ packages:
     resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==}
     resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==}
     engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
     engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
 
 
+  check-error@1.0.3:
+    resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==}
+
   cheerio-select@2.1.0:
   cheerio-select@2.1.0:
     resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
     resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
 
 
@@ -3368,6 +3400,10 @@ packages:
     resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
     resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
     engines: {node: '>=0.10'}
     engines: {node: '>=0.10'}
 
 
+  deep-eql@4.1.4:
+    resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==}
+    engines: {node: '>=6'}
+
   deep-is@0.1.4:
   deep-is@0.1.4:
     resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
     resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
 
 
@@ -3433,6 +3469,10 @@ packages:
   didi@9.0.2:
   didi@9.0.2:
     resolution: {integrity: sha512-q2+aj+lnJcUweV7A9pdUrwFr4LHVmRPwTmQLtHPFz4aT7IBoryN6Iy+jmFku+oIzr5ebBkvtBCOb87+dJhb7bg==}
     resolution: {integrity: sha512-q2+aj+lnJcUweV7A9pdUrwFr4LHVmRPwTmQLtHPFz4aT7IBoryN6Iy+jmFku+oIzr5ebBkvtBCOb87+dJhb7bg==}
 
 
+  diff-sequences@29.6.3:
+    resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
+    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+
   dijkstrajs@1.0.3:
   dijkstrajs@1.0.3:
     resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
     resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
 
 
@@ -3983,6 +4023,9 @@ packages:
     resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==}
     resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==}
     engines: {node: '>=18'}
     engines: {node: '>=18'}
 
 
+  get-func-name@2.0.2:
+    resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==}
+
   get-intrinsic@1.2.4:
   get-intrinsic@1.2.4:
     resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==}
     resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==}
     engines: {node: '>= 0.4'}
     engines: {node: '>= 0.4'}
@@ -4753,6 +4796,9 @@ packages:
     resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
     resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
     engines: {node: '>= 0.6.0'}
     engines: {node: '>= 0.6.0'}
 
 
+  loupe@2.3.7:
+    resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==}
+
   lru-cache@10.4.3:
   lru-cache@10.4.3:
     resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
     resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
 
 
@@ -5144,6 +5190,10 @@ packages:
     resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==}
     resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==}
     engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
     engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
 
 
+  p-limit@5.0.0:
+    resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==}
+    engines: {node: '>=18'}
+
   p-locate@4.1.0:
   p-locate@4.1.0:
     resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
     resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
     engines: {node: '>=8'}
     engines: {node: '>=8'}
@@ -5241,6 +5291,9 @@ packages:
   pathe@2.0.3:
   pathe@2.0.3:
     resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
     resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
 
 
+  pathval@1.1.1:
+    resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==}
+
   perfect-debounce@1.0.0:
   perfect-debounce@1.0.0:
     resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
     resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
 
 
@@ -5766,6 +5819,9 @@ packages:
     resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
     resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
     engines: {node: '>= 0.4'}
     engines: {node: '>= 0.4'}
 
 
+  siginfo@2.0.0:
+    resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
   signal-exit@4.1.0:
   signal-exit@4.1.0:
     resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
     resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
     engines: {node: '>=14'}
     engines: {node: '>=14'}
@@ -5876,6 +5932,9 @@ packages:
     resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==}
     resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==}
     deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility'
     deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility'
 
 
+  stackback@0.0.2:
+    resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
   stackblur-canvas@2.7.0:
   stackblur-canvas@2.7.0:
     resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
     resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
     engines: {node: '>=0.1.14'}
     engines: {node: '>=0.1.14'}
@@ -5884,6 +5943,9 @@ packages:
     resolution: {integrity: sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==}
     resolution: {integrity: sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==}
     engines: {node: '>=0.10.0'}
     engines: {node: '>=0.10.0'}
 
 
+  std-env@3.10.0:
+    resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+
   steady-xml@0.1.0:
   steady-xml@0.1.0:
     resolution: {integrity: sha512-5sk17qO2wWRtonTNoBhoKAB35OSsGJOa3+NEa6D+1GS+de+ujDWxnflMkXBrviOfkNrPTUqduAdXhrMJs89nAw==}
     resolution: {integrity: sha512-5sk17qO2wWRtonTNoBhoKAB35OSsGJOa3+NEa6D+1GS+de+ujDWxnflMkXBrviOfkNrPTUqduAdXhrMJs89nAw==}
     engines: {node: '>=12.0.0'}
     engines: {node: '>=12.0.0'}
@@ -6076,6 +6138,9 @@ packages:
   tiny-warning@1.0.3:
   tiny-warning@1.0.3:
     resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
     resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
 
 
+  tinybench@2.9.0:
+    resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
   tinyexec@0.3.1:
   tinyexec@0.3.1:
     resolution: {integrity: sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==}
     resolution: {integrity: sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==}
 
 
@@ -6083,6 +6148,14 @@ packages:
     resolution: {integrity: sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==}
     resolution: {integrity: sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==}
     engines: {node: '>=12.0.0'}
     engines: {node: '>=12.0.0'}
 
 
+  tinypool@0.8.4:
+    resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==}
+    engines: {node: '>=14.0.0'}
+
+  tinyspy@2.2.1:
+    resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==}
+    engines: {node: '>=14.0.0'}
+
   to-object-path@0.3.0:
   to-object-path@0.3.0:
     resolution: {integrity: sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==}
     resolution: {integrity: sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==}
     engines: {node: '>=0.10.0'}
     engines: {node: '>=0.10.0'}
@@ -6132,6 +6205,10 @@ packages:
     resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
     resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
     engines: {node: '>= 0.8.0'}
     engines: {node: '>= 0.8.0'}
 
 
+  type-detect@4.1.0:
+    resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==}
+    engines: {node: '>=4'}
+
   type-fest@0.20.2:
   type-fest@0.20.2:
     resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
     resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
     engines: {node: '>=10'}
     engines: {node: '>=10'}
@@ -6324,6 +6401,11 @@ packages:
   videojs-vtt.js@0.15.5:
   videojs-vtt.js@0.15.5:
     resolution: {integrity: sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==}
     resolution: {integrity: sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==}
 
 
+  vite-node@1.6.1:
+    resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==}
+    engines: {node: ^18.0.0 || >=20.0.0}
+    hasBin: true
+
   vite-plugin-compression@0.5.1:
   vite-plugin-compression@0.5.1:
     resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==}
     resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==}
     peerDependencies:
     peerDependencies:
@@ -6397,6 +6479,31 @@ packages:
       terser:
       terser:
         optional: true
         optional: true
 
 
+  vitest@1.6.1:
+    resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==}
+    engines: {node: ^18.0.0 || >=20.0.0}
+    hasBin: true
+    peerDependencies:
+      '@edge-runtime/vm': '*'
+      '@types/node': ^18.0.0 || >=20.0.0
+      '@vitest/browser': 1.6.1
+      '@vitest/ui': 1.6.1
+      happy-dom: '*'
+      jsdom: '*'
+    peerDependenciesMeta:
+      '@edge-runtime/vm':
+        optional: true
+      '@types/node':
+        optional: true
+      '@vitest/browser':
+        optional: true
+      '@vitest/ui':
+        optional: true
+      happy-dom:
+        optional: true
+      jsdom:
+        optional: true
+
   vue-demi@0.12.5:
   vue-demi@0.12.5:
     resolution: {integrity: sha512-BREuTgTYlUr0zw0EZn3hnhC3I6gPWv+Kwh4MCih6QcAeaTlaIX0DwOVN0wHej7hSvDPecz4jygy/idsgKfW58Q==}
     resolution: {integrity: sha512-BREuTgTYlUr0zw0EZn3hnhC3I6gPWv+Kwh4MCih6QcAeaTlaIX0DwOVN0wHej7hSvDPecz4jygy/idsgKfW58Q==}
     engines: {node: '>=12'}
     engines: {node: '>=12'}
@@ -6587,6 +6694,11 @@ packages:
     engines: {node: '>= 8'}
     engines: {node: '>= 8'}
     hasBin: true
     hasBin: true
 
 
+  why-is-node-running@2.3.0:
+    resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+    engines: {node: '>=8'}
+    hasBin: true
+
   wildcard@1.1.2:
   wildcard@1.1.2:
     resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==}
     resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==}
 
 
@@ -6817,7 +6929,7 @@ snapshots:
       '@babel/core': 7.26.0
       '@babel/core': 7.26.0
       '@babel/helper-compilation-targets': 7.25.9
       '@babel/helper-compilation-targets': 7.25.9
       '@babel/helper-plugin-utils': 7.25.9
       '@babel/helper-plugin-utils': 7.25.9
-      debug: 4.3.7
+      debug: 4.4.3
       lodash.debounce: 4.0.8
       lodash.debounce: 4.0.8
       resolve: 1.22.8
       resolve: 1.22.8
     transitivePeerDependencies:
     transitivePeerDependencies:
@@ -7921,7 +8033,7 @@ snapshots:
       '@antfu/install-pkg': 0.4.1
       '@antfu/install-pkg': 0.4.1
       '@antfu/utils': 0.7.10
       '@antfu/utils': 0.7.10
       '@iconify/types': 2.0.0
       '@iconify/types': 2.0.0
-      debug: 4.3.7
+      debug: 4.4.3
       kolorist: 1.8.0
       kolorist: 1.8.0
       local-pkg: 0.5.1
       local-pkg: 0.5.1
       mlly: 1.7.3
       mlly: 1.7.3
@@ -8594,7 +8706,7 @@ snapshots:
     dependencies:
     dependencies:
       '@typescript-eslint/types': 6.21.0
       '@typescript-eslint/types': 6.21.0
       '@typescript-eslint/visitor-keys': 6.21.0
       '@typescript-eslint/visitor-keys': 6.21.0
-      debug: 4.3.7
+      debug: 4.4.3
       globby: 11.1.0
       globby: 11.1.0
       is-glob: 4.0.3
       is-glob: 4.0.3
       minimatch: 9.0.3
       minimatch: 9.0.3
@@ -8609,7 +8721,7 @@ snapshots:
     dependencies:
     dependencies:
       '@typescript-eslint/types': 7.18.0
       '@typescript-eslint/types': 7.18.0
       '@typescript-eslint/visitor-keys': 7.18.0
       '@typescript-eslint/visitor-keys': 7.18.0
-      debug: 4.3.7
+      debug: 4.4.3
       globby: 11.1.0
       globby: 11.1.0
       is-glob: 4.0.3
       is-glob: 4.0.3
       minimatch: 9.0.5
       minimatch: 9.0.5
@@ -8624,7 +8736,7 @@ snapshots:
     dependencies:
     dependencies:
       '@typescript-eslint/types': 8.26.1
       '@typescript-eslint/types': 8.26.1
       '@typescript-eslint/visitor-keys': 8.26.1
       '@typescript-eslint/visitor-keys': 8.26.1
-      debug: 4.3.7
+      debug: 4.4.3
       fast-glob: 3.3.2
       fast-glob: 3.3.2
       is-glob: 4.0.3
       is-glob: 4.0.3
       minimatch: 9.0.5
       minimatch: 9.0.5
@@ -8840,7 +8952,7 @@ snapshots:
   '@unocss/rule-utils@0.58.9':
   '@unocss/rule-utils@0.58.9':
     dependencies:
     dependencies:
       '@unocss/core': 0.58.9
       '@unocss/core': 0.58.9
-      magic-string: 0.30.14
+      magic-string: 0.30.17
 
 
   '@unocss/rule-utils@66.1.0-beta.5':
   '@unocss/rule-utils@66.1.0-beta.5':
     dependencies:
     dependencies:
@@ -8980,6 +9092,35 @@ snapshots:
       vite: 5.1.4(@types/node@20.17.9)(less@4.6.4)(sass@1.81.0)(terser@5.36.0)
       vite: 5.1.4(@types/node@20.17.9)(less@4.6.4)(sass@1.81.0)(terser@5.36.0)
       vue: 3.5.12(typescript@5.3.3)
       vue: 3.5.12(typescript@5.3.3)
 
 
+  '@vitest/expect@1.6.1':
+    dependencies:
+      '@vitest/spy': 1.6.1
+      '@vitest/utils': 1.6.1
+      chai: 4.5.0
+
+  '@vitest/runner@1.6.1':
+    dependencies:
+      '@vitest/utils': 1.6.1
+      p-limit: 5.0.0
+      pathe: 1.1.2
+
+  '@vitest/snapshot@1.6.1':
+    dependencies:
+      magic-string: 0.30.17
+      pathe: 1.1.2
+      pretty-format: 29.7.0
+
+  '@vitest/spy@1.6.1':
+    dependencies:
+      tinyspy: 2.2.1
+
+  '@vitest/utils@1.6.1':
+    dependencies:
+      diff-sequences: 29.6.3
+      estree-walker: 3.0.3
+      loupe: 2.3.7
+      pretty-format: 29.7.0
+
   '@volar/language-core@1.11.1':
   '@volar/language-core@1.11.1':
     dependencies:
     dependencies:
       '@volar/source-map': 1.11.1
       '@volar/source-map': 1.11.1
@@ -9298,6 +9439,10 @@ snapshots:
     dependencies:
     dependencies:
       acorn: 8.14.0
       acorn: 8.14.0
 
 
+  acorn-walk@8.3.5:
+    dependencies:
+      acorn: 8.14.0
+
   acorn@8.14.0: {}
   acorn@8.14.0: {}
 
 
   adler-32@1.3.1: {}
   adler-32@1.3.1: {}
@@ -9385,6 +9530,8 @@ snapshots:
       get-intrinsic: 1.3.0
       get-intrinsic: 1.3.0
       is-array-buffer: 3.0.5
       is-array-buffer: 3.0.5
 
 
+  assertion-error@1.1.0: {}
+
   assign-symbols@1.0.0: {}
   assign-symbols@1.0.0: {}
 
 
   astral-regex@2.0.0: {}
   astral-regex@2.0.0: {}
@@ -9656,6 +9803,16 @@ snapshots:
       adler-32: 1.3.1
       adler-32: 1.3.1
       crc-32: 1.2.2
       crc-32: 1.2.2
 
 
+  chai@4.5.0:
+    dependencies:
+      assertion-error: 1.1.0
+      check-error: 1.0.3
+      deep-eql: 4.1.4
+      get-func-name: 2.0.2
+      loupe: 2.3.7
+      pathval: 1.1.1
+      type-detect: 4.1.0
+
   chalk@1.1.3:
   chalk@1.1.3:
     dependencies:
     dependencies:
       ansi-styles: 2.2.1
       ansi-styles: 2.2.1
@@ -9671,6 +9828,10 @@ snapshots:
 
 
   chalk@5.3.0: {}
   chalk@5.3.0: {}
 
 
+  check-error@1.0.3:
+    dependencies:
+      get-func-name: 2.0.2
+
   cheerio-select@2.1.0:
   cheerio-select@2.1.0:
     dependencies:
     dependencies:
       boolbase: 1.0.0
       boolbase: 1.0.0
@@ -10162,6 +10323,10 @@ snapshots:
 
 
   decode-uri-component@0.2.2: {}
   decode-uri-component@0.2.2: {}
 
 
+  deep-eql@4.1.4:
+    dependencies:
+      type-detect: 4.1.0
+
   deep-is@0.1.4: {}
   deep-is@0.1.4: {}
 
 
   default-passive-events@2.0.0: {}
   default-passive-events@2.0.0: {}
@@ -10241,6 +10406,8 @@ snapshots:
 
 
   didi@9.0.2: {}
   didi@9.0.2: {}
 
 
+  diff-sequences@29.6.3: {}
+
   dijkstrajs@1.0.3: {}
   dijkstrajs@1.0.3: {}
 
 
   dingtalk-jsapi@3.2.2:
   dingtalk-jsapi@3.2.2:
@@ -10941,6 +11108,8 @@ snapshots:
 
 
   get-east-asian-width@1.3.0: {}
   get-east-asian-width@1.3.0: {}
 
 
+  get-func-name@2.0.2: {}
+
   get-intrinsic@1.2.4:
   get-intrinsic@1.2.4:
     dependencies:
     dependencies:
       es-errors: 1.3.0
       es-errors: 1.3.0
@@ -11681,6 +11850,10 @@ snapshots:
 
 
   loglevel@1.9.2: {}
   loglevel@1.9.2: {}
 
 
+  loupe@2.3.7:
+    dependencies:
+      get-func-name: 2.0.2
+
   lru-cache@10.4.3: {}
   lru-cache@10.4.3: {}
 
 
   lru-cache@5.1.1:
   lru-cache@5.1.1:
@@ -12131,6 +12304,10 @@ snapshots:
     dependencies:
     dependencies:
       yocto-queue: 1.1.1
       yocto-queue: 1.1.1
 
 
+  p-limit@5.0.0:
+    dependencies:
+      yocto-queue: 1.1.1
+
   p-locate@4.1.0:
   p-locate@4.1.0:
     dependencies:
     dependencies:
       p-limit: 2.3.0
       p-limit: 2.3.0
@@ -12206,6 +12383,8 @@ snapshots:
 
 
   pathe@2.0.3: {}
   pathe@2.0.3: {}
 
 
+  pathval@1.1.1: {}
+
   perfect-debounce@1.0.0: {}
   perfect-debounce@1.0.0: {}
 
 
   performance-now@2.1.0: {}
   performance-now@2.1.0: {}
@@ -12739,6 +12918,8 @@ snapshots:
       side-channel-map: 1.0.1
       side-channel-map: 1.0.1
       side-channel-weakmap: 1.0.2
       side-channel-weakmap: 1.0.2
 
 
+  siginfo@2.0.0: {}
+
   signal-exit@4.1.0: {}
   signal-exit@4.1.0: {}
 
 
   signature_pad@3.0.0-beta.4: {}
   signature_pad@3.0.0-beta.4: {}
@@ -12851,6 +13032,8 @@ snapshots:
 
 
   stable@0.1.8: {}
   stable@0.1.8: {}
 
 
+  stackback@0.0.2: {}
+
   stackblur-canvas@2.7.0: {}
   stackblur-canvas@2.7.0: {}
 
 
   static-extend@0.1.2:
   static-extend@0.1.2:
@@ -12858,6 +13041,8 @@ snapshots:
       define-property: 0.2.5
       define-property: 0.2.5
       object-copy: 0.1.0
       object-copy: 0.1.0
 
 
+  std-env@3.10.0: {}
+
   steady-xml@0.1.0: {}
   steady-xml@0.1.0: {}
 
 
   stop-iteration-iterator@1.1.0:
   stop-iteration-iterator@1.1.0:
@@ -13112,6 +13297,8 @@ snapshots:
 
 
   tiny-warning@1.0.3: {}
   tiny-warning@1.0.3: {}
 
 
+  tinybench@2.9.0: {}
+
   tinyexec@0.3.1: {}
   tinyexec@0.3.1: {}
 
 
   tinyglobby@0.2.10:
   tinyglobby@0.2.10:
@@ -13119,6 +13306,10 @@ snapshots:
       fdir: 6.4.2(picomatch@4.0.2)
       fdir: 6.4.2(picomatch@4.0.2)
       picomatch: 4.0.2
       picomatch: 4.0.2
 
 
+  tinypool@0.8.4: {}
+
+  tinyspy@2.2.1: {}
+
   to-object-path@0.3.0:
   to-object-path@0.3.0:
     dependencies:
     dependencies:
       kind-of: 3.2.2
       kind-of: 3.2.2
@@ -13165,6 +13356,8 @@ snapshots:
     dependencies:
     dependencies:
       prelude-ls: 1.2.1
       prelude-ls: 1.2.1
 
 
+  type-detect@4.1.0: {}
+
   type-fest@0.20.2: {}
   type-fest@0.20.2: {}
 
 
   type@2.7.3: {}
   type@2.7.3: {}
@@ -13429,6 +13622,23 @@ snapshots:
     dependencies:
     dependencies:
       global: 4.4.0
       global: 4.4.0
 
 
+  vite-node@1.6.1(@types/node@20.17.9)(less@4.6.4)(sass@1.81.0)(terser@5.36.0):
+    dependencies:
+      cac: 6.7.14
+      debug: 4.4.3
+      pathe: 1.1.2
+      picocolors: 1.1.1
+      vite: 5.1.4(@types/node@20.17.9)(less@4.6.4)(sass@1.81.0)(terser@5.36.0)
+    transitivePeerDependencies:
+      - '@types/node'
+      - less
+      - lightningcss
+      - sass
+      - stylus
+      - sugarss
+      - supports-color
+      - terser
+
   vite-plugin-compression@0.5.1(vite@5.1.4(@types/node@20.17.9)(less@4.6.4)(sass@1.81.0)(terser@5.36.0)):
   vite-plugin-compression@0.5.1(vite@5.1.4(@types/node@20.17.9)(less@4.6.4)(sass@1.81.0)(terser@5.36.0)):
     dependencies:
     dependencies:
       chalk: 4.1.2
       chalk: 4.1.2
@@ -13513,6 +13723,39 @@ snapshots:
       sass: 1.81.0
       sass: 1.81.0
       terser: 5.36.0
       terser: 5.36.0
 
 
+  vitest@1.6.1(@types/node@20.17.9)(less@4.6.4)(sass@1.81.0)(terser@5.36.0):
+    dependencies:
+      '@vitest/expect': 1.6.1
+      '@vitest/runner': 1.6.1
+      '@vitest/snapshot': 1.6.1
+      '@vitest/spy': 1.6.1
+      '@vitest/utils': 1.6.1
+      acorn-walk: 8.3.5
+      chai: 4.5.0
+      debug: 4.4.3
+      execa: 8.0.1
+      local-pkg: 0.5.1
+      magic-string: 0.30.17
+      pathe: 1.1.2
+      picocolors: 1.1.1
+      std-env: 3.10.0
+      strip-literal: 2.1.1
+      tinybench: 2.9.0
+      tinypool: 0.8.4
+      vite: 5.1.4(@types/node@20.17.9)(less@4.6.4)(sass@1.81.0)(terser@5.36.0)
+      vite-node: 1.6.1(@types/node@20.17.9)(less@4.6.4)(sass@1.81.0)(terser@5.36.0)
+      why-is-node-running: 2.3.0
+    optionalDependencies:
+      '@types/node': 20.17.9
+    transitivePeerDependencies:
+      - less
+      - lightningcss
+      - sass
+      - stylus
+      - sugarss
+      - supports-color
+      - terser
+
   vue-demi@0.12.5(vue@3.5.12(typescript@5.3.3)):
   vue-demi@0.12.5(vue@3.5.12(typescript@5.3.3)):
     dependencies:
     dependencies:
       vue: 3.5.12(typescript@5.3.3)
       vue: 3.5.12(typescript@5.3.3)
@@ -13710,6 +13953,11 @@ snapshots:
     dependencies:
     dependencies:
       isexe: 2.0.0
       isexe: 2.0.0
 
 
+  why-is-node-running@2.3.0:
+    dependencies:
+      siginfo: 2.0.0
+      stackback: 0.0.2
+
   wildcard@1.1.2: {}
   wildcard@1.1.2: {}
 
 
   wmf@1.0.2: {}
   wmf@1.0.2: {}

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

@@ -69,6 +69,10 @@ export const IotRhDailyReportApi = {
     return await request.get({ url: `/pms/iot-rh-daily-report/polylineStatistics`, params })
     return await request.get({ url: `/pms/iot-rh-daily-report/polylineStatistics`, params })
   },
   },
 
 
+  getIotRhDailyReportSummaryMonthPolyline: async (params: any) => {
+    return await request.get({ url: `/pms/iot-rh-daily-report/monthPolylineStatistics`, params })
+  },
+
   nptStatistics: async (params: any) => {
   nptStatistics: async (params: any) => {
     return await request.get({ url: `/pms/iot-rh-daily-report/nptStatistics`, params })
     return await request.get({ url: `/pms/iot-rh-daily-report/nptStatistics`, params })
   },
   },

+ 9 - 1
src/assets/icons/grid-16.svg

@@ -1 +1,9 @@
-<svg width="24.000000" height="24.000000" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><clipPath id="clip1_20937"><rect width="24.000000" height="24.000000" fill="white" fill-opacity="0"></rect></clipPath></defs><rect width="24.000000" height="24.000000" fill="currentColor" fill-opacity="0"></rect><rect width="24.000000" height="24.000000" fill-opacity="1.000000"></rect><g clip-path="url(#clip1_20937)"><path d="M1 3L22 3C22.5523 3 23 3.44771 23 4L23 20C23 20.5523 22.5523 21 22 21L1 21C0.447723 21 0 20.5523 0 20L0 4C0 3.44771 0.447723 3 1 3ZM22 4L1 4L1 20L22 20L22 4ZM2 5L6 5L6 8L2 8L2 5ZM7 5L11 5L11 8L7 8L7 5ZM12 5L16 5L16 8L12 8L12 5ZM17 5L21 5L21 8L17 8L17 5ZM2 9L6 9L6 11L2 11L2 9ZM7 9L11 9L11 11L7 11L7 9ZM12 9L16 9L16 11L12 11L12 9ZM17 9L21 9L21 11L17 11L17 9ZM2 12L6 12L6 15L2 15L2 12ZM7 12L11 12L11 15L7 15L7 12ZM12 12L16 12L16 15L12 15L12 12ZM17 12L21 12L21 15L17 15L17 12ZM2 16L6 16L6 19L2 19L2 16ZM7 16L11 16L11 19L7 19L7 16ZM12 16L16 16L16 19L12 19L12 16ZM17 16L21 16L21 19L17 19L17 16Z" clip-rule="evenodd" fill="currentColor" fill-opacity="0.701961" fill-rule="evenodd"></path></g></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
+  <rect x="2" y="3" width="20" height="18" rx="1" fill="none" stroke="currentColor" stroke-width="1.25" />
+  <line x1="7" y1="3" x2="7" y2="21" stroke="currentColor" stroke-width="1.25" />
+  <line x1="12" y1="3" x2="12" y2="21" stroke="currentColor" stroke-width="1.25" />
+  <line x1="17" y1="3" x2="17" y2="21" stroke="currentColor" stroke-width="1.25" />
+  <line x1="2" y1="7.5" x2="22" y2="7.5" stroke="currentColor" stroke-width="1.25" />
+  <line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="1.25" />
+  <line x1="2" y1="16.5" x2="22" y2="16.5" stroke="currentColor" stroke-width="1.25" />
+</svg>

+ 5 - 1
src/assets/icons/grid-four.svg

@@ -1 +1,5 @@
-<svg width="24.000000" height="24.000000" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><clipPath id="clip1_20933"><rect width="24.000000" height="24.000000" fill="white" fill-opacity="0"></rect></clipPath></defs><rect width="24.000000" height="24.000000" fill="currentColor" fill-opacity="0"></rect><rect width="24.000000" height="24.000000" fill-opacity="1.000000"></rect><g clip-path="url(#clip1_20933)"><path d="M22 3L1 3C0.447723 3 0 3.44771 0 4L0 20C0 20.5523 0.447723 21 1 21L22 21C22.5523 21 23 20.5523 23 20L23 4C23 3.44771 22.5523 3 22 3ZM1 4L22 4L22 20L1 20L1 4ZM2 5L11 5L11 11L2 11L2 5ZM12 5L21 5L21 11L12 11L12 5ZM2 12L11 12L11 19L2 19L2 12ZM21 12L12 12L12 19L21 19L21 12Z" clip-rule="evenodd" fill="currentColor" fill-opacity="0.701961" fill-rule="evenodd"></path></g></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
+  <rect x="2" y="3" width="20" height="18" rx="1" fill="none" stroke="currentColor" stroke-width="1.25" />
+  <line x1="12" y1="3" x2="12" y2="21" stroke="currentColor" stroke-width="1.25" />
+  <line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="1.25" />
+</svg>

+ 7 - 1
src/assets/icons/grid-nine.svg

@@ -1 +1,7 @@
-<svg width="24.000000" height="24.000000" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><clipPath id="clip1_20935"><rect width="24.000000" height="24.000000" fill="white" fill-opacity="0"></rect></clipPath></defs><rect width="24.000000" height="24.000000" fill="currentColor" fill-opacity="0"></rect><rect width="24.000000" height="24.000000" fill-opacity="1.000000"></rect><g clip-path="url(#clip1_20935)"><path d="M1 3L22 3C22.5523 3 23 3.44771 23 4L23 20C23 20.5523 22.5523 21 22 21L1 21C0.447723 21 0 20.5523 0 20L0 4C0 3.44771 0.447723 3 1 3ZM22 4L1 4L1 20L22 20L22 4ZM2 5L8 5L8 9L2 9L2 5ZM9 5L14 5L14 9L9 9L9 5ZM15 5L21 5L21 9L15 9L15 5ZM2 10L8 10L8 14L2 14L2 10ZM9 10L14 10L14 14L9 14L9 10ZM15 10L21 10L21 14L15 14L15 10ZM2 15L8 15L8 19L2 19L2 15ZM9 15L14 15L14 19L9 19L9 15ZM15 15L21 15L21 19L15 19L15 15Z" clip-rule="evenodd" fill="currentColor" fill-opacity="0.701961" fill-rule="evenodd"></path></g></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
+  <rect x="2" y="3" width="20" height="18" rx="1" fill="none" stroke="currentColor" stroke-width="1.25" />
+  <line x1="8.67" y1="3" x2="8.67" y2="21" stroke="currentColor" stroke-width="1.25" />
+  <line x1="15.33" y1="3" x2="15.33" y2="21" stroke="currentColor" stroke-width="1.25" />
+  <line x1="2" y1="9" x2="22" y2="9" stroke="currentColor" stroke-width="1.25" />
+  <line x1="2" y1="15" x2="22" y2="15" stroke="currentColor" stroke-width="1.25" />
+</svg>

+ 3 - 1
src/assets/icons/grid-one.svg

@@ -1 +1,3 @@
-<svg width="24.000000" height="24.000000" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><clipPath id="clip1_20931"><rect width="24.000000" height="24.000000" fill="white" fill-opacity="0"></rect></clipPath></defs><rect width="24.000000" height="24.000000" fill="currentColor" fill-opacity="0"></rect><rect width="24.000000" height="24.000000" fill-opacity="1.000000"></rect><g clip-path="url(#clip1_20931)"><path d="M1 3L22 3C22.5523 3 23 3.44771 23 4L23 20C23 20.5523 22.5523 21 22 21L1 21C0.447723 21 0 20.5523 0 20L0 4C0 3.44771 0.447723 3 1 3ZM22 4L1 4L1 20L22 20L22 4ZM21 5L2 5L2 19L21 19L21 5Z" clip-rule="evenodd" fill="currentColor" fill-opacity="0.701961" fill-rule="evenodd"></path></g></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
+  <rect x="2" y="3" width="20" height="18" rx="1" fill="none" stroke="currentColor" stroke-width="1.25" />
+</svg>

+ 1214 - 0
src/components/custom-components/hikvision-video-vue/index.vue

@@ -0,0 +1,1214 @@
+<template>
+  <div class="hikvision-video" :class="{ 'is-editor': editorMode }">
+    <div v-if="editorMode" class="editor-placeholder">
+      <i class="i-lucide:cctv"></i>
+      <strong>{{ camera?.name || '视频监控' }}</strong>
+      <span>{{ camera ? '预览时自动播放' : '请在右侧属性中选择监控点' }}</span>
+    </div>
+
+    <div v-else ref="stageRef" class="video-stage" @dblclick.stop="toggleFullscreen">
+      <div :id="playerContainerId" ref="playerContainerRef" class="player-container"></div>
+
+      <div class="video-overlay">
+        <div v-if="camera" class="hover-title">
+          <span class="title-main">
+            <i class="i-lucide:video"></i>
+            <span>{{ camera.name }}</span>
+          </span>
+          <span class="status">{{ previewStatus }}</span>
+          <button
+            type="button"
+            class="close-button"
+            title="关闭预览"
+            aria-label="关闭预览"
+            @dblclick.stop
+            @click.stop="stopPreview">
+            <i class="i-lucide:circle-x"></i>
+          </button>
+        </div>
+
+        <div v-if="loading" class="center-state">
+          <i class="i-lucide:loader-circle animate-spin"></i>
+          <span>正在加载监控画面</span>
+        </div>
+        <div v-else-if="errorMessage" class="center-state is-error">
+          <i class="i-lucide:triangle-alert"></i>
+          <span>{{ errorMessage }}</span>
+          <button v-if="camera" type="button" @click.stop="play">重新预览</button>
+        </div>
+        <div v-else-if="!playing" class="center-state">
+          <i class="i-lucide:cctv"></i>
+          <span>{{ camera ? '监控画面已关闭' : '未选择监控点' }}</span>
+          <button v-if="camera" type="button" @click.stop="play">开始预览</button>
+        </div>
+
+        <div v-if="camera && (playing || loading)" class="hover-actions" @dblclick.stop>
+          <el-tooltip :content="streamSwitchTitle" placement="top" :teleported="false">
+            <button
+              type="button"
+              class="action-button"
+              :disabled="!canOperate || streamSwitching"
+              :aria-label="streamSwitchTitle"
+              @click.stop="switchStream">
+              <i
+                :class="
+                  streamSwitching ? 'i-lucide:loader-circle animate-spin' : 'i-lucide:refresh-cw'
+                "></i>
+            </button>
+          </el-tooltip>
+
+          <el-tooltip content="抓取当前画面" placement="top" :teleported="false">
+            <button
+              type="button"
+              class="action-button"
+              :disabled="!canOperate || capturing"
+              aria-label="抓取当前画面"
+              @click.stop="capturePicture">
+              <i :class="capturing ? 'i-lucide:loader-circle animate-spin' : 'i-lucide:camera'"></i>
+            </button>
+          </el-tooltip>
+
+          <el-tooltip :content="zoomTitle" placement="top" :teleported="false">
+            <button
+              type="button"
+              class="action-button"
+              :class="{ active: zoomEnabled }"
+              :disabled="!canOperate || zoomLoading"
+              :aria-label="zoomTitle"
+              :aria-pressed="zoomEnabled"
+              @click.stop="toggleZoom">
+              <i
+                :class="
+                  zoomLoading
+                    ? 'i-lucide:loader-circle animate-spin'
+                    : zoomEnabled
+                      ? 'i-lucide:zoom-out'
+                      : 'i-lucide:zoom-in'
+                "></i>
+            </button>
+          </el-tooltip>
+
+          <el-tooltip :content="soundTitle" placement="top" :teleported="false">
+            <button
+              type="button"
+              class="action-button"
+              :class="{ active: soundEnabled }"
+              :disabled="!canOperate || soundLoading"
+              :aria-label="soundTitle"
+              :aria-pressed="soundEnabled"
+              @click.stop="toggleSound">
+              <i :class="soundEnabled ? 'i-lucide:volume-2' : 'i-lucide:volume-x'"></i>
+            </button>
+          </el-tooltip>
+
+          <el-tooltip :content="talkTitle" placement="top" :teleported="false">
+            <button
+              type="button"
+              class="action-button"
+              :class="{ 'is-talking': talking }"
+              :disabled="!canOperate || talkLoading"
+              :aria-label="talkTitle"
+              :aria-pressed="talking"
+              @click.stop="toggleTalk">
+              <i
+                :class="
+                  talkLoading
+                    ? 'i-lucide:loader-circle animate-spin'
+                    : talking
+                      ? 'i-lucide:mic'
+                      : 'i-lucide:mic-off'
+                "></i>
+            </button>
+          </el-tooltip>
+
+          <el-popover
+            v-model:visible="ptzVisible"
+            placement="top-end"
+            trigger="click"
+            :width="154"
+            :show-arrow="false"
+            :teleported="false"
+            popper-class="hikvision-single-ptz-popover"
+            @hide="stopPtzControl">
+            <template #reference>
+              <button
+                type="button"
+                class="action-button"
+                :class="{ active: ptzVisible }"
+                :disabled="!canOperate"
+                aria-label="打开云台控制"
+                :aria-expanded="ptzVisible">
+                <i class="i-lucide:move"></i>
+              </button>
+            </template>
+            <div class="ptz-content">
+              <div class="ptz-title"><i class="i-lucide:move"></i><span>云台控制</span></div>
+              <div class="ptz-panel" role="group" aria-label="云台方向控制">
+                <button
+                  v-for="direction in ptzDirections"
+                  :key="direction.command"
+                  type="button"
+                  class="ptz-button"
+                  :class="{ 'is-active': activePtzCommand === direction.command }"
+                  :style="{ gridArea: direction.gridArea }"
+                  :title="direction.label"
+                  :aria-label="direction.label"
+                  @pointerdown="startPtzControl($event, direction.command)"
+                  @pointerup="stopPtzFromPointer"
+                  @pointercancel="stopPtzFromPointer"
+                  @lostpointercapture="stopPtzFromPointer"
+                  @contextmenu.prevent
+                  @dragstart.prevent>
+                  <i :class="direction.icon"></i>
+                </button>
+                <span class="ptz-center"><i class="i-lucide:scan"></i></span>
+              </div>
+            </div>
+          </el-popover>
+
+          <el-tooltip
+            :content="fullscreen ? '退出全屏' : '全屏'"
+            placement="top"
+            :teleported="false">
+            <button
+              type="button"
+              class="action-button"
+              :disabled="fullscreenLoading"
+              :aria-label="fullscreen ? '退出全屏' : '全屏'"
+              :aria-pressed="fullscreen"
+              @click.stop="toggleFullscreen">
+              <i
+                :class="
+                  fullscreenLoading
+                    ? 'i-lucide:loader-circle animate-spin'
+                    : fullscreen
+                      ? 'i-lucide:minimize'
+                      : 'i-lucide:maximize'
+                "></i>
+            </button>
+          </el-tooltip>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import {
+  HikvisionApi,
+  type HikvisionCameraNode,
+  type HikvisionPtzCommand,
+  type HikvisionStreamType
+} from '@/api/pms/hikvision'
+import {
+  getHikvisionPlayerBasePath,
+  loadHikvisionPlayer,
+  type HikvisionPlayerInstance
+} from '@/utils/hikvisionH5Player'
+import { ElMessage } from 'element-plus'
+import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+
+const props = withDefaults(
+  defineProps<{
+    camera?: HikvisionCameraNode | null
+    editorMode?: boolean
+    autoplay?: boolean
+  }>(),
+  {
+    camera: null,
+    editorMode: false,
+    autoplay: true
+  }
+)
+
+const emit = defineEmits<{
+  error: [error: unknown]
+  playing: [camera: HikvisionCameraNode]
+  stopped: []
+}>()
+
+const instanceUid = getCurrentInstance()?.uid ?? Math.floor(Math.random() * 1000000)
+const playerContainerId = `hikvision-single-player-${instanceUid}`
+const stageRef = ref<HTMLElement>()
+const playerContainerRef = ref<HTMLElement>()
+const loading = ref(false)
+const playing = ref(false)
+const interrupted = ref(false)
+const errorMessage = ref('')
+const streamType = ref<HikvisionStreamType>(0)
+const streamSwitching = ref(false)
+const capturing = ref(false)
+const soundEnabled = ref(false)
+const soundLoading = ref(false)
+const talking = ref(false)
+const talkLoading = ref(false)
+const zoomEnabled = ref(false)
+const zoomLoading = ref(false)
+const ptzVisible = ref(false)
+const activePtzCommand = ref<HikvisionPtzCommand>()
+const fullscreen = ref(false)
+const fullscreenLoading = ref(false)
+
+let player: HikvisionPlayerInstance | undefined
+let playerInitPromise: Promise<HikvisionPlayerInstance> | undefined
+let resizeObserver: ResizeObserver | undefined
+let resizeFrame: number | undefined
+let resizeRunning = false
+let resizePending = false
+let mounted = false
+let destroyed = false
+let operationId = 0
+let playIssuedOperationId: number | undefined
+let errorTimer: ReturnType<typeof window.setTimeout> | undefined
+let talkOperationId = 0
+let zoomOperationId = 0
+
+interface ActivePtzPress {
+  cameraIndexCode: string
+  command: HikvisionPtzCommand
+  pointerId: number
+  target: HTMLElement
+  startRequest: Promise<boolean>
+}
+
+let activePtzPress: ActivePtzPress | undefined
+let ptzBusy = false
+
+const PTZ_CONTROL_SPEED = 30
+const ptzDirections: Array<{
+  label: string
+  icon: string
+  gridArea: string
+  command: HikvisionPtzCommand
+}> = [
+  { label: '左上', icon: 'i-lucide:arrow-up-left', gridArea: '1 / 1', command: 'LEFT_UP' },
+  { label: '向上', icon: 'i-lucide:arrow-up', gridArea: '1 / 2', command: 'UP' },
+  { label: '右上', icon: 'i-lucide:arrow-up-right', gridArea: '1 / 3', command: 'RIGHT_UP' },
+  { label: '向左', icon: 'i-lucide:arrow-left', gridArea: '2 / 1', command: 'LEFT' },
+  { label: '向右', icon: 'i-lucide:arrow-right', gridArea: '2 / 3', command: 'RIGHT' },
+  {
+    label: '左下',
+    icon: 'i-lucide:arrow-down-left',
+    gridArea: '3 / 1',
+    command: 'LEFT_DOWN'
+  },
+  { label: '向下', icon: 'i-lucide:arrow-down', gridArea: '3 / 2', command: 'DOWN' },
+  {
+    label: '右下',
+    icon: 'i-lucide:arrow-down-right',
+    gridArea: '3 / 3',
+    command: 'RIGHT_DOWN'
+  }
+]
+
+const canOperate = computed(() => playing.value && !loading.value && !interrupted.value)
+const previewStatus = computed(() => {
+  if (loading.value) return '正在加载'
+  if (interrupted.value) return '画面中断'
+  if (playing.value) return '正在预览'
+  return '未预览'
+})
+const streamSwitchTitle = computed(() => (streamType.value === 0 ? '切换为子码流' : '切换为主码流'))
+const soundTitle = computed(() => (soundEnabled.value ? '关闭声音' : '开启声音'))
+const talkTitle = computed(() => {
+  if (talkLoading.value) return talking.value ? '正在停止对讲' : '正在开启对讲'
+  return talking.value ? '停止对讲' : '开启对讲'
+})
+const zoomTitle = computed(() => {
+  if (zoomLoading.value) return zoomEnabled.value ? '正在关闭电子放大' : '正在开启电子放大'
+  return zoomEnabled.value ? '关闭电子放大' : '开启电子放大'
+})
+
+function clearErrorTimer() {
+  if (errorTimer === undefined) return
+  window.clearTimeout(errorTimer)
+  errorTimer = undefined
+}
+
+function clearError() {
+  clearErrorTimer()
+  errorMessage.value = ''
+}
+
+function showError(message: string, error?: unknown) {
+  if (destroyed) return
+  clearErrorTimer()
+  errorMessage.value = message
+  if (error !== undefined) emit('error', error)
+  errorTimer = window.setTimeout(() => {
+    errorMessage.value = ''
+    errorTimer = undefined
+  }, 3000)
+}
+
+function getTalkErrorMessage(errorCode: number) {
+  if (errorCode === 0x12f950000) return '麦克风采集失败,请检查 HTTPS 和麦克风权限'
+  if (errorCode === 0x12f950001) return '监控设备的对讲音频编码不受支持'
+  return `对讲异常(${errorCode})`
+}
+
+async function initPlayer() {
+  if (player) return player
+  if (playerInitPromise) return playerInitPromise
+
+  playerInitPromise = (async () => {
+    let initializingPlayer: HikvisionPlayerInstance | undefined
+    try {
+      await nextTick()
+      const JSPlugin = await loadHikvisionPlayer()
+      if (destroyed || props.editorMode) throw new Error('播放器已停止初始化')
+      initializingPlayer = new JSPlugin({
+        szId: playerContainerId,
+        szBasePath: getHikvisionPlayerBasePath(),
+        iMaxSplit: 1,
+        iCurrentSplit: 1,
+        mseWorkerEnable: window.crossOriginIsolated,
+        bSupporDoubleClickFull: false,
+        oStyle: { borderSelect: '#facc15' }
+      })
+      await initializingPlayer.JS_SetWindowControlCallback({
+        firstFrameDisplay: (windowIndex) => {
+          if (windowIndex !== 0 || playIssuedOperationId !== operationId) return
+          loading.value = false
+          interrupted.value = false
+          playing.value = true
+          if (props.camera) emit('playing', props.camera)
+        },
+        InterruptStream: (windowIndex, interruptTime) => {
+          if (windowIndex !== 0 || playIssuedOperationId !== operationId) return
+          loading.value = false
+          interrupted.value = true
+          playing.value = false
+          void stopTalk()
+          void stopZoom()
+          showError(`监控画面已中断(${interruptTime} 秒未收到码流)`)
+        },
+        StreamEnd: (windowIndex) => {
+          if (windowIndex !== 0 || playIssuedOperationId !== operationId) return
+          loading.value = false
+          playing.value = false
+          void stopTalk()
+          void stopZoom()
+          showError('监控画面已结束')
+        },
+        performanceLack: () => showError('当前设备性能不足,监控画面可能卡顿'),
+        pluginErrorHandler: (windowIndex, errorCode, error) => {
+          if (windowIndex !== 0 || playIssuedOperationId !== operationId) return
+          console.error('H5player 播放器错误', errorCode, error)
+          loading.value = false
+          playing.value = false
+          showError(`播放器异常(${errorCode})`, error)
+        },
+        talkPluginErrorHandler: (errorCode, error) => {
+          console.error('H5player 对讲错误', errorCode, error)
+          talkOperationId += 1
+          talking.value = false
+          talkLoading.value = false
+          showError(getTalkErrorMessage(errorCode), error)
+        }
+      })
+      if (destroyed || props.editorMode) throw new Error('播放器已停止初始化')
+      player = initializingPlayer
+      initializingPlayer = undefined
+      scheduleResize()
+      return player
+    } catch (error) {
+      await initializingPlayer?.JS_StopRealPlayAll().catch(() => undefined)
+      await Promise.resolve(initializingPlayer?.JS_Destroy()).catch(() => undefined)
+      throw error
+    } finally {
+      playerInitPromise = undefined
+    }
+  })()
+
+  return playerInitPromise
+}
+
+async function play() {
+  const camera = props.camera
+  if (!mounted || destroyed || props.editorMode || !camera) return
+  if (camera.available === false || camera.onlineStatus === false) {
+    showError(`${camera.name} 不在线,无法预览`)
+    return
+  }
+
+  const currentOperationId = ++operationId
+  playIssuedOperationId = undefined
+  loading.value = true
+  playing.value = false
+  interrupted.value = false
+  streamType.value = 0
+  clearError()
+
+  try {
+    const [currentPlayer, previewUrl] = await Promise.all([
+      initPlayer(),
+      HikvisionApi.getPreviewUrl(camera.indexCode, 0)
+    ])
+    if (currentOperationId !== operationId || destroyed) return
+    if (!previewUrl) throw new Error('预览地址为空')
+
+    await stopPtzControl()
+    await stopTalk()
+    await stopZoom()
+    await closeSound()
+    await currentPlayer.JS_Stop(0).catch(() => undefined)
+    if (currentOperationId !== operationId || destroyed) return
+
+    playIssuedOperationId = currentOperationId
+    await currentPlayer.JS_Play(previewUrl, { playURL: previewUrl, mode: 0, keepDecoder: 0 }, 0)
+  } catch (error) {
+    if (currentOperationId !== operationId || destroyed) return
+    console.error('监控点预览失败', error)
+    loading.value = false
+    playing.value = false
+    playIssuedOperationId = undefined
+    showError(error instanceof Error ? error.message : '监控点预览失败', error)
+  }
+}
+
+async function switchStream() {
+  const camera = props.camera
+  if (!camera || !canOperate.value || streamSwitching.value) return
+  const nextStreamType: HikvisionStreamType = streamType.value === 0 ? 1 : 0
+  const currentOperationId = ++operationId
+  playIssuedOperationId = undefined
+  streamSwitching.value = true
+  loading.value = true
+  playing.value = false
+  clearError()
+
+  try {
+    const [currentPlayer, previewUrl] = await Promise.all([
+      initPlayer(),
+      HikvisionApi.getPreviewUrl(camera.indexCode, nextStreamType)
+    ])
+    if (currentOperationId !== operationId || destroyed) return
+    if (!previewUrl) throw new Error('切换码流的预览地址为空')
+    await stopPtzControl()
+    await stopTalk()
+    await stopZoom()
+    await closeSound()
+    await currentPlayer.JS_Stop(0)
+    if (currentOperationId !== operationId || destroyed) return
+    streamType.value = nextStreamType
+    playIssuedOperationId = currentOperationId
+    await currentPlayer.JS_Play(previewUrl, { playURL: previewUrl, mode: 0, keepDecoder: 0 }, 0)
+  } catch (error) {
+    if (currentOperationId !== operationId || destroyed) return
+    console.error('切换监控码流失败', error)
+    loading.value = false
+    playing.value = false
+    showError(error instanceof Error ? error.message : '切换监控码流失败', error)
+  } finally {
+    if (currentOperationId === operationId) streamSwitching.value = false
+  }
+}
+
+function createCaptureFileName(camera: HikvisionCameraNode) {
+  const safeName = camera.name.replace(/[\\/:*?"<>|]/g, '_').trim() || camera.indexCode
+  const now = new Date()
+  const pad = (value: number) => String(value).padStart(2, '0')
+  return `${safeName}_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(
+    now.getHours()
+  )}${pad(now.getMinutes())}${pad(now.getSeconds())}`
+}
+
+async function capturePicture() {
+  if (!props.camera || !player || !canOperate.value || capturing.value) return
+  const currentOperationId = operationId
+  capturing.value = true
+  clearError()
+  try {
+    await player.JS_CapturePicture(0, createCaptureFileName(props.camera), 'JPEG')
+    if (currentOperationId !== operationId || destroyed) return
+    ElMessage.success('抓图已保存到本地')
+  } catch (error) {
+    if (currentOperationId !== operationId || destroyed) return
+    console.error('监控画面抓图失败', error)
+    showError(error instanceof Error ? error.message : '监控画面抓图失败', error)
+  } finally {
+    if (currentOperationId === operationId) capturing.value = false
+  }
+}
+
+async function closeSound() {
+  if (!soundEnabled.value) return true
+  try {
+    await player?.JS_CloseSound(0)
+    soundEnabled.value = false
+    return true
+  } catch {
+    return false
+  }
+}
+
+async function toggleSound() {
+  if (!player || !canOperate.value || soundLoading.value) return
+  const currentOperationId = operationId
+  soundLoading.value = true
+  clearError()
+  try {
+    if (soundEnabled.value) {
+      await player.JS_CloseSound(0)
+      if (currentOperationId !== operationId || destroyed) return
+      soundEnabled.value = false
+      ElMessage.success('声音已关闭')
+    } else {
+      await player.JS_OpenSound(0)
+      if (currentOperationId !== operationId || destroyed) return
+      soundEnabled.value = true
+      ElMessage.success('声音已开启')
+    }
+  } catch (error) {
+    if (currentOperationId !== operationId || destroyed) return
+    console.error('切换监控声音失败', error)
+    showError(error instanceof Error ? error.message : '切换监控声音失败', error)
+  } finally {
+    if (currentOperationId === operationId) soundLoading.value = false
+  }
+}
+
+async function stopTalk(notify = false) {
+  const wasTalking = talking.value
+  const currentTalkOperationId = ++talkOperationId
+  talking.value = false
+  if (!wasTalking) {
+    talkLoading.value = false
+    return true
+  }
+  talkLoading.value = true
+  try {
+    await player?.JS_StopTalk()
+    if (notify) ElMessage.success('对讲已停止')
+    return true
+  } catch (error) {
+    if (notify) showError(error instanceof Error ? error.message : '停止对讲失败', error)
+    return false
+  } finally {
+    if (currentTalkOperationId === talkOperationId) talkLoading.value = false
+  }
+}
+
+async function toggleTalk() {
+  if (!props.camera || !player || !canOperate.value || talkLoading.value) return
+  if (talking.value) {
+    await stopTalk(true)
+    return
+  }
+  if (!window.isSecureContext) {
+    showError('对讲功能仅支持 HTTPS 安全环境')
+    return
+  }
+
+  const currentTalkOperationId = ++talkOperationId
+  talkLoading.value = true
+  clearError()
+  try {
+    const talkUrl = await HikvisionApi.getTalkUrl(props.camera.indexCode)
+    if (currentTalkOperationId !== talkOperationId || destroyed) return
+    if (!talkUrl) throw new Error('对讲地址为空')
+    await player.JS_StartTalk(talkUrl)
+    if (currentTalkOperationId !== talkOperationId || destroyed) {
+      await player.JS_StopTalk().catch(() => undefined)
+      return
+    }
+    talking.value = true
+    ElMessage.success('对讲已开启')
+  } catch (error) {
+    if (currentTalkOperationId !== talkOperationId || destroyed) return
+    console.error('开启监控对讲失败', error)
+    showError(error instanceof Error ? error.message : '开启对讲失败,请检查麦克风权限', error)
+  } finally {
+    if (currentTalkOperationId === talkOperationId) talkLoading.value = false
+  }
+}
+
+async function stopZoom() {
+  const currentZoomOperationId = ++zoomOperationId
+  if (!zoomEnabled.value && !zoomLoading.value) return true
+  zoomLoading.value = true
+  try {
+    await player?.JS_DisableZoom(0)
+    if (currentZoomOperationId !== zoomOperationId) return false
+    zoomEnabled.value = false
+    return true
+  } catch {
+    return false
+  } finally {
+    if (currentZoomOperationId === zoomOperationId) zoomLoading.value = false
+  }
+}
+
+async function toggleZoom() {
+  if (!player || !canOperate.value || zoomLoading.value) return
+  if (!window.isSecureContext) {
+    showError('电子放大仅支持 HTTPS 安全环境')
+    return
+  }
+  const enable = !zoomEnabled.value
+  const currentZoomOperationId = ++zoomOperationId
+  zoomLoading.value = true
+  clearError()
+  try {
+    if (enable) await player.JS_EnableZoom(0)
+    else await player.JS_DisableZoom(0)
+    if (currentZoomOperationId !== zoomOperationId) {
+      if (enable) await player.JS_DisableZoom(0).catch(() => undefined)
+      return
+    }
+    zoomEnabled.value = enable
+    ElMessage.success(enable ? '电子放大已开启,请在画面中框选区域' : '电子放大已关闭')
+  } catch (error) {
+    if (currentZoomOperationId !== zoomOperationId) return
+    console.error('切换电子放大失败', error)
+    showError(error instanceof Error ? error.message : '切换电子放大失败', error)
+  } finally {
+    if (currentZoomOperationId === zoomOperationId) zoomLoading.value = false
+  }
+}
+
+function startPtzControl(event: PointerEvent, command: HikvisionPtzCommand) {
+  if (
+    !event.isPrimary ||
+    event.button !== 0 ||
+    activePtzPress ||
+    ptzBusy ||
+    !props.camera ||
+    !canOperate.value
+  ) {
+    return
+  }
+  event.preventDefault()
+  const target = event.currentTarget as HTMLElement
+  try {
+    target.setPointerCapture(event.pointerId)
+  } catch {
+    // 旧浏览器不支持指针捕获时,仍由按钮本身的 pointerup 尝试停止。
+  }
+  const press: ActivePtzPress = {
+    cameraIndexCode: props.camera.indexCode,
+    command,
+    pointerId: event.pointerId,
+    target,
+    startRequest: Promise.resolve(true)
+  }
+  activePtzPress = press
+  ptzBusy = true
+  activePtzCommand.value = command
+  press.startRequest = HikvisionApi.controlPtz({
+    cameraIndexCode: press.cameraIndexCode,
+    action: 0,
+    command,
+    speed: PTZ_CONTROL_SPEED
+  })
+    .then(() => true)
+    .catch((error) => {
+      console.error('开启云台方向控制失败', error)
+      showError('当前监控点不支持云台控制', error)
+      return false
+    })
+}
+
+async function stopPtzControl(pointerId?: number) {
+  const press = activePtzPress
+  if (!press || (pointerId !== undefined && press.pointerId !== pointerId)) return
+  activePtzPress = undefined
+  activePtzCommand.value = undefined
+  try {
+    if (press.target.hasPointerCapture(press.pointerId)) {
+      press.target.releasePointerCapture(press.pointerId)
+    }
+  } catch {
+    // 元素销毁或浏览器已自动释放时无需处理。
+  }
+  const started = await press.startRequest
+  if (!started) {
+    ptzBusy = false
+    return
+  }
+  try {
+    await HikvisionApi.controlPtz({
+      cameraIndexCode: press.cameraIndexCode,
+      action: 1,
+      command: press.command
+    })
+  } catch (error) {
+    console.error('停止云台方向控制失败', error)
+    showError(error instanceof Error ? error.message : '停止云台方向控制失败', error)
+  } finally {
+    ptzBusy = false
+  }
+}
+
+function stopPtzFromPointer(event: PointerEvent) {
+  void stopPtzControl(event.pointerId)
+}
+
+async function stopPreview() {
+  operationId += 1
+  playIssuedOperationId = undefined
+  loading.value = false
+  streamSwitching.value = false
+  clearError()
+  try {
+    await stopPtzControl()
+    await stopTalk()
+    await stopZoom()
+    await closeSound()
+    await player?.JS_Stop(0)
+  } catch (error) {
+    console.error('关闭监控画面失败', error)
+    showError(error instanceof Error ? error.message : '关闭监控画面失败', error)
+  } finally {
+    playing.value = false
+    interrupted.value = false
+    ptzVisible.value = false
+    emit('stopped')
+  }
+}
+
+async function resizePlayer() {
+  resizeFrame = undefined
+  if (!player || !playerContainerRef.value) return
+  if (resizeRunning) {
+    resizePending = true
+    return
+  }
+  const { clientWidth, clientHeight } = playerContainerRef.value
+  if (!clientWidth || !clientHeight) return
+  resizeRunning = true
+  try {
+    await player.JS_Resize(clientWidth, clientHeight)
+  } catch {
+    // 布局变化与播放器销毁发生在同一帧时可以安全忽略。
+  } finally {
+    resizeRunning = false
+    if (resizePending && !destroyed) {
+      resizePending = false
+      scheduleResize()
+    }
+  }
+}
+
+function scheduleResize() {
+  if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
+  resizeFrame = requestAnimationFrame(resizePlayer)
+}
+
+async function toggleFullscreen() {
+  if (fullscreenLoading.value || !stageRef.value) return
+  fullscreenLoading.value = true
+  try {
+    if (document.fullscreenElement === stageRef.value) await document.exitFullscreen()
+    else await stageRef.value.requestFullscreen()
+  } catch (error) {
+    showError(error instanceof Error ? error.message : '切换全屏失败', error)
+  } finally {
+    fullscreenLoading.value = false
+  }
+}
+
+function handleFullscreenChange() {
+  fullscreen.value = document.fullscreenElement === stageRef.value
+  void nextTick(scheduleResize)
+}
+
+async function teardownPlayer() {
+  operationId += 1
+  playIssuedOperationId = undefined
+  resizeObserver?.disconnect()
+  resizeObserver = undefined
+  if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
+  resizeFrame = undefined
+  resizePending = false
+  await stopPtzControl()
+  await stopTalk()
+  await stopZoom()
+  const currentPlayer = player
+  player = undefined
+  if (currentPlayer) {
+    await currentPlayer.JS_CloseSound(0).catch(() => undefined)
+    await currentPlayer.JS_StopTalk().catch(() => undefined)
+    await currentPlayer.JS_StopRealPlayAll().catch(() => undefined)
+    await Promise.resolve(currentPlayer.JS_Destroy()).catch(() => undefined)
+  }
+  loading.value = false
+  playing.value = false
+  interrupted.value = false
+  streamSwitching.value = false
+  capturing.value = false
+  soundEnabled.value = false
+  soundLoading.value = false
+  talkLoading.value = false
+  zoomEnabled.value = false
+  zoomLoading.value = false
+}
+
+async function syncCamera() {
+  if (!mounted || destroyed) return
+  await teardownPlayer()
+  if (props.editorMode || !props.camera || !props.autoplay) return
+  await nextTick()
+  if (playerContainerRef.value) {
+    resizeObserver = new ResizeObserver(scheduleResize)
+    resizeObserver.observe(playerContainerRef.value)
+  }
+  await play()
+}
+
+watch(
+  () => [props.camera?.indexCode, props.editorMode, props.autoplay] as const,
+  () => void syncCamera()
+)
+
+onMounted(() => {
+  mounted = true
+  destroyed = false
+  document.addEventListener('fullscreenchange', handleFullscreenChange)
+  void syncCamera()
+})
+
+onBeforeUnmount(() => {
+  destroyed = true
+  mounted = false
+  clearErrorTimer()
+  document.removeEventListener('fullscreenchange', handleFullscreenChange)
+  if (document.fullscreenElement === stageRef.value) void document.exitFullscreen()
+  void teardownPlayer()
+})
+
+defineExpose({
+  play,
+  stop: stopPreview,
+  capturePicture,
+  toggleFullscreen
+})
+</script>
+
+<style scoped lang="scss">
+.hikvision-video,
+.video-stage,
+.player-container,
+.video-overlay {
+  width: 100%;
+  height: 100%;
+}
+
+.hikvision-video {
+  min-width: 0;
+  min-height: 0;
+  overflow: hidden;
+  color: #f8fafc;
+  background: #0b1118;
+}
+
+.editor-placeholder {
+  display: flex;
+  width: 100%;
+  height: 100%;
+  min-width: 0;
+  min-height: 0;
+  padding: 18px;
+  color: #cbd5e1;
+  background: linear-gradient(rgb(96 165 250 / 8%) 1px, transparent 1px),
+    linear-gradient(90deg, rgb(96 165 250 / 8%) 1px, transparent 1px), #0b1118;
+  background-size: 24px 24px;
+  border: 1px solid rgb(96 165 250 / 42%);
+  box-sizing: border-box;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.editor-placeholder > i {
+  font-size: clamp(24px, 15%, 48px);
+  color: #60a5fa;
+}
+
+.editor-placeholder strong,
+.editor-placeholder span {
+  max-width: 100%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.editor-placeholder span {
+  font-size: 12px;
+  color: #94a3b8;
+}
+
+.video-stage {
+  position: relative;
+  overflow: hidden;
+  background: #0b1118;
+}
+
+.video-stage:fullscreen {
+  width: 100vw;
+  height: 100vh;
+}
+
+.player-container,
+.video-overlay {
+  position: absolute;
+  inset: 0;
+}
+
+.player-container :deep(.parent-wnd),
+.player-container :deep(.sub-wnd) {
+  width: 100% !important;
+  height: 100% !important;
+}
+
+.video-overlay {
+  z-index: 2;
+  pointer-events: none;
+}
+
+.hover-title,
+.hover-actions {
+  position: absolute;
+  right: 0;
+  left: 0;
+  z-index: 3;
+  display: flex;
+  min-height: 40px;
+  padding: 5px 12px;
+  pointer-events: none;
+  opacity: 0;
+  visibility: hidden;
+  backdrop-filter: blur(3px) saturate(115%);
+  box-sizing: border-box;
+  align-items: center;
+  transition:
+    opacity 0.18s ease,
+    transform 0.18s ease,
+    visibility 0.18s ease;
+}
+
+.video-stage:hover .hover-title,
+.video-stage:hover .hover-actions,
+.hover-title:focus-within,
+.hover-actions:focus-within {
+  opacity: 1;
+  visibility: visible;
+  transform: translateY(0);
+}
+
+.hover-title {
+  top: 0;
+  background: linear-gradient(180deg, rgb(2 6 23 / 90%), rgb(15 23 42 / 50%));
+  border-bottom: 1px solid rgb(255 255 255 / 10%);
+  transform: translateY(-4px);
+}
+
+.title-main {
+  display: inline-flex;
+  min-width: 0;
+  flex: 1;
+  font-size: 14px;
+  font-weight: 600;
+  align-items: center;
+  gap: 6px;
+}
+
+.title-main span {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.status {
+  margin-left: 10px;
+  font-size: 12px;
+  color: #e2e8f0;
+  white-space: nowrap;
+}
+
+.close-button,
+.action-button {
+  display: inline-flex;
+  width: 30px;
+  height: 30px;
+  padding: 0;
+  color: #e2e8f0;
+  pointer-events: auto;
+  cursor: pointer;
+  background: rgb(255 255 255 / 8%);
+  border: 1px solid rgb(255 255 255 / 12%);
+  border-radius: 6px;
+  align-items: center;
+  justify-content: center;
+}
+
+.close-button {
+  margin-left: 6px;
+  background: transparent;
+  border-color: transparent;
+}
+
+.close-button:hover {
+  color: #fff;
+  background: rgb(239 68 68 / 78%);
+}
+
+.hover-actions {
+  bottom: 0;
+  overflow: visible;
+  background: linear-gradient(0deg, rgb(2 6 23 / 90%), rgb(15 23 42 / 48%));
+  border-top: 1px solid rgb(255 255 255 / 8%);
+  transform: translateY(4px);
+  justify-content: flex-end;
+  gap: 7px;
+}
+
+.action-button:hover:not(:disabled),
+.action-button.active {
+  color: #fff;
+  background: rgb(59 130 246 / 68%);
+  border-color: rgb(147 197 253 / 72%);
+}
+
+.action-button.is-talking {
+  color: #fff;
+  background: rgb(239 68 68 / 68%);
+  border-color: rgb(252 165 165 / 78%);
+}
+
+.action-button:disabled {
+  cursor: not-allowed;
+  opacity: 0.5;
+}
+
+.center-state {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  padding: 46px 16px;
+  font-size: 13px;
+  color: #94a3b8;
+  pointer-events: none;
+  background: rgb(2 6 23 / 28%);
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.center-state > i {
+  font-size: 26px;
+  color: #60a5fa;
+}
+
+.center-state.is-error {
+  color: #fecaca;
+  background: rgb(69 10 10 / 72%);
+}
+
+.center-state.is-error > i {
+  color: #f87171;
+}
+
+.center-state button {
+  padding: 5px 12px;
+  color: #dbeafe;
+  pointer-events: auto;
+  cursor: pointer;
+  background: rgb(37 99 235 / 44%);
+  border: 1px solid rgb(147 197 253 / 50%);
+  border-radius: 5px;
+}
+
+.ptz-content {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.ptz-title {
+  display: flex;
+  padding-bottom: 8px;
+  font-size: 14px;
+  font-weight: 600;
+  color: #f8fafc;
+  border-bottom: 1px solid rgb(148 163 184 / 20%);
+  align-items: center;
+  gap: 7px;
+}
+
+.ptz-panel {
+  display: grid;
+  grid-template-columns: repeat(3, 36px);
+  grid-template-rows: repeat(3, 36px);
+  gap: 6px;
+  justify-content: center;
+}
+
+.ptz-button,
+.ptz-center {
+  display: inline-flex;
+  width: 36px;
+  height: 36px;
+  color: #dbeafe;
+  background: #273544;
+  border: 1px solid #46576a;
+  border-radius: 7px;
+  align-items: center;
+  justify-content: center;
+  box-sizing: border-box;
+}
+
+.ptz-button {
+  padding: 0;
+  cursor: pointer;
+  user-select: none;
+  touch-action: none;
+}
+
+.ptz-button:hover,
+.ptz-button.is-active {
+  color: #fff;
+  background: #0284c7;
+  border-color: #7dd3fc;
+}
+
+.ptz-center {
+  grid-area: 2 / 2;
+  color: #7dd3fc;
+  background: rgb(14 165 233 / 10%);
+  border-radius: 50%;
+}
+
+:global(.hikvision-single-ptz-popover.el-popper) {
+  min-width: 0;
+  padding: 12px;
+  background: #18212c;
+  border-color: #46576a;
+  box-shadow: 0 10px 28px rgb(2 6 23 / 46%);
+}
+
+@media (width <= 420px), (height <= 180px) {
+  .hover-title,
+  .hover-actions {
+    min-height: 34px;
+    padding: 2px 6px;
+  }
+
+  .hover-actions {
+    gap: 3px;
+  }
+
+  .action-button,
+  .close-button {
+    width: 27px;
+    height: 27px;
+  }
+
+  .status {
+    display: none;
+  }
+}
+</style>

+ 66 - 0
src/components/custom-components/status-vue/index.vue

@@ -0,0 +1,66 @@
+<template>
+  <div class="status-display" :style="statusStyle" :title="displayText">
+    {{ displayText }}
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { IStatusColorRule } from '@/components/mt-edit/store/types'
+import { computed, type PropType } from 'vue'
+
+const props = defineProps({
+  value: {
+    type: [String, Number, Boolean] as PropType<string | number | boolean>,
+    default: ''
+  },
+  colorRules: {
+    type: Array as PropType<IStatusColorRule[]>,
+    default: () => []
+  },
+  defaultColor: {
+    type: String,
+    default: '#909399'
+  },
+  fontColor: {
+    type: String,
+    default: '#ffffff'
+  },
+  fontSize: {
+    type: Number,
+    default: 14
+  },
+  borderRadius: {
+    type: Number,
+    default: 36
+  }
+})
+
+const matchedRule = computed(() =>
+  props.colorRules.find((rule) => String(rule.value) === String(props.value))
+)
+
+const displayText = computed(() => matchedRule.value?.label || '')
+
+const statusStyle = computed(() => ({
+  backgroundColor: matchedRule.value?.color || props.defaultColor,
+  borderRadius: `${Math.max(props.borderRadius, 0)}px`,
+  color: props.fontColor,
+  fontSize: `${Math.max(props.fontSize, 0)}px`
+}))
+</script>
+
+<style scoped>
+.status-display {
+  display: flex;
+  width: 100%;
+  height: 100%;
+  padding: 0 10px;
+  overflow: hidden;
+  line-height: 1;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  box-sizing: border-box;
+  align-items: center;
+  justify-content: center;
+}
+</style>

+ 94 - 41
src/components/custom-components/text-vue/index.vue

@@ -1,52 +1,105 @@
 <template>
 <template>
-  <p
-    class="break-words w-1/1 h-1/1"
-    :class="props.vertical ? 'text-vertical' : ''"
+  <div
+    class="mt-text"
     :style="{
     :style="{
-      fontFamily: props.fontFamily,
-      fontSize: props.fontSize + 'px',
-      color: props.fill,
-      fontWeight: props.bold ? 'bold' : 'normal',
-      textAlign: props.textAlign
+      justifyContent: verticalAlignStyle
     }">
     }">
-    {{ props.text }}
-  </p>
+    <span
+      class="mt-text__content"
+      :class="{ 'mt-text__content--vertical': props.vertical }"
+      :style="{
+        fontFamily: props.fontFamily,
+        fontSize: `${props.fontSize}px`,
+        color: props.fill,
+        fontWeight: props.bold ? 'bold' : 'normal',
+        fontStyle: props.italic ? 'italic' : 'normal',
+        lineHeight: props.lineHeight,
+        textAlign: props.textAlign
+      }">
+      {{ displayText }}
+    </span>
+  </div>
 </template>
 </template>
 <script setup lang="ts">
 <script setup lang="ts">
-const props = defineProps({
-  fontFamily: {
-    type: String,
-    default: ''
-  },
-  fontSize: {
-    type: Number,
-    default: 15
-  },
-  text: {
-    type: String,
-    default: ''
-  },
-  fill: {
-    type: String,
-    default: ''
-  },
-  vertical: {
-    type: Boolean,
-    default: false
-  },
-  bold: {
-    type: Boolean,
-    default: false
-  },
-  textAlign: {
-    type: String,
-    default: 'left'
+import { computed } from 'vue'
+import { formatTextDisplayValue } from '@/components/mt-edit/utils/text-format'
+
+const props = withDefaults(
+  defineProps<{
+    fontFamily?: string
+    fontSize?: number
+    text?: string | number
+    fill?: string
+    vertical?: boolean
+    bold?: boolean
+    italic?: boolean
+    lineHeight?: number
+    textAlign?: string
+    verticalAlign?: 'top' | 'center' | 'bottom'
+    prefix?: string
+    suffix?: string
+    emptyText?: string
+    decimalPlaces?: number
+    showUnit?: boolean
+  }>(),
+  {
+    fontFamily: '',
+    fontSize: 15,
+    text: '',
+    fill: '',
+    vertical: false,
+    bold: false,
+    italic: false,
+    lineHeight: 1.5,
+    textAlign: 'left',
+    verticalAlign: 'top',
+    prefix: '',
+    suffix: '',
+    emptyText: '--',
+    decimalPlaces: 2,
+    showUnit: true
   }
   }
+)
+
+const displayText = computed(() =>
+  formatTextDisplayValue(props.text, {
+    prefix: props.prefix,
+    suffix: props.suffix,
+    emptyText: props.emptyText
+  })
+)
+const verticalAlignStyle = computed(() => {
+  if (props.verticalAlign === 'center') {
+    return 'center'
+  }
+  if (props.verticalAlign === 'bottom') {
+    return 'flex-end'
+  }
+  return 'flex-start'
 })
 })
 </script>
 </script>
 <style scoped>
 <style scoped>
-.text-vertical {
-  writing-mode: tb;
-  letter-spacing: 5px;
+.mt-text {
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: 100%;
+  min-width: 0;
+  min-height: 0;
+}
+
+.mt-text__content {
+  display: block;
+  width: 100%;
+  max-width: 100%;
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
+
+.mt-text__content--vertical {
+  width: auto;
+  max-height: 100%;
+  writing-mode: vertical-rl;
+  text-orientation: mixed;
 }
 }
 </style>
 </style>

+ 29 - 12
src/components/mt-dzr/index.vue

@@ -61,6 +61,8 @@ import RotateHandle from './components/rotate-handle.vue'
 import renderItem from './components/render-item.vue'
 import renderItem from './components/render-item.vue'
 import { dzrStore } from './store/index'
 import { dzrStore } from './store/index'
 import type { MouseTouchEvent } from './utils/types'
 import type { MouseTouchEvent } from './utils/types'
+import { completeModelMove } from './utils/move-completion'
+import { hasExceededDragThreshold, normalizeCoordinate } from './utils/drag-position'
 const MtDzrProps = withDefaults(defineProps<IDzrProps>(), {
 const MtDzrProps = withDefaults(defineProps<IDzrProps>(), {
   id: randomString(16),
   id: randomString(16),
   modelValue: () => {
   modelValue: () => {
@@ -164,14 +166,22 @@ const onMouseDown = (de: MouseTouchEvent) => {
   const max_top = clientHeight - MtDzrProps.modelValue.height
   const max_top = clientHeight - MtDzrProps.modelValue.height
   let set_new_left = init_x
   let set_new_left = init_x
   let set_new_top = init_y
   let set_new_top = init_y
+  let drag_started = false
   const onMouseMove = (me: MouseTouchEvent) => {
   const onMouseMove = (me: MouseTouchEvent) => {
-    dzrStore.showDzrCopy({ ...dzr_copy_info_value.value }, gen_id)
     const { clientX: me_client_x, clientY: me_client_y } = getXY(me)
     const { clientX: me_client_x, clientY: me_client_y } = getXY(me)
-    const move_x = (me_client_x - de_client_x) / MtDzrProps.scaleRatio
-    const move_y = (me_client_y - de_client_y) / MtDzrProps.scaleRatio
+    const screen_move_x = me_client_x - de_client_x
+    const screen_move_y = me_client_y - de_client_y
+    if (!drag_started && !hasExceededDragThreshold(screen_move_x, screen_move_y)) {
+      return
+    }
+    drag_started = true
+    dzrStore.showDzrCopy({ ...dzr_copy_info_value.value }, gen_id)
 
 
-    const new_left = alignToGrid(init_x + move_x, grid_align_size.value)
-    const new_top = alignToGrid(init_y + move_y, grid_align_size.value)
+    const move_x = screen_move_x / MtDzrProps.scaleRatio
+    const move_y = screen_move_y / MtDzrProps.scaleRatio
+
+    const new_left = normalizeCoordinate(alignToGrid(init_x + move_x, grid_align_size.value))
+    const new_top = normalizeCoordinate(alignToGrid(init_y + move_y, grid_align_size.value))
     set_new_left = new_left < min_left ? min_left : new_left > max_left ? max_left : new_left
     set_new_left = new_left < min_left ? min_left : new_left > max_left ? max_left : new_left
     set_new_top = new_top < min_top ? min_top : new_top > max_top ? max_top : new_top
     set_new_top = new_top < min_top ? min_top : new_top > max_top ? max_top : new_top
     drag_data_info.value = {
     drag_data_info.value = {
@@ -205,8 +215,8 @@ const onMouseDown = (de: MouseTouchEvent) => {
       if (adsorp_diff_x == 0 && adsorp_diff_y == 0) {
       if (adsorp_diff_x == 0 && adsorp_diff_y == 0) {
         return
         return
       }
       }
-      set_new_left += adsorp_diff_x
-      set_new_top += adsorp_diff_y
+      set_new_left = normalizeCoordinate(set_new_left + adsorp_diff_x)
+      set_new_top = normalizeCoordinate(set_new_top + adsorp_diff_y)
       drag_data_info.value = {
       drag_data_info.value = {
         ...drag_data_info.value,
         ...drag_data_info.value,
         left: set_new_left,
         left: set_new_left,
@@ -232,12 +242,19 @@ const onMouseDown = (de: MouseTouchEvent) => {
   autoDestroyMouseMove(onMouseMove, () => {
   autoDestroyMouseMove(onMouseMove, () => {
     nextTick(() => {
     nextTick(() => {
       dzrStore.hideDzrCopy()
       dzrStore.hideDzrCopy()
-      MtDzrEmits('moveMouseUp')
-      MtDzrEmits('update:modelValue', {
+      if (!drag_started) {
+        return
+      }
+      const finalPosition = {
         ...MtDzrProps.modelValue,
         ...MtDzrProps.modelValue,
-        left: set_new_left,
-        top: set_new_top
-      })
+        left: normalizeCoordinate(set_new_left),
+        top: normalizeCoordinate(set_new_top)
+      }
+      completeModelMove(
+        finalPosition,
+        (value) => MtDzrEmits('update:modelValue', value),
+        () => MtDzrEmits('moveMouseUp')
+      )
     })
     })
   })
   })
 }
 }

+ 16 - 0
src/components/mt-dzr/utils/drag-position.ts

@@ -0,0 +1,16 @@
+const DEFAULT_DRAG_THRESHOLD = 3
+const DEFAULT_COORDINATE_PRECISION = 2
+
+export const hasExceededDragThreshold = (
+  deltaX: number,
+  deltaY: number,
+  threshold = DEFAULT_DRAG_THRESHOLD
+) => Math.hypot(deltaX, deltaY) >= threshold
+
+export const normalizeCoordinate = (value: number, precision = DEFAULT_COORDINATE_PRECISION) => {
+  if (!Number.isFinite(value)) {
+    return value
+  }
+
+  return Number(value.toFixed(precision))
+}

+ 8 - 0
src/components/mt-dzr/utils/move-completion.ts

@@ -0,0 +1,8 @@
+export const completeModelMove = <T>(
+  value: T,
+  updateModel: (value: T) => void,
+  notifyMoveCompleted: () => void
+) => {
+  updateModel(value)
+  notifyMoveCompleted()
+}

+ 399 - 62
src/components/mt-edit/components/done-tree/index.vue

@@ -1,75 +1,170 @@
 <template>
 <template>
-  <el-tree
-    :data="doneTreeProps.doneJson"
-    :props="defaultProps"
-    @node-click="handleNodeClick"
-    :default-expand-all="true"
-    :expand-on-click-node="false"
-    :highlight-current="true"
-    node-key="id"
-    :current-node-key="current_node_key">
-    <template #default="{ node, data }">
-      <div class="flex justify-between w-9/10">
-        <div>{{ node.label }}</div>
-        <div class="flex items-center">
-          <el-button
-            text
-            circle
-            size="small"
-            :disabled="!canMoveUp(data)"
-            title="上移一层"
-            @click.stop="moveLayer(data, 'up')">
-            <el-icon :size="16">
-              <ArrowUp />
-            </el-icon>
-          </el-button>
-          <el-button
-            text
-            circle
-            size="small"
-            :disabled="!canMoveDown(data)"
-            title="下移一层"
-            @click.stop="moveLayer(data, 'down')">
-            <el-icon :size="16">
-              <ArrowDown />
-            </el-icon>
-          </el-button>
-          <el-button text circle size="small" class="mr-10px">
-            <el-icon :title="data.hide ? '隐藏' : '显示'" :size="20" @click.stop="changeHide(data)">
-              <svg-analysis :name="data.hide ? 'view-hide' : 'view-show'" />
-            </el-icon>
-          </el-button>
-        </div>
+  <div class="layer-panel">
+    <div class="layer-panel__overview">
+      <div class="layer-stat">
+        <strong>{{ doneTreeProps.doneJson.length }}</strong>
+        <span>全部组件</span>
       </div>
       </div>
-    </template>
-  </el-tree>
+      <div class="layer-stat layer-stat--hidden">
+        <strong>{{ hidden_count }}</strong>
+        <span>已隐藏</span>
+      </div>
+      <div class="layer-stat layer-stat--visible">
+        <strong>{{ visible_count }}</strong>
+        <span>画布可见</span>
+      </div>
+    </div>
+
+    <div class="layer-panel__toolbar">
+      <el-input v-model="search_keyword" clearable placeholder="搜索组件名称或类型">
+        <template #prefix>
+          <el-icon><Search /></el-icon>
+        </template>
+      </el-input>
+      <div class="layer-panel__batch-actions">
+        <el-button :disabled="hidden_count === 0" @click="setAllHidden(false)">
+          <el-icon><View /></el-icon>
+          全部显示
+        </el-button>
+        <el-button
+          :disabled="doneTreeProps.doneJson.length === 0 || visible_count === 0"
+          @click="setAllHidden(true)">
+          <el-icon><Hide /></el-icon>
+          全部隐藏
+        </el-button>
+      </div>
+    </div>
+
+    <div class="layer-panel__list-header">
+      <span>图层列表</span>
+      <span>{{ filtered_done_json.length }} 项</span>
+    </div>
+
+    <el-scrollbar class="layer-panel__scrollbar">
+      <el-tree
+        class="layer-tree"
+        :data="filtered_done_json"
+        :props="defaultProps"
+        :expand-on-click-node="false"
+        :highlight-current="true"
+        node-key="id"
+        empty-text="没有找到匹配的组件"
+        :current-node-key="current_node_key"
+        @node-click="handleNodeClick">
+        <template #default="{ node, data }">
+          <div class="layer-node" :class="{ 'layer-node--hidden': data.hide }">
+            <span class="layer-node__index">{{ getLayerIndex(data) + 1 }}</span>
+            <div class="layer-node__info">
+              <span class="layer-node__name" :title="node.label">{{ node.label }}</span>
+              <span class="layer-node__meta">
+                {{ getTypeLabel(data.type) }} · 第 {{ getLayerIndex(data) + 1 }} 层
+              </span>
+            </div>
+            <div class="layer-node__actions">
+              <el-button
+                text
+                circle
+                :disabled="!canMoveUp(data)"
+                title="上移一层"
+                @click.stop="moveLayer(data, 'up')">
+                <el-icon><ArrowUp /></el-icon>
+              </el-button>
+              <el-button
+                text
+                circle
+                :disabled="!canMoveDown(data)"
+                title="下移一层"
+                @click.stop="moveLayer(data, 'down')">
+                <el-icon><ArrowDown /></el-icon>
+              </el-button>
+              <el-button
+                text
+                circle
+                :class="{ 'layer-node__visibility--hidden': data.hide }"
+                :title="data.hide ? '显示组件' : '隐藏组件'"
+                @click.stop="changeHide(data)">
+                <el-icon>
+                  <svg-analysis :name="data.hide ? 'view-hide' : 'view-show'" />
+                </el-icon>
+              </el-button>
+            </div>
+          </div>
+        </template>
+        <template #empty>
+          <el-empty :image-size="72" :description="empty_description" />
+        </template>
+      </el-tree>
+    </el-scrollbar>
+
+    <div class="layer-panel__hint">点击图层可定位组件,右侧按钮用于调整层级和可见性</div>
+  </div>
 </template>
 </template>
 
 
 <script lang="ts" setup>
 <script lang="ts" setup>
-import { ElTree, ElButton, ElIcon } from 'element-plus'
-import { ArrowDown, ArrowUp } from '@element-plus/icons-vue'
-import { computed } from 'vue'
-import type { IDoneJson } from '@/components/mt-edit/store/types'
 import SvgAnalysis from '@/components/mt-edit/components/svg-analysis/index.vue'
 import SvgAnalysis from '@/components/mt-edit/components/svg-analysis/index.vue'
+import type { IDoneJson } from '@/components/mt-edit/store/types'
+import { ArrowDown, ArrowUp, Hide, Search, View } from '@element-plus/icons-vue'
+import { ElButton, ElEmpty, ElIcon, ElInput, ElScrollbar, ElTree } from 'element-plus'
+import { computed, ref } from 'vue'
+
 type DoneTree = {
 type DoneTree = {
   doneJson: IDoneJson[]
   doneJson: IDoneJson[]
-  selectedItemsId: string[] //已选中组件的id
+  selectedItemsId: string[]
 }
 }
-const doneTreeProps = withDefaults(defineProps<DoneTree>(), {})
-
-const emits = defineEmits(['updateSelectedItemsId', 'updateSelectedIdHide', 'moveLayer'])
 
 
+const doneTreeProps = withDefaults(defineProps<DoneTree>(), {})
+const emits = defineEmits([
+  'updateSelectedItemsId',
+  'updateSelectedIdHide',
+  'updateAllHide',
+  'moveLayer'
+])
+const search_keyword = ref('')
+const hidden_count = computed(() => doneTreeProps.doneJson.filter((item) => item.hide).length)
+const visible_count = computed(() => doneTreeProps.doneJson.length - hidden_count.value)
+const filtered_done_json = computed(() => {
+  const keyword = search_keyword.value.trim().toLocaleLowerCase()
+  if (!keyword) {
+    return doneTreeProps.doneJson
+  }
+  return doneTreeProps.doneJson.filter((item) => {
+    return (
+      item.title.toLocaleLowerCase().includes(keyword) ||
+      getTypeLabel(item.type).toLocaleLowerCase().includes(keyword)
+    )
+  })
+})
+const empty_description = computed(() =>
+  search_keyword.value.trim() ? '没有找到匹配的组件' : '画布中还没有组件'
+)
 const current_node_key = computed(() => {
 const current_node_key = computed(() => {
-  return doneTreeProps.selectedItemsId.length == 1 ? doneTreeProps.selectedItemsId[0] : ''
+  return doneTreeProps.selectedItemsId.length === 1 ? doneTreeProps.selectedItemsId[0] : ''
 })
 })
+const typeLabelMap: Record<IDoneJson['type'], string> = {
+  svg: '矢量组件',
+  vue: '交互组件',
+  img: '图片',
+  'custom-svg': '自定义图形',
+  group: '组合',
+  'sys-line': '连线'
+}
+const defaultProps = {
+  children: 'nochildren',
+  label: 'title'
+}
+
+const getTypeLabel = (type: IDoneJson['type']) => typeLabelMap[type] || '组件'
+const getLayerIndex = (data: IDoneJson) => {
+  return doneTreeProps.doneJson.findIndex((item) => item.id === data.id)
+}
 const handleNodeClick = (data: IDoneJson) => {
 const handleNodeClick = (data: IDoneJson) => {
   emits('updateSelectedItemsId', data.id)
   emits('updateSelectedItemsId', data.id)
 }
 }
 const changeHide = (data: IDoneJson) => {
 const changeHide = (data: IDoneJson) => {
   emits('updateSelectedIdHide', data.id)
   emits('updateSelectedIdHide', data.id)
 }
 }
-const getLayerIndex = (data: IDoneJson) => {
-  return doneTreeProps.doneJson.findIndex((item) => item.id === data.id)
+const setAllHidden = (hidden: boolean) => {
+  emits('updateAllHide', hidden)
 }
 }
 const canMoveUp = (data: IDoneJson) => {
 const canMoveUp = (data: IDoneJson) => {
   const index = getLayerIndex(data)
   const index = getLayerIndex(data)
@@ -79,13 +174,255 @@ const canMoveDown = (data: IDoneJson) => {
   return getLayerIndex(data) > 0
   return getLayerIndex(data) > 0
 }
 }
 const moveLayer = (data: IDoneJson, direction: 'up' | 'down') => {
 const moveLayer = (data: IDoneJson, direction: 'up' | 'down') => {
-  emits('moveLayer', {
-    id: data.id,
-    direction
-  })
-}
-const defaultProps = {
-  children: 'nochildren',
-  label: 'title'
+  emits('moveLayer', { id: data.id, direction })
 }
 }
 </script>
 </script>
+
+<style scoped>
+.layer-panel {
+  display: flex;
+  height: 100%;
+  min-height: 0;
+  padding: 16px 18px 12px;
+  background: #f7f9fc;
+  box-sizing: border-box;
+  flex-direction: column;
+}
+
+.layer-panel__overview {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 9px;
+  margin-bottom: 14px;
+}
+
+.layer-stat {
+  display: flex;
+  min-width: 0;
+  padding: 10px 12px;
+  background: #fff;
+  border: 1px solid #e2e7ef;
+  border-radius: 10px;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.layer-stat strong {
+  font-size: 18px;
+  line-height: 22px;
+  color: #344054;
+}
+
+.layer-stat span {
+  overflow: hidden;
+  font-size: 11px;
+  line-height: 16px;
+  color: #98a2b3;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.layer-stat--hidden strong {
+  color: #f59e0b;
+}
+
+.layer-stat--visible strong {
+  color: var(--el-color-primary);
+}
+
+.layer-panel__toolbar {
+  display: flex;
+  gap: 10px;
+  margin-bottom: 14px;
+}
+
+.layer-panel__toolbar :deep(.el-input) {
+  flex: 1;
+}
+
+.layer-panel__toolbar :deep(.el-input__wrapper) {
+  border-radius: 9px;
+  box-shadow: 0 0 0 1px #dbe1ea inset;
+}
+
+.layer-panel__batch-actions {
+  display: flex;
+  gap: 7px;
+}
+
+.layer-panel__batch-actions :deep(.el-button) {
+  height: 32px;
+  padding: 0 10px;
+  margin: 0;
+  border-radius: 8px;
+}
+
+.layer-panel__batch-actions :deep(.el-button > span) {
+  gap: 5px;
+}
+
+.layer-panel__list-header {
+  display: flex;
+  padding: 0 10px 8px;
+  font-size: 12px;
+  font-weight: 600;
+  line-height: 18px;
+  color: #667085;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.layer-panel__scrollbar {
+  min-height: 0;
+  flex: 1;
+}
+
+.layer-tree {
+  background: transparent;
+}
+
+.layer-tree :deep(.el-tree-node) {
+  margin-bottom: 4px;
+}
+
+.layer-tree :deep(.el-tree-node__content) {
+  height: 50px;
+  padding: 0 8px !important;
+  background: #fff;
+  border: 1px solid #e5e9f0;
+  border-radius: 9px;
+  transition:
+    background 0.18s ease,
+    border-color 0.18s ease,
+    box-shadow 0.18s ease;
+}
+
+.layer-tree :deep(.el-tree-node__content:hover) {
+  background: #f8fbff;
+  border-color: var(--el-color-primary-light-7);
+}
+
+.layer-tree :deep(.el-tree-node.is-current > .el-tree-node__content) {
+  background: var(--el-color-primary-light-9);
+  border-color: var(--el-color-primary-light-5);
+  box-shadow: 0 3px 10px rgb(64 158 255 / 10%);
+}
+
+.layer-node {
+  display: flex;
+  width: 100%;
+  min-width: 0;
+  align-items: center;
+}
+
+.layer-node__index {
+  display: inline-flex;
+  width: 26px;
+  height: 26px;
+  font-size: 11px;
+  font-weight: 600;
+  color: #667085;
+  background: #f1f4f8;
+  border-radius: 7px;
+  flex: 0 0 26px;
+  align-items: center;
+  justify-content: center;
+}
+
+.layer-node__info {
+  display: flex;
+  min-width: 0;
+  padding-left: 10px;
+  flex: 1;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.layer-node__name {
+  overflow: hidden;
+  font-size: 13px;
+  font-weight: 600;
+  line-height: 17px;
+  color: #344054;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.layer-node__meta {
+  font-size: 11px;
+  line-height: 15px;
+  color: #98a2b3;
+}
+
+.layer-node__actions {
+  display: flex;
+  padding-left: 8px;
+  flex: 0 0 auto;
+  gap: 2px;
+}
+
+.layer-node__actions :deep(.el-button) {
+  width: 28px;
+  height: 28px;
+  margin: 0;
+  color: #667085;
+}
+
+.layer-node__actions :deep(.el-button:hover) {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+}
+
+.layer-node__actions :deep(.layer-node__visibility--hidden) {
+  color: #f59e0b;
+  background: #fff7e6;
+}
+
+.layer-node--hidden .layer-node__name {
+  color: #98a2b3;
+  text-decoration: line-through;
+}
+
+.layer-panel__hint {
+  padding-top: 10px;
+  margin-top: 8px;
+  font-size: 11px;
+  line-height: 16px;
+  color: #98a2b3;
+  text-align: center;
+  border-top: 1px solid #e1e6ed;
+}
+
+.dark .layer-panel {
+  background: #181f2b;
+}
+
+.dark .layer-stat,
+.dark .layer-tree :deep(.el-tree-node__content) {
+  background: #202735;
+  border-color: #303949;
+}
+
+.dark .layer-stat strong,
+.dark .layer-node__name {
+  color: #dbe3ed;
+}
+
+.dark .layer-node__index {
+  color: #aeb8c7;
+  background: #2b3444;
+}
+
+.dark .layer-tree :deep(.el-tree-node__content:hover) {
+  background: #252e3d;
+}
+
+.dark .layer-tree :deep(.el-tree-node.is-current > .el-tree-node__content) {
+  background: rgb(64 158 255 / 14%);
+  border-color: var(--el-color-primary-dark-2);
+}
+
+.dark .layer-panel__hint {
+  border-color: #303949;
+}
+</style>

+ 32 - 16
src/components/mt-edit/components/draw-line-render/index.vue

@@ -93,6 +93,10 @@
 import type { MouseTouchEvent } from '@/components/mt-dzr/utils/types'
 import type { MouseTouchEvent } from '@/components/mt-dzr/utils/types'
 import type { IDoneJson, IGlobalStoreCanvasCfg, IGlobalStoreGridCfg } from '../../store/types'
 import type { IDoneJson, IGlobalStoreCanvasCfg, IGlobalStoreGridCfg } from '../../store/types'
 import { alignToGrid, isSystemShortcutKey, positionArrarToPath } from '@/components/mt-edit/utils'
 import { alignToGrid, isSystemShortcutKey, positionArrarToPath } from '@/components/mt-edit/utils'
+import {
+  hasLineDragStarted,
+  normalizeLineGeometry
+} from '@/components/mt-edit/utils/line-interaction'
 import { computed } from 'vue'
 import { computed } from 'vue'
 import { configStore } from '../../store/config'
 import { configStore } from '../../store/config'
 type LineRenderProps = {
 type LineRenderProps = {
@@ -144,10 +148,15 @@ const onMouseDown = (de: MouseTouchEvent, point_index: number, item: { x: number
   const d_y = de instanceof MouseEvent ? de.clientY : de.touches[0].pageY
   const d_y = de instanceof MouseEvent ? de.clientY : de.touches[0].pageY
   let new_x = 0
   let new_x = 0
   let new_y = 0
   let new_y = 0
+  let has_dragged = false
   const onMouseMove = (e: MouseTouchEvent) => {
   const onMouseMove = (e: MouseTouchEvent) => {
     // 记录鼠标移动的位置
     // 记录鼠标移动的位置
     const m_x = e instanceof MouseEvent ? e.clientX : e.touches[0].pageX
     const m_x = e instanceof MouseEvent ? e.clientX : e.touches[0].pageX
     const m_y = e instanceof MouseEvent ? e.clientY : e.touches[0].pageY
     const m_y = e instanceof MouseEvent ? e.clientY : e.touches[0].pageY
+    if (!hasLineDragStarted(m_x - d_x, m_y - d_y)) {
+      return
+    }
+    has_dragged = true
     // 移动的距离
     // 移动的距离
     const move_x = isSystemShortcutKey(de)
     const move_x = isSystemShortcutKey(de)
       ? 0
       ? 0
@@ -174,37 +183,44 @@ const onMouseDown = (de: MouseTouchEvent, point_index: number, item: { x: number
     document.removeEventListener('mouseup', onMouseUp)
     document.removeEventListener('mouseup', onMouseUp)
     document.removeEventListener('touchmove', onMouseMove)
     document.removeEventListener('touchmove', onMouseMove)
     document.removeEventListener('touchend', onMouseUp)
     document.removeEventListener('touchend', onMouseUp)
-    const itemRect = document
-      .querySelector(`#${lineRenderProps.itemJson.id} g .real`)!
-      .getBoundingClientRect()
-    const canvas_area_bounding_info = lineRenderProps.canvasDom!.getBoundingClientRect()
+    if (!has_dragged) {
+      return
+    }
+    const line_element = document.querySelector(`#${lineRenderProps.itemJson.id} g .real`)
+    if (!line_element || !lineRenderProps.canvasDom) {
+      return
+    }
+    const itemRect = line_element.getBoundingClientRect()
+    const canvas_area_bounding_info = lineRenderProps.canvasDom.getBoundingClientRect()
     const new_left =
     const new_left =
       (itemRect?.left - canvas_area_bounding_info?.left) / lineRenderProps.canvasCfg.scale
       (itemRect?.left - canvas_area_bounding_info?.left) / lineRenderProps.canvasCfg.scale
     const new_top =
     const new_top =
       (itemRect?.top - canvas_area_bounding_info?.top) / lineRenderProps.canvasCfg.scale
       (itemRect?.top - canvas_area_bounding_info?.top) / lineRenderProps.canvasCfg.scale
     const move_x = new_left - lineRenderProps.itemJson.binfo.left
     const move_x = new_left - lineRenderProps.itemJson.binfo.left
     const move_y = new_top - lineRenderProps.itemJson.binfo.top
     const move_y = new_top - lineRenderProps.itemJson.binfo.top
+    const normalized_geometry = normalizeLineGeometry(
+      {
+        left: new_left,
+        top: new_top,
+        width: itemRect.width / lineRenderProps.canvasCfg.scale,
+        height: itemRect.height / lineRenderProps.canvasCfg.scale
+      },
+      lineRenderProps.itemJson.props.point_position.val.map((m: { x: number; y: number }) => ({
+        x: m.x - move_x,
+        y: m.y - move_y
+      }))
+    )
     const new_item_json = {
     const new_item_json = {
       ...lineRenderProps.itemJson,
       ...lineRenderProps.itemJson,
       binfo: {
       binfo: {
         ...lineRenderProps.itemJson.binfo,
         ...lineRenderProps.itemJson.binfo,
-        left: new_left,
-        top: new_top,
-        width: itemRect?.width / lineRenderProps.canvasCfg.scale,
-        height: itemRect?.height / lineRenderProps.canvasCfg.scale
+        ...normalized_geometry.binfo
       },
       },
       props: {
       props: {
         ...lineRenderProps.itemJson.props,
         ...lineRenderProps.itemJson.props,
         point_position: {
         point_position: {
           ...lineRenderProps.itemJson.props.point_position,
           ...lineRenderProps.itemJson.props.point_position,
-          val: lineRenderProps.itemJson.props.point_position.val.map(
-            (m: { x: number; y: number }) => {
-              return {
-                x: m.x - move_x,
-                y: m.y - move_y
-              }
-            }
-          )
+          val: normalized_geometry.points
         }
         }
       }
       }
     }
     }

+ 5 - 1
src/components/mt-edit/components/group-render/index.vue

@@ -17,6 +17,7 @@
         :grid="groupRender.grid"
         :grid="groupRender.grid"
         :canvas-cfg="groupRender.canvasCfg"
         :canvas-cfg="groupRender.canvasCfg"
         :canvas-dom="groupRender.canvasDom"
         :canvas-dom="groupRender.canvasDom"
+        :preview-mode="groupRender.previewMode"
         :lock-state="false" />
         :lock-state="false" />
     </div>
     </div>
   </div>
   </div>
@@ -33,8 +34,11 @@ type GroupRender = {
   grid: IGlobalStoreGridCfg
   grid: IGlobalStoreGridCfg
   canvasCfg: IGlobalStoreCanvasCfg
   canvasCfg: IGlobalStoreCanvasCfg
   canvasDom: HTMLElement | null
   canvasDom: HTMLElement | null
+  previewMode?: boolean
 }
 }
-const groupRender = withDefaults(defineProps<GroupRender>(), {})
+const groupRender = withDefaults(defineProps<GroupRender>(), {
+  previewMode: false
+})
 </script>
 </script>
 <style scoped>
 <style scoped>
 .mt-group {
 .mt-group {

+ 687 - 283
src/components/mt-edit/components/layout/header-panel/index.vue

@@ -1,277 +1,243 @@
 <template>
 <template>
-  <div class="flex justify-between" style="width: 100%">
-    <div class="flex items-center justify-between w-200px">
-      <div class="editor-brand">
-        <el-image
-          class="editor-brand__logo"
-          :src="deepOilLogo" />
-        <el-text
-          class="editor-brand__title animate__animated animate__bounceIn animate__slow animate__1 animate__delay-0s"
-          >DeepOil</el-text
-        >
+  <div class="editor-header">
+    <section class="editor-header__brand">
+      <el-image class="editor-brand__logo" :src="deepOilLogo" />
+      <div class="editor-brand__text">
+        <span class="editor-brand__name">拓扑设计</span>
+        <span class="editor-brand__project">{{ headerPanelProps.title }}</span>
       </div>
       </div>
-      <el-button
-        text
-        circle
-        size="small"
+      <tool-button
+        :label="headerPanelProps.leftAside ? '收起' : '展开'"
         @click="emits('update:leftAside', !headerPanelProps.leftAside)">
         @click="emits('update:leftAside', !headerPanelProps.leftAside)">
-        <el-icon :size="20">
-          <svg-analysis v-if="headerPanelProps.leftAside" name="menu-fold" />
-          <svg-analysis v-else name="menu-unfold" />
-        </el-icon>
-      </el-button>
-    </div>
-    <div class="flex justify-between" style="width: calc(100% - 440px)">
-      <div class="flex items-center">
-        <el-button-group>
-          <el-button
-            text
-            circle
-            size="small"
-            :disabled="!headerPanelProps.undoEnabled"
-            @click="emits('onUndoClick')">
-            <el-icon :title="`撤销 ${systemShortcutKey}+Z`" :size="20">
-              <svg-analysis name="undo" />
-            </el-icon>
-          </el-button>
-          <el-button
-            text
-            circle
-            size="small"
-            :disabled="!headerPanelProps.redoEnabled"
-            @click="emits('onRedoClick')">
-            <el-icon :title="`重做 ${systemShortcutKey}+Y`" :size="20">
-              <svg-analysis name="redo" />
-            </el-icon>
-          </el-button>
-        </el-button-group>
-        <el-divider direction="vertical" />
-        <el-button
-          text
-          circle
-          size="small"
+        <svg-analysis :name="headerPanelProps.leftAside ? 'menu-fold' : 'menu-unfold'" />
+      </tool-button>
+    </section>
+
+    <div class="editor-header__toolbar">
+      <div class="tool-group">
+        <tool-button
+          label="撤销"
+          :disabled="!headerPanelProps.undoEnabled"
+          @click="emits('onUndoClick')">
+          <svg-analysis name="undo" />
+        </tool-button>
+        <tool-button
+          label="重做"
+          :disabled="!headerPanelProps.redoEnabled"
+          @click="emits('onRedoClick')">
+          <svg-analysis name="redo" />
+        </tool-button>
+        <tool-button
+          label="删除"
           :disabled="!headerPanelProps.deleteEnabled"
           :disabled="!headerPanelProps.deleteEnabled"
+          danger
           @click="onDeleteClick">
           @click="onDeleteClick">
-          <el-icon title="删除 delete" :class="``" :size="20">
-            <svg-analysis name="delete" />
-          </el-icon>
-        </el-button>
-        <el-divider direction="vertical" />
-        <el-button text circle size="small" @click="onTreeClick">
-          <el-icon title="组件树" :size="20">
-            <svg-analysis name="tree-list" />
-          </el-icon>
-        </el-button>
-        <el-divider direction="vertical" />
-        <el-button-group>
-          <el-button text circle size="small" @click="onImportClick">
-            <el-icon title="导入数据模型" :size="20">
-              <svg-analysis name="import-json" />
-            </el-icon>
-          </el-button>
-          <el-button text circle size="small" @click="onExportClick">
-            <el-icon title="导出数据模型" :size="20">
-              <svg-analysis name="export-json" />
-            </el-icon> </el-button
-        ></el-button-group>
-        <el-divider direction="vertical" />
+          <svg-analysis name="delete" />
+        </tool-button>
+      </div>
+
+      <div class="tool-group">
+        <tool-button label="组件树" @click="onTreeClick">
+          <svg-analysis name="tree-list" />
+        </tool-button>
+        <tool-button label="导入" @click="onImportClick">
+          <svg-analysis name="import-json" />
+        </tool-button>
+        <tool-button label="导出" @click="onExportClick">
+          <svg-analysis name="export-json" />
+        </tool-button>
+      </div>
+
+      <div class="tool-group">
+        <el-popover
+          v-model:visible="layerPopoverVisible"
+          placement="bottom"
+          :width="320"
+          trigger="click"
+          :disabled="!headerPanelProps.layerEnabled">
+          <template #reference>
+            <tool-button label="层级" :disabled="!headerPanelProps.layerEnabled">
+              <Files />
+            </tool-button>
+          </template>
+          <div class="layer-control-panel">
+            <div class="layer-control-panel__heading">
+              <strong>调整层级</strong>
+              <span>控制选中组件在画布中的前后顺序</span>
+            </div>
+            <div class="layer-control-panel__actions">
+              <tool-button
+                label="置顶"
+                compact
+                :disabled="!headerPanelProps.canMoveLayerUp"
+                @click="changeLayer('top')">
+                <Top />
+              </tool-button>
+              <tool-button
+                label="置底"
+                compact
+                :disabled="!headerPanelProps.canMoveLayerDown"
+                @click="changeLayer('bottom')">
+                <Bottom />
+              </tool-button>
+            </div>
+            <div class="layer-control-panel__hint">
+              快捷键:{{ systemShortcutKey }} + → 置顶,{{ systemShortcutKey }} + ← 置底
+            </div>
+          </div>
+        </el-popover>
         <el-popover
         <el-popover
           placement="bottom"
           placement="bottom"
-          :width="240"
-          trigger="hover"
+          :width="460"
+          trigger="click"
           :disabled="!headerPanelProps.alignEnabled">
           :disabled="!headerPanelProps.alignEnabled">
           <template #reference>
           <template #reference>
-            <el-button text circle size="small" :disabled="!headerPanelProps.alignEnabled">
-              <el-icon title="对齐" :size="20">
-                <svg-analysis name="align" />
-              </el-icon>
-            </el-button>
+            <tool-button label="对齐" :disabled="!headerPanelProps.alignEnabled">
+              <svg-analysis name="align" />
+            </tool-button>
           </template>
           </template>
-          <div class="flex justify-center">
-            <el-button-group>
-              <el-button text circle size="small" @click="alignSelected('left')">
-                <el-icon title="左对齐" :size="20">
-                  <svg-analysis name="align-left" />
-                </el-icon>
-              </el-button>
-              <el-button text circle size="small" @click="alignSelected('horizontally')">
-                <el-icon title="水平居中" :size="20">
-                  <svg-analysis name="align-horizontally" />
-                </el-icon>
-              </el-button>
-              <el-button text circle size="small" @click="alignSelected('right')">
-                <el-icon title="右对齐" :size="20">
-                  <svg-analysis name="align-right" />
-                </el-icon>
-              </el-button>
-              <el-button text circle size="small" @click="alignSelected('top')">
-                <el-icon title="上对齐" :size="20">
-                  <svg-analysis name="align-top" />
-                </el-icon>
-              </el-button>
-              <el-button text circle size="small" @click="alignSelected('vertically')">
-                <el-icon title="垂直居中" :size="20">
-                  <svg-analysis name="align-vertical" />
-                </el-icon>
-              </el-button>
-              <el-button text circle size="small" @click="alignSelected('bottom')">
-                <el-icon title="下对齐" :size="20">
-                  <svg-analysis name="align-bottom" />
-                </el-icon>
-              </el-button>
-              <el-button text circle size="small" @click="alignSelected('horizontal-distribution')">
-                <el-icon title="水平分布" :size="20">
-                  <svg-analysis name="horizontal-distribution" />
-                </el-icon>
-              </el-button>
-              <el-button text circle size="small" @click="alignSelected('vertical-distribution')">
-                <el-icon title="垂直分布" :size="20">
-                  <svg-analysis name="vertical-distribution" />
-                </el-icon>
-              </el-button>
-            </el-button-group>
+          <div class="align-panel">
+            <div class="align-panel__title">对齐与分布</div>
+            <div class="align-panel__actions">
+              <tool-button label="左对齐" compact @click="alignSelected('left')">
+                <svg-analysis name="align-left" />
+              </tool-button>
+              <tool-button label="水平居中" compact @click="alignSelected('horizontally')">
+                <svg-analysis name="align-horizontally" />
+              </tool-button>
+              <tool-button label="右对齐" compact @click="alignSelected('right')">
+                <svg-analysis name="align-right" />
+              </tool-button>
+              <tool-button label="顶部" compact @click="alignSelected('top')">
+                <svg-analysis name="align-top" />
+              </tool-button>
+              <tool-button label="垂直居中" compact @click="alignSelected('vertically')">
+                <svg-analysis name="align-vertical" />
+              </tool-button>
+              <tool-button label="底部" compact @click="alignSelected('bottom')">
+                <svg-analysis name="align-bottom" />
+              </tool-button>
+              <tool-button
+                label="水平分布"
+                compact
+                @click="alignSelected('horizontal-distribution')">
+                <svg-analysis name="horizontal-distribution" />
+              </tool-button>
+              <tool-button label="垂直分布" compact @click="alignSelected('vertical-distribution')">
+                <svg-analysis name="vertical-distribution" />
+              </tool-button>
+            </div>
           </div>
           </div>
         </el-popover>
         </el-popover>
-        <el-divider direction="vertical" />
-        <el-button-group>
-          <el-button
-            text
-            circle
-            size="small"
-            :disabled="!headerPanelProps.groupEnabled"
-            @click="onGroupClick">
-            <el-icon title="组合" :size="20">
-              <svg-analysis name="group" />
-            </el-icon>
-          </el-button>
-          <el-button
-            text
-            circle
-            size="small"
-            :disabled="!headerPanelProps.unGroupEnabled"
-            @click="onUngroupClick">
-            <el-icon title="取消组合" :size="20">
-              <svg-analysis name="ungroup" />
-            </el-icon>
-          </el-button>
-        </el-button-group>
-        <el-divider direction="vertical" v-if="!is_npm_env" />
-        <el-button text circle size="small" @click="onDrawLineClick" v-if="!is_npm_env">
-          <el-icon
-            title="连线编辑模式"
-            :size="20"
-            :class="drawline_selected ? 'icon-selected' : ''">
-            <svg-analysis name="pen-line" />
-          </el-icon>
-        </el-button>
+        <tool-button label="组合" :disabled="!headerPanelProps.groupEnabled" @click="onGroupClick">
+          <svg-analysis name="group" />
+        </tool-button>
+        <tool-button
+          label="解组"
+          :disabled="!headerPanelProps.unGroupEnabled"
+          @click="onUngroupClick">
+          <svg-analysis name="ungroup" />
+        </tool-button>
+        <tool-button
+          v-if="!is_npm_env"
+          label="连线"
+          :active="drawline_selected"
+          @click="onDrawLineClick">
+          <svg-analysis name="pen-line" />
+        </tool-button>
       </div>
       </div>
-      <div class="flex justify-center items-center">
-        <el-tag v-if="headerPanelProps.realTimeData.show" size="small">{{
-          headerPanelProps.realTimeData.text
-        }}</el-tag>
+    </div>
+
+    <section class="editor-header__actions">
+      <el-tag
+        v-if="headerPanelProps.realTimeData.show"
+        class="coordinate-status"
+        size="small"
+        effect="plain">
+        {{ headerPanelProps.realTimeData.text }}
+      </el-tag>
+      <div v-if="headerPanelProps.selectedItemsId.length" class="selection-status">
+        已选 {{ headerPanelProps.selectedItemsId.length }} 项
       </div>
       </div>
-      <div class="flex items-center mr-20px">
-        <el-button text circle size="small" @click="emits('onReturnClick')">
-          <el-icon title="返回" :size="20">
-            <svg-analysis name="return" />
-          </el-icon>
-        </el-button>
-        <el-divider direction="vertical" />
-        <el-button
-          text
-          circle
-          size="small"
-          :loading="headerPanelProps.saveLoading"
-          :disabled="headerPanelProps.saveLoading"
-          @click="emits('onSaveClick')">
-          <el-icon title="保存" :size="20">
-            <svg-analysis name="save" />
-          </el-icon>
-        </el-button>
-        <el-divider v-if="headerPanelProps.useThumbnail" direction="vertical" />
-        <el-button
-          v-if="headerPanelProps.useThumbnail"
-          text
-          circle
-          size="small"
-          @click="emits('onThumbnailClick')">
-          <el-icon title="生成缩略图" :size="20">
-            <svg-analysis name="thumbnail" />
-          </el-icon>
-        </el-button>
-        <el-divider direction="vertical" />
-        <el-button text circle size="small" @click="emits('onPreviewClick')">
-          <el-icon title="预览" :size="20">
-            <svg-analysis name="preview" />
-          </el-icon>
-        </el-button>
+      <div
+        class="save-status"
+        :class="{ 'save-status--unsaved': headerPanelProps.unsaved }"
+        role="status">
+        <span class="save-status__dot"></span>
+        {{ headerPanelProps.unsaved ? '未保存' : '已保存' }}
       </div>
       </div>
-    </div>
-    <div class="flex items-center justify-between w-200px">
+      <tool-button label="返回" @click="emits('onReturnClick')">
+        <svg-analysis name="return" />
+      </tool-button>
+      <tool-button
+        v-if="headerPanelProps.useThumbnail"
+        label="缩略图"
+        @click="emits('onThumbnailClick')">
+        <svg-analysis name="thumbnail" />
+      </tool-button>
+      <tool-button label="预览" @click="emits('onPreviewClick')">
+        <svg-analysis name="preview" />
+      </tool-button>
       <el-button
       <el-button
-        text
-        circle
-        size="small"
-        @click="emits('update:rightAside', !headerPanelProps.rightAside)">
-        <el-icon :size="20" style="cursor: pointer">
-          <svg-analysis v-if="headerPanelProps.rightAside" name="menu-unfold" />
-          <svg-analysis v-else name="menu-fold" />
-        </el-icon>
+        class="save-button"
+        type="primary"
+        :loading="headerPanelProps.saveLoading"
+        :disabled="headerPanelProps.saveLoading"
+        :title="`保存(${systemShortcutKey} + S)`"
+        @click="emits('onSaveClick')">
+        <el-icon v-if="!headerPanelProps.saveLoading"><svg-analysis name="save" /></el-icon>
+        {{ headerPanelProps.saveLoading ? '保存中' : '保存' }}
       </el-button>
       </el-button>
-      <div class="flex items-center">
-        <el-button text circle size="small" @click="onHelpClick">
-          <el-icon title="帮助" :size="20">
-            <svg-analysis name="help" />
+
+      <div class="utility-actions">
+        <el-button text circle @click="emits('update:rightAside', !headerPanelProps.rightAside)">
+          <el-icon>
+            <svg-analysis :name="headerPanelProps.rightAside ? 'menu-unfold' : 'menu-fold'" />
           </el-icon>
           </el-icon>
         </el-button>
         </el-button>
-        <el-button text circle size="small" @click="toggle">
-          <el-icon title="全屏" :size="20">
+        <el-button text circle @click="toggle">
+          <el-icon>
             <svg-analysis :name="isFullscreen ? 'exit-full-screen' : 'full-screen'" />
             <svg-analysis :name="isFullscreen ? 'exit-full-screen' : 'full-screen'" />
           </el-icon>
           </el-icon>
         </el-button>
         </el-button>
-        <el-divider direction="vertical" />
-        <el-button text circle size="small" @click="changeLockState">
-          <el-icon :title="headerPanelProps.lockState ? '已锁定' : '已解锁'" :size="20">
+        <el-button
+          text
+          circle
+          :class="{ 'utility-actions__active': headerPanelProps.lockState }"
+          @click="changeLockState">
+          <el-icon>
             <svg-analysis :name="headerPanelProps.lockState ? 'lock' : 'unlock'" />
             <svg-analysis :name="headerPanelProps.lockState ? 'lock' : 'unlock'" />
           </el-icon>
           </el-icon>
         </el-button>
         </el-button>
-        <el-divider direction="vertical" />
-        <el-button text circle size="small" class="mr-10px">
-          <el-icon
-            :title="isDark ? '切换到日间模式' : '切换到夜间模式'"
-            :size="20"
-            @click="toggleDark()">
-            <svg-analysis :name="isDark ? 'light' : 'dark'" />
-          </el-icon>
+        <el-button text circle @click="toggleDark()">
+          <el-icon><svg-analysis :name="isDark ? 'light' : 'dark'" /></el-icon>
         </el-button>
         </el-button>
       </div>
       </div>
-    </div>
+    </section>
   </div>
   </div>
 </template>
 </template>
+
 <script setup lang="ts">
 <script setup lang="ts">
-import { useDark, useToggle, useFullscreen } from '@vueuse/core'
-import {
-  ElIcon,
-  ElDivider,
-  ElPopover,
-  ElButton,
-  ElButtonGroup,
-  ElImage,
-  ElText,
-  ElTag
-} from 'element-plus'
+import deepOilLogo from '@/assets/imgs/logo.png'
 import SvgAnalysis from '@/components/mt-edit/components/svg-analysis/index.vue'
 import SvgAnalysis from '@/components/mt-edit/components/svg-analysis/index.vue'
 import type { IRealTimeData } from '@/components/mt-edit/store/types'
 import type { IRealTimeData } from '@/components/mt-edit/store/types'
-import deepOilLogo from '@/assets/imgs/logo.png'
 import { getSystemShortcutKeyName } from '@/components/mt-edit/utils'
 import { getSystemShortcutKeyName } from '@/components/mt-edit/utils'
-import { ref } from 'vue'
+import { Bottom, Files, Top } from '@element-plus/icons-vue'
+import { useDark, useFullscreen, useToggle } from '@vueuse/core'
+import { ElButton, ElIcon, ElImage, ElPopover, ElTag } from 'element-plus'
+import { defineComponent, h, ref } from 'vue'
+
 type HeaderPanelProps = {
 type HeaderPanelProps = {
+  title?: string
   leftAside: boolean
   leftAside: boolean
   rightAside: boolean
   rightAside: boolean
-  selectedItemsId: string[] //已选中组件的id
+  selectedItemsId: string[]
   groupEnabled: boolean
   groupEnabled: boolean
   unGroupEnabled: boolean
   unGroupEnabled: boolean
   alignEnabled: boolean
   alignEnabled: boolean
+  layerEnabled: boolean
+  canMoveLayerUp: boolean
+  canMoveLayerDown: boolean
   deleteEnabled: boolean
   deleteEnabled: boolean
   lockState: boolean
   lockState: boolean
   undoEnabled: boolean
   undoEnabled: boolean
@@ -279,12 +245,57 @@ type HeaderPanelProps = {
   realTimeData: IRealTimeData
   realTimeData: IRealTimeData
   useThumbnail?: boolean
   useThumbnail?: boolean
   saveLoading?: boolean
   saveLoading?: boolean
+  unsaved?: boolean
 }
 }
+
+const ToolButton = defineComponent({
+  name: 'ToolButton',
+  props: {
+    label: { type: String, required: true },
+    disabled: Boolean,
+    active: Boolean,
+    danger: Boolean,
+    compact: Boolean
+  },
+  emits: ['click'],
+  setup(props, { slots, emit }) {
+    return () =>
+      h(
+        'span',
+        { class: 'tool-button-wrap' },
+        h(
+          ElButton,
+          {
+            text: true,
+            disabled: props.disabled,
+            class: [
+              'tool-button',
+              {
+                'tool-button--active': props.active,
+                'tool-button--danger': props.danger,
+                'tool-button--compact': props.compact
+              }
+            ],
+            onClick: () => emit('click')
+          },
+          {
+            default: () => [
+              h(ElIcon, { size: props.compact ? 18 : 20 }, slots.default),
+              h('span', { class: 'tool-button__label' }, props.label)
+            ]
+          }
+        )
+      )
+  }
+})
+
 const headerPanelProps = withDefaults(defineProps<HeaderPanelProps>(), {
 const headerPanelProps = withDefaults(defineProps<HeaderPanelProps>(), {
+  title: '未命名项目',
   leftAside: true,
   leftAside: true,
   rightAside: true,
   rightAside: true,
   useThumbnail: false,
   useThumbnail: false,
   saveLoading: false,
   saveLoading: false,
+  unsaved: false,
   selectedItemsId: () => []
   selectedItemsId: () => []
 })
 })
 const emits = defineEmits([
 const emits = defineEmits([
@@ -295,9 +306,9 @@ const emits = defineEmits([
   'onDeleteClick',
   'onDeleteClick',
   'onExportClick',
   'onExportClick',
   'onTreeClick',
   'onTreeClick',
+  'changeLayer',
   'alignSelected',
   'alignSelected',
   'update:lockState',
   'update:lockState',
-  'onHelpClick',
   'onRedoClick',
   'onRedoClick',
   'onUndoClick',
   'onUndoClick',
   'onImportClick',
   'onImportClick',
@@ -307,28 +318,21 @@ const emits = defineEmits([
   'onDrawLineClick',
   'onDrawLineClick',
   'onThumbnailClick'
   'onThumbnailClick'
 ])
 ])
-const isDark = useDark({
-  selector: '#mt-edit'
-})
+const isDark = useDark({ selector: '#mt-edit' })
 const { isFullscreen, toggle } = useFullscreen()
 const { isFullscreen, toggle } = useFullscreen()
 const toggleDark = useToggle(isDark)
 const toggleDark = useToggle(isDark)
 const drawline_selected = ref(false)
 const drawline_selected = ref(false)
-const is_npm_env = ref(import.meta.env.MODE === 'npm')
+const layerPopoverVisible = ref(false)
 const systemShortcutKey = getSystemShortcutKeyName()
 const systemShortcutKey = getSystemShortcutKeyName()
-const onGroupClick = () => {
-  emits('onGroupClick')
-}
-const onUngroupClick = () => {
-  emits('onUngroupClick')
-}
-const onDeleteClick = () => {
-  emits('onDeleteClick')
-}
-const onExportClick = () => {
-  emits('onExportClick')
-}
-const onTreeClick = () => {
-  emits('onTreeClick')
+const is_npm_env = ref(import.meta.env.MODE === 'npm')
+const onGroupClick = () => emits('onGroupClick')
+const onUngroupClick = () => emits('onUngroupClick')
+const onDeleteClick = () => emits('onDeleteClick')
+const onExportClick = () => emits('onExportClick')
+const onTreeClick = () => emits('onTreeClick')
+const changeLayer = (type: 'top' | 'bottom') => {
+  emits('changeLayer', type)
+  layerPopoverVisible.value = false
 }
 }
 const alignSelected = (
 const alignSelected = (
   type:
   type:
@@ -340,52 +344,452 @@ const alignSelected = (
     | 'bottom'
     | 'bottom'
     | 'horizontal-distribution'
     | 'horizontal-distribution'
     | 'vertical-distribution'
     | 'vertical-distribution'
-) => {
-  emits('alignSelected', type)
-}
-const changeLockState = () => {
-  emits('update:lockState', !headerPanelProps.lockState)
-}
-const onHelpClick = () => {
-  emits('onHelpClick')
-}
-const onImportClick = () => {
-  emits('onImportClick')
-}
+) => emits('alignSelected', type)
+const changeLockState = () => emits('update:lockState', !headerPanelProps.lockState)
+const onImportClick = () => emits('onImportClick')
 const onDrawLineClick = () => {
 const onDrawLineClick = () => {
   drawline_selected.value = !drawline_selected.value
   drawline_selected.value = !drawline_selected.value
   emits('onDrawLineClick', drawline_selected.value)
   emits('onDrawLineClick', drawline_selected.value)
 }
 }
 </script>
 </script>
+
 <style scoped>
 <style scoped>
-.editor-brand {
+.editor-header {
   display: flex;
   display: flex;
+  width: 100%;
+  height: 72px;
+  min-width: 0;
+  padding: 7px 18px;
+  color: #27364a;
+  background: rgb(255 255 255 / 96%);
+  border-bottom: 1px solid #dce2ea;
+  box-shadow: 0 2px 10px rgb(15 23 42 / 5%);
+  box-sizing: border-box;
   align-items: center;
   align-items: center;
-  gap: 8px;
-  height: 36px;
-  padding-left: 12px;
+}
+
+.editor-header__brand,
+.editor-header__actions,
+.tool-group,
+.utility-actions {
+  display: flex;
+  align-items: center;
+}
+
+.editor-header__brand {
+  position: relative;
+  flex: 0 0 240px;
+  gap: 14px;
+  padding-right: 18px;
+}
+
+.editor-header__brand::after,
+.tool-group::after {
+  position: absolute;
+  top: 50%;
+  right: 0;
+  width: 1px;
+  height: 38px;
+  background: #dfe4eb;
+  content: '';
+  transform: translateY(-50%);
 }
 }
 
 
 .editor-brand__logo {
 .editor-brand__logo {
-  width: 28px;
-  height: 28px;
-  flex: 0 0 28px;
-  border-radius: 6px;
+  width: 32px;
+  height: 32px;
   overflow: hidden;
   overflow: hidden;
-  box-shadow: 0 0 0 1px rgba(18, 120, 255, 0.16);
+  border-radius: 9px;
+  box-shadow:
+    0 0 0 1px rgb(64 158 255 / 16%),
+    0 4px 12px rgb(64 158 255 / 12%);
+  flex: 0 0 32px;
 }
 }
 
 
-.editor-brand__title {
+.editor-brand__text {
+  display: flex;
+  min-width: 0;
+  flex: 1;
+  flex-direction: column;
+  gap: 5px;
+}
+
+.editor-brand__name {
   font-size: 15px;
   font-size: 15px;
   font-weight: 700;
   font-weight: 700;
-  color: #1f2937;
-  letter-spacing: 0;
   line-height: 1;
   line-height: 1;
+  color: #101828;
 }
 }
-</style>
-<style scoped>
-.icon-selected {
-  color: #409eff;
-  background-color: #ecf5ff;
+
+.editor-brand__project {
+  overflow: hidden;
+  font-size: 12px;
+  line-height: 1.2;
+  color: #667085;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.editor-header__toolbar {
+  display: flex;
+  min-width: 0;
+  overflow: auto hidden;
+  flex: 1;
+  align-items: center;
+  scrollbar-width: none;
+}
+
+.editor-header__toolbar::-webkit-scrollbar {
+  display: none;
+}
+
+.tool-group {
+  position: relative;
+  flex: 0 0 auto;
+  gap: 2px;
+  padding: 0 8px;
+}
+
+.editor-header__actions {
+  flex: 0 0 auto;
+  gap: 6px;
+  padding-left: 10px;
+}
+
+.selection-status {
+  padding: 6px 12px;
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--el-color-primary);
+  white-space: nowrap;
+  background: var(--el-color-primary-light-9);
+  border-radius: 10px;
+}
+
+.save-status {
+  display: inline-flex;
+  padding: 6px 10px;
+  font-size: 12px;
+  font-weight: 600;
+  line-height: 1;
+  color: var(--el-color-success);
+  white-space: nowrap;
+  background: var(--el-color-success-light-9);
+  border-radius: 10px;
+  align-items: center;
+  gap: 6px;
+}
+
+.save-status__dot {
+  width: 6px;
+  height: 6px;
+  background: currentcolor;
+  border-radius: 50%;
+}
+
+.save-status--unsaved {
+  color: var(--el-color-warning-dark-2);
+  background: var(--el-color-warning-light-9);
+}
+
+:deep(.tool-button-wrap) {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+}
+
+:deep(.tool-button) {
+  display: inline-flex;
+  width: 44px;
+  height: 52px;
+  min-width: 44px;
+  padding: 5px 4px;
+  margin: 0;
+  color: #344054;
+  border-radius: 8px;
+  align-items: center;
+  justify-content: center;
+}
+
+:deep(.tool-button > span) {
+  display: flex;
+  width: 100%;
+  height: 100%;
+  text-align: center;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+  gap: 4px;
+}
+
+:deep(.tool-button:not(.tool-button--compact) .el-icon) {
+  width: 20px;
+  height: 20px;
+  margin: 0;
+  flex: 0 0 20px;
+}
+
+:deep(.tool-button:hover) {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+}
+
+:deep(.tool-button.is-disabled) {
+  color: #98a2b3;
+  background: transparent;
+  opacity: 1;
+}
+
+:deep(.tool-button--active) {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  box-shadow: inset 0 0 0 1px var(--el-color-primary-light-7);
+}
+
+:deep(.tool-button--danger:not(.is-disabled):hover) {
+  color: var(--el-color-danger);
+  background: var(--el-color-danger-light-9);
+}
+
+:deep(.tool-button__label) {
+  margin-left: 0;
+  font-size: 12px;
+  font-weight: 600;
+  line-height: 1;
+  white-space: nowrap;
+}
+
+:deep(.tool-button .el-icon + .tool-button__label) {
+  margin-left: 0;
+}
+
+:deep(.tool-button--compact) {
+  width: auto;
+  height: auto;
+  min-width: 0;
+  padding: 7px 9px;
+}
+
+:deep(.tool-button--compact > span) {
+  width: auto;
+  height: auto;
+  flex-direction: row;
+  gap: 6px;
+}
+
+.save-button {
+  width: auto;
+  height: auto;
+  min-width: 0;
+  padding: 9px 16px;
+  margin-left: 6px;
+  font-weight: 600;
+  border-radius: 9px;
+  box-shadow: 0 5px 12px rgb(64 158 255 / 22%);
+}
+
+:deep(.save-button > span) {
+  gap: 7px;
+}
+
+.utility-actions {
+  padding-left: 8px;
+  margin-left: 4px;
+  border-left: 1px solid #dfe4eb;
+  gap: 2px;
+}
+
+.utility-actions :deep(.el-button) {
+  width: 32px;
+  height: 32px;
+  margin: 0;
+  color: #475467;
+}
+
+.utility-actions :deep(.el-button:hover),
+.utility-actions :deep(.utility-actions__active) {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+}
+
+.coordinate-status {
+  flex: 0 0 auto;
+}
+
+.align-panel__title {
+  margin-bottom: 8px;
+  font-size: 13px;
+  font-weight: 600;
+  color: #273244;
+}
+
+.align-panel__actions {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 8px;
+}
+
+.align-panel__actions :deep(.tool-button-wrap),
+.align-panel__actions :deep(.tool-button) {
+  width: 100%;
+}
+
+.layer-control-panel__heading {
+  display: flex;
+  padding-bottom: 10px;
+  border-bottom: 1px solid #e8ecf2;
+  flex-direction: column;
+  gap: 3px;
+}
+
+.layer-control-panel__heading strong {
+  font-size: 13px;
+  line-height: 19px;
+  color: #273244;
+}
+
+.layer-control-panel__heading span,
+.layer-control-panel__hint {
+  font-size: 11px;
+  line-height: 17px;
+  color: #8a95a6;
+}
+
+.layer-control-panel__actions {
+  display: grid;
+  padding: 10px 0;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 7px;
+}
+
+.layer-control-panel__actions :deep(.tool-button-wrap),
+.layer-control-panel__actions :deep(.tool-button) {
+  width: 100%;
+}
+
+.layer-control-panel__actions :deep(.tool-button) {
+  padding: 9px 10px;
+  background: #f7f9fc;
+  border: 1px solid #e5eaf1;
+  justify-content: flex-start;
+}
+
+.layer-control-panel__actions :deep(.tool-button:not(.is-disabled):hover) {
+  background: var(--el-color-primary-light-9);
+  border-color: var(--el-color-primary-light-7);
+}
+
+.layer-control-panel__hint {
+  padding-top: 8px;
+  text-align: center;
+  border-top: 1px solid #e8ecf2;
+}
+
+.dark .editor-header {
+  color: #cbd5e1;
+  background: rgb(24 31 43 / 97%);
+  border-bottom-color: #30394a;
+  box-shadow: 0 2px 12px rgb(0 0 0 / 22%);
+}
+
+.dark .editor-header__brand,
+.dark .tool-group,
+.dark .utility-actions {
+  border-color: #30394a;
+}
+
+.dark .editor-header__brand::after,
+.dark .tool-group::after {
+  background: #30394a;
+}
+
+.dark .editor-brand__name,
+.dark .align-panel__title,
+.dark .layer-control-panel__heading strong {
+  color: #f1f5f9;
+}
+
+.dark .editor-brand__project {
+  color: #a7b2c3;
+}
+
+.dark :deep(.tool-button) {
+  color: #d2d9e4;
+}
+
+.dark :deep(.tool-button.is-disabled) {
+  color: #7f8b9d;
+  opacity: 1;
+}
+
+.dark .utility-actions :deep(.el-button) {
+  color: #bdc7d5;
+}
+
+.dark :deep(.tool-button:hover),
+.dark :deep(.tool-button--active),
+.dark .utility-actions :deep(.el-button:hover),
+.dark .utility-actions :deep(.utility-actions__active) {
+  color: var(--el-color-primary-light-3);
+  background: rgb(64 158 255 / 14%);
+}
+
+@media (width <= 1440px) {
+  .editor-header {
+    padding-right: 10px;
+    padding-left: 10px;
+  }
+
+  .editor-header__brand {
+    flex-basis: 190px;
+    gap: 9px;
+    padding-right: 10px;
+  }
+
+  .selection-status,
+  .save-status {
+    padding-right: 7px;
+    padding-left: 7px;
+  }
+
+  .save-button {
+    padding-right: 12px;
+    padding-left: 12px;
+    margin-left: 0;
+  }
+}
+
+@media (width <= 1200px) {
+  .editor-header__brand {
+    flex-basis: 132px;
+  }
+
+  .editor-brand__logo,
+  .editor-brand__name {
+    display: none;
+  }
+
+  .editor-header__actions {
+    gap: 3px;
+    padding-left: 6px;
+  }
+
+  .tool-group {
+    padding-right: 5px;
+    padding-left: 5px;
+  }
+}
+
+@media (width <= 1100px) {
+  .editor-header__brand {
+    flex-basis: 44px;
+    padding-right: 4px;
+    justify-content: center;
+  }
+
+  .editor-brand__text,
+  .selection-status {
+    display: none;
+  }
 }
 }
 </style>
 </style>

+ 892 - 139
src/components/mt-edit/components/layout/left-aside/index.vue

@@ -1,157 +1,241 @@
 <template>
 <template>
-  <div id="mt-left-aside" class="pt-10px h-1/1 box-border p-x-10px">
-    <el-input v-model="search_str" class="pb-10px pr-10px" placeholder="请输入关键字进行搜索" />
-    <div class="h-85/100">
-      <el-scrollbar class="pr-10px" :view-style="{ height: '100%' }">
-        <el-collapse v-model="active_names">
+  <div id="mt-left-aside" class="component-library">
+    <header class="component-library__header">
+      <div>
+        <h2>组件库</h2>
+        <p>拖拽组件到画布中使用</p>
+      </div>
+      <span class="component-library__count">{{ filtered_component_count }} 个</span>
+    </header>
+
+    <el-input
+      v-model="search_str"
+      class="component-library__search"
+      clearable
+      placeholder="搜索组件名称">
+      <template #prefix>
+        <el-icon><Search /></el-icon>
+      </template>
+    </el-input>
+
+    <main class="component-library__content">
+      <el-scrollbar :view-style="{ minHeight: '100%' }">
+        <el-collapse v-if="checked_keys.length" v-model="active_names" class="category-list">
           <el-collapse-item
           <el-collapse-item
             v-for="config_item_key in checked_keys"
             v-for="config_item_key in checked_keys"
             :key="config_item_key"
             :key="config_item_key"
-            :title="config_item_key"
             :name="config_item_key">
             :name="config_item_key">
-            <div class="flex flex-wrap">
-              <div
+            <template #title>
+              <div class="category-title">
+                <span class="category-title__icon"><CollectionTag /></span>
+                <span class="category-title__name">{{ config_item_key }}</span>
+                <span class="category-title__count">
+                  {{ getFilteritems(leftAsideProps.leftAsideConfig.get(config_item_key)).length }}
+                </span>
+              </div>
+            </template>
+
+            <div
+              v-if="getFilteritems(leftAsideProps.leftAsideConfig.get(config_item_key)).length"
+              class="component-grid">
+              <article
+                v-for="item in getFilteritems(leftAsideProps.leftAsideConfig.get(config_item_key))"
+                :key="item.id"
                 draggable="true"
                 draggable="true"
+                class="component-card"
                 @dragstart="onDragStart(config_item_key, item.id)"
                 @dragstart="onDragStart(config_item_key, item.id)"
-                @touchstart.passive="onDragStart(config_item_key, item.id)"
-                class="w-40px h-40px"
-                v-for="(item, index) in getFilteritems(
-                  leftAsideProps.leftAsideConfig.get(config_item_key)
-                )"
-                :key="item.id">
-                <el-tooltip
-                  v-model:visible="is_show_tooltip[`${config_item_key}${item.id}`]"
-                  placement="right"
-                  :width="200"
-                  :effect="isDark ? 'dark' : 'light'"
-                  :show-arrow="false"
-                  :hide-after="0"
-                  trigger="hover"
-                  :enterable="false"
-                  :offset="getOffset(index + 1)">
+                @touchstart.passive="onDragStart(config_item_key, item.id)">
+                <div class="component-card__preview">
                   <el-image
                   <el-image
                     draggable="false"
                     draggable="false"
-                    class="w-30px h-30px select-none"
+                    fit="contain"
+                    class="component-card__image"
                     :class="isDark ? 'bg-amber-50' : ''"
                     :class="isDark ? 'bg-amber-50' : ''"
                     :src="item.thumbnail" />
                     :src="item.thumbnail" />
-                  <template #content>
-                    <div class="flex justify-center items-center">
-                      <div class="flex flex-col">
-                        <el-text>{{ item.title }}</el-text>
-                        <el-image
-                          class="w-100px h-100px pt-5px"
-                          :class="isDark ? 'bg-amber-50' : ''"
-                          :src="item.thumbnail" />
-                      </div>
-                    </div>
-                  </template>
+                </div>
+                <el-tooltip
+                  :content="item.title"
+                  placement="top"
+                  :show-after="300"
+                  :effect="isDark ? 'dark' : 'light'">
+                  <span class="component-card__name">{{ item.title }}</span>
                 </el-tooltip>
                 </el-tooltip>
-              </div>
+              </article>
             </div>
             </div>
+            <el-empty v-else :image-size="44" description="暂无匹配组件" />
           </el-collapse-item>
           </el-collapse-item>
         </el-collapse>
         </el-collapse>
+        <el-empty v-else :image-size="64" description="暂无可用分类" />
       </el-scrollbar>
       </el-scrollbar>
-    </div>
-    <div class="h-[calc(10%-1px)] flex justify-center items-center ct-border">
-      <el-button class="w-80/100" @click="onManageClick">管理</el-button>
-    </div>
-    <el-dialog v-model="manage_dialog_visiable" title="图库管理" width="80%" destroy-on-close>
-      <div class="flex">
-        <div>
+    </main>
+
+    <footer class="component-library__footer">
+      <el-button type="primary" plain @click="onManageClick">
+        <el-icon><Setting /></el-icon>
+        组件管理
+      </el-button>
+      <span>管理分类与本地素材</span>
+    </footer>
+
+    <el-dialog
+      v-model="manage_dialog_visiable"
+      width="960px"
+      class="component-manage-dialog"
+      align-center
+      destroy-on-close
+      @opened="onManageOpened">
+      <template #header>
+        <div class="manage-dialog__header">
+          <span class="manage-dialog__header-icon"><CollectionTag /></span>
           <div>
           <div>
-            <div class="flex justify-center">
-              <el-checkbox v-model="check_all" :indeterminate="is_indeterminate">全选</el-checkbox>
-            </div>
-            <el-scrollbar height="50vh">
-              <el-tree
-                ref="treeRef"
-                :data="classify_list"
-                :highlight-current="true"
-                show-checkbox
-                @check-change="handleCheckChange"
-                node-key="label"
-                :default-checked-keys="checked_keys"
-                @node-click="onNodeClick" />
-            </el-scrollbar>
-            <el-upload
-              ref="uploadRef"
-              class="w-24px h-24px"
-              v-model:file-list="up_img_list"
-              :auto-upload="false"
-              :limit="1"
-              :show-file-list="false"
-              :on-change="onUpLoadChange"
-              accept="image/*">
-              <el-button type="primary">本地上传</el-button>
-            </el-upload>
+            <h2>组件管理</h2>
+            <p>选择侧栏中显示的分类,并管理本地上传素材</p>
           </div>
           </div>
         </div>
         </div>
+      </template>
 
 
-        <el-divider direction="vertical" class="h-50vh ml-40px" />
-        <div v-if="selected_node_key">
-          <el-scrollbar height="50vh">
-            <div class="flex flex-wrap">
-              <div
-                v-for="item in leftAsideProps.leftAsideConfig.get(selected_node_key)"
+      <div class="manage-dialog__layout">
+        <aside class="manage-categories">
+          <div class="manage-categories__header">
+            <div>
+              <strong>组件分类</strong>
+              <span>{{ checked_keys.length }}/{{ classify_list.length }} 个已启用</span>
+            </div>
+            <el-checkbox v-model="check_all" :indeterminate="is_indeterminate">全选</el-checkbox>
+          </div>
+          <el-scrollbar class="manage-categories__scrollbar">
+            <el-tree
+              ref="treeRef"
+              class="manage-category-tree"
+              :data="classify_list"
+              :highlight-current="true"
+              :check-on-click-node="false"
+              :check-on-click-leaf="false"
+              show-checkbox
+              node-key="label"
+              :current-node-key="selected_node_key"
+              :default-checked-keys="checked_keys"
+              @check-change="handleCheckChange"
+              @node-click="onNodeClick">
+              <template #default="{ data }">
+                <div class="manage-category-node">
+                  <span>{{ data.label }}</span>
+                  <span>{{ leftAsideProps.leftAsideConfig.get(data.label)?.length || 0 }}</span>
+                </div>
+              </template>
+            </el-tree>
+          </el-scrollbar>
+        </aside>
+
+        <main class="manage-components">
+          <header class="manage-components__header">
+            <div>
+              <h3>{{ selected_node_key || '请选择分类' }}</h3>
+              <p>共 {{ selected_category_items.length }} 个组件</p>
+            </div>
+            <div class="manage-components__actions">
+              <el-input v-model="manage_search_str" clearable placeholder="搜索当前分类">
+                <template #prefix>
+                  <el-icon><Search /></el-icon>
+                </template>
+              </el-input>
+              <el-upload
+                v-if="selected_node_key === '本地文件'"
+                ref="uploadRef"
+                v-model:file-list="up_img_list"
+                :auto-upload="false"
+                :limit="1"
+                :show-file-list="false"
+                :on-change="onUpLoadChange"
+                accept="image/*">
+                <el-button type="primary">
+                  <el-icon><UploadFilled /></el-icon>
+                  上传素材
+                </el-button>
+              </el-upload>
+            </div>
+          </header>
+
+          <el-scrollbar class="manage-components__scrollbar">
+            <div v-if="managed_category_items.length" class="manage-component-grid">
+              <article
+                v-for="item in managed_category_items"
                 :key="item.id"
                 :key="item.id"
-                class="w-160px h-160px flex flex-wrap justify-center items-center cursor-pointer relative"
-                @mouseenter="show_del_local_file = item.id"
-                @mouseleave="show_del_local_file = null">
-                <el-tooltip
-                  :effect="isDark ? 'dark' : 'light'"
-                  :content="item.title"
-                  placement="top">
-                  <div>
-                    <el-image
-                      class="w-60px h-60px"
-                      :class="isDark ? 'bg-amber-50' : ''"
-                      :src="item.thumbnail" />
-                    <div class="w-60px h-60px flex justify-center items-center">
-                      <el-text truncated>{{ item.title }}</el-text>
-                    </div>
-                    <div
-                      v-if="selected_node_key == '本地文件' && show_del_local_file == item.id"
-                      class="absolute w-160px h-160px left-0 top-0 opacity-80 bg-light-300 flex justify-center items-center">
-                      <el-button type="danger" @click="onDelLocalFile(item)">删除</el-button>
-                    </div>
-                  </div>
+                class="manage-component-card">
+                <div class="manage-component-card__preview">
+                  <el-image
+                    fit="contain"
+                    class="manage-component-card__image"
+                    :class="isDark ? 'bg-amber-50' : ''"
+                    :src="item.thumbnail" />
+                </div>
+                <el-tooltip :content="item.title" placement="top" :show-after="300">
+                  <span class="manage-component-card__name">{{ item.title }}</span>
                 </el-tooltip>
                 </el-tooltip>
-              </div>
+                <el-popconfirm
+                  v-if="selected_node_key === '本地文件'"
+                  width="210"
+                  title="确认删除这个本地素材吗?"
+                  confirm-button-text="删除"
+                  cancel-button-text="取消"
+                  @confirm="onDelLocalFile(item)">
+                  <template #reference>
+                    <el-button
+                      class="manage-component-card__delete"
+                      type="danger"
+                      text
+                      circle
+                      @click.stop>
+                      <el-icon><Delete /></el-icon>
+                    </el-button>
+                  </template>
+                </el-popconfirm>
+              </article>
             </div>
             </div>
+            <el-empty v-else :image-size="84" :description="manage_empty_description" />
           </el-scrollbar>
           </el-scrollbar>
-        </div>
+        </main>
       </div>
       </div>
+
+      <template #footer>
+        <div class="manage-dialog__footer">
+          <span>分类显示设置会自动保存</span>
+          <el-button type="primary" @click="manage_dialog_visiable = false">完成</el-button>
+        </div>
+      </template>
     </el-dialog>
     </el-dialog>
   </div>
   </div>
 </template>
 </template>
 <script setup lang="ts">
 <script setup lang="ts">
-import { computed, reactive, ref } from 'vue'
+import { globalStore } from '@/components/mt-edit/store/global'
+import type {
+  ILeftAsideConfig,
+  ILeftAsideConfigItem,
+  ILeftAsideConfigItemPublic
+} from '@/components/mt-edit/store/types'
+import { blobToBase64 } from '@/components/mt-edit/utils'
+import { CollectionTag, Delete, Search, Setting, UploadFilled } from '@element-plus/icons-vue'
+import { useDark, useLocalStorage } from '@vueuse/core'
 import {
 import {
-  ElInput,
+  ElButton,
+  ElCheckbox,
   ElCollapse,
   ElCollapse,
   ElCollapseItem,
   ElCollapseItem,
-  ElButton,
-  ElScrollbar,
-  ElImage,
-  ElTooltip,
-  ElText,
   ElDialog,
   ElDialog,
-  ElCheckbox,
-  ElDivider,
+  ElEmpty,
+  ElIcon,
+  ElImage,
+  ElInput,
+  ElMessage,
+  ElPopconfirm,
+  ElScrollbar,
   ElTree,
   ElTree,
+  ElTooltip,
   ElUpload,
   ElUpload,
-  type UploadUserFile,
-  ElMessage,
-  type UploadFile
+  type UploadFile,
+  type UploadUserFile
 } from 'element-plus'
 } from 'element-plus'
-import { useDark, useLocalStorage } from '@vueuse/core'
-import type {
-  ILeftAsideConfig,
-  ILeftAsideConfigItem,
-  ILeftAsideConfigItemPublic
-} from '@/components/mt-edit/store/types'
-import { globalStore } from '@/components/mt-edit/store/global'
-import { blobToBase64 } from '@/components/mt-edit/utils'
+import { computed, nextTick, ref, watch } from 'vue'
 type LeftAsideProps = {
 type LeftAsideProps = {
   leftAsideConfig: ILeftAsideConfig
   leftAsideConfig: ILeftAsideConfig
 }
 }
@@ -168,12 +252,11 @@ const disable_classify = useLocalStorage<string[]>('mt-disable-classify', [])
 const local_file = useLocalStorage<ILeftAsideConfigItem[]>('mt-local-file', [])
 const local_file = useLocalStorage<ILeftAsideConfigItem[]>('mt-local-file', [])
 leftAsideProps.leftAsideConfig.set('本地文件', local_file.value)
 leftAsideProps.leftAsideConfig.set('本地文件', local_file.value)
 const treeRef = ref()
 const treeRef = ref()
-const is_show_tooltip: Record<string, boolean> = reactive({})
-const active_names = ref([])
-const search_str = ref()
+const active_names = ref<string[]>([])
+const search_str = ref('')
 const manage_dialog_visiable = ref(false)
 const manage_dialog_visiable = ref(false)
 const up_img_list = ref<UploadUserFile[]>([])
 const up_img_list = ref<UploadUserFile[]>([])
-const show_del_local_file = ref<null | string>(null)
+const manage_search_str = ref('')
 const classify_list = computed(() =>
 const classify_list = computed(() =>
   [...leftAsideProps.leftAsideConfig.keys()].map((m) => {
   [...leftAsideProps.leftAsideConfig.keys()].map((m) => {
     return { label: m }
     return { label: m }
@@ -182,34 +265,56 @@ const classify_list = computed(() =>
 const checked_keys = ref<string[]>(
 const checked_keys = ref<string[]>(
   classify_list.value.filter((f) => !disable_classify.value.includes(f.label)).map((m) => m.label)
   classify_list.value.filter((f) => !disable_classify.value.includes(f.label)).map((m) => m.label)
 )
 )
-const selected_node_key = ref()
+const filtered_component_count = computed(() =>
+  checked_keys.value.reduce(
+    (count, key) => count + getFilteritems(leftAsideProps.leftAsideConfig.get(key)).length,
+    0
+  )
+)
+const selected_node_key = ref<string>()
+const selected_category_items = computed<ILeftAsideConfigItemPublic[]>(() => {
+  if (!selected_node_key.value) {
+    return []
+  }
+  return leftAsideProps.leftAsideConfig.get(selected_node_key.value) || []
+})
+const managed_category_items = computed(() => {
+  const keyword = manage_search_str.value.trim().toLocaleLowerCase()
+  if (!keyword) {
+    return selected_category_items.value
+  }
+  return selected_category_items.value.filter((item) =>
+    item.title.toLocaleLowerCase().includes(keyword)
+  )
+})
+const manage_empty_description = computed(() => {
+  if (!selected_node_key.value) {
+    return '请先选择一个组件分类'
+  }
+  return manage_search_str.value.trim() ? '没有找到匹配的组件' : '当前分类暂无组件'
+})
 const check_all = computed({
 const check_all = computed({
   get: () => {
   get: () => {
     return classify_list.value.length == checked_keys.value.length
     return classify_list.value.length == checked_keys.value.length
   },
   },
   set: (val) => {
   set: (val) => {
-    if (val) {
-      checked_keys.value = classify_list.value.map((m) => m.label)
-    } else {
-      checked_keys.value = []
-      treeRef.value?.setCheckedNodes([])
-    }
+    const keys = val ? classify_list.value.map((m) => m.label) : []
+    checked_keys.value = keys
+    treeRef.value?.setCheckedKeys(keys)
   }
   }
 })
 })
 const is_indeterminate = computed(() => {
 const is_indeterminate = computed(() => {
   return checked_keys.value.length > 0 && checked_keys.value.length < classify_list.value.length
   return checked_keys.value.length > 0 && checked_keys.value.length < classify_list.value.length
 })
 })
-const getOffset = (index: number) => {
-  return index % 4 == 0 ? 40 : index % 4 == 3 ? 80 : index % 4 == 2 ? 120 : 160
-}
 const getFilteritems = (
 const getFilteritems = (
   arr: ILeftAsideConfigItemPublic[] | undefined
   arr: ILeftAsideConfigItemPublic[] | undefined
 ): ILeftAsideConfigItemPublic[] => {
 ): ILeftAsideConfigItemPublic[] => {
   if (!arr) {
   if (!arr) {
     return []
     return []
   }
   }
-  if (search_str.value) {
-    return arr.filter((f) => f.title.includes(search_str.value))
+  const keyword = search_str.value.trim()
+  if (keyword) {
+    return arr.filter((f) => f.title.toLocaleLowerCase().includes(keyword.toLocaleLowerCase()))
   }
   }
   return arr
   return arr
 }
 }
@@ -218,7 +323,6 @@ const onDragStart = (config_item_key: string, item_id: string) => {
     console.error('拖拽初始化失败', config_item_key, item_id)
     console.error('拖拽初始化失败', config_item_key, item_id)
     return
     return
   }
   }
-  is_show_tooltip[`${config_item_key}${item_id}`] = false
   globalStore.setIntention('create')
   globalStore.setIntention('create')
   globalStore.setCreateItemInfo({
   globalStore.setCreateItemInfo({
     config_key: config_item_key,
     config_key: config_item_key,
@@ -226,9 +330,31 @@ const onDragStart = (config_item_key: string, item_id: string) => {
   })
   })
 }
 }
 const onManageClick = () => {
 const onManageClick = () => {
+  const selectedExists = classify_list.value.some((item) => item.label === selected_node_key.value)
+  if (!selectedExists) {
+    selected_node_key.value =
+      classify_list.value.find((item) => item.label === '本地文件')?.label ||
+      classify_list.value[0]?.label
+  }
+  manage_search_str.value = ''
   manage_dialog_visiable.value = true
   manage_dialog_visiable.value = true
 }
 }
-const handleCheckChange = (data: { label: string }, checked: boolean, indeterminate: boolean) => {
+const onManageOpened = () => {
+  nextTick(() => {
+    treeRef.value?.setCurrentKey(selected_node_key.value)
+    treeRef.value?.setCheckedKeys(checked_keys.value)
+  })
+}
+
+watch(search_str, (keyword) => {
+  if (!keyword.trim()) {
+    return
+  }
+  active_names.value = checked_keys.value.filter(
+    (key) => getFilteritems(leftAsideProps.leftAsideConfig.get(key)).length > 0
+  )
+})
+const handleCheckChange = (data: { label: string }, checked: boolean) => {
   if (checked && !checked_keys.value.includes(data.label)) {
   if (checked && !checked_keys.value.includes(data.label)) {
     checked_keys.value.push(data.label)
     checked_keys.value.push(data.label)
   } else if (!checked) {
   } else if (!checked) {
@@ -240,6 +366,7 @@ const handleCheckChange = (data: { label: string }, checked: boolean, indetermin
 }
 }
 const onNodeClick = ({ label }: { label: string }) => {
 const onNodeClick = ({ label }: { label: string }) => {
   selected_node_key.value = label
   selected_node_key.value = label
+  manage_search_str.value = ''
 }
 }
 const onUpLoadChange = (e: UploadFile) => {
 const onUpLoadChange = (e: UploadFile) => {
   if (!e.raw!.type.includes('image/')) {
   if (!e.raw!.type.includes('image/')) {
@@ -279,6 +406,7 @@ const onUpLoadChange = (e: UploadFile) => {
     up_img_list.value = []
     up_img_list.value = []
     selected_node_key.value = '本地文件'
     selected_node_key.value = '本地文件'
     treeRef.value?.setCurrentKey('本地文件')
     treeRef.value?.setCurrentKey('本地文件')
+    ElMessage.success('素材上传成功')
   })
   })
 }
 }
 const onDelLocalFile = ({ id }: ILeftAsideConfigItem) => {
 const onDelLocalFile = ({ id }: ILeftAsideConfigItem) => {
@@ -287,11 +415,636 @@ const onDelLocalFile = ({ id }: ILeftAsideConfigItem) => {
     local_file.value.splice(find_index, 1)
     local_file.value.splice(find_index, 1)
   }
   }
   leftAsideProps.leftAsideConfig.set('本地文件', local_file.value)
   leftAsideProps.leftAsideConfig.set('本地文件', local_file.value)
+  ElMessage.success('本地素材已删除')
 }
 }
 </script>
 </script>
+<style scoped>
+.component-library {
+  display: flex;
+  height: 100%;
+  min-height: 0;
+  padding: 14px 12px 0;
+  background: rgb(255 255 255 / 96%);
+  box-sizing: border-box;
+  flex-direction: column;
+}
+
+.component-library__header {
+  display: flex;
+  padding: 0 4px;
+  align-items: flex-start;
+  justify-content: space-between;
+}
+
+.component-library__header h2 {
+  margin: 0;
+  font-size: 16px;
+  font-weight: 700;
+  line-height: 22px;
+  color: #172033;
+}
+
+.component-library__header p {
+  margin: 3px 0 0;
+  font-size: 12px;
+  line-height: 18px;
+  color: #7a8699;
+}
+
+.component-library__count {
+  padding: 3px 8px;
+  font-size: 11px;
+  font-weight: 600;
+  line-height: 18px;
+  color: var(--el-color-primary);
+  white-space: nowrap;
+  background: var(--el-color-primary-light-9);
+  border-radius: 10px;
+}
+
+.component-library__search {
+  margin: 13px 0 12px;
+}
+
+.component-library__search :deep(.el-input__wrapper) {
+  padding: 0 11px;
+  background: #fff;
+  border-radius: 9px;
+  box-shadow: 0 0 0 1px #dbe1ea inset;
+}
+
+.component-library__search :deep(.el-input__wrapper:hover),
+.component-library__search :deep(.el-input__wrapper.is-focus) {
+  box-shadow: 0 0 0 1px var(--el-color-primary) inset;
+}
+
+.component-library__content {
+  min-height: 0;
+  flex: 1;
+}
+
+.component-library__content :deep(.el-scrollbar__view) {
+  padding-right: 3px;
+}
+
+.category-list {
+  border: 0;
+}
+
+.category-list :deep(.el-collapse-item) {
+  margin-bottom: 8px;
+  overflow: hidden;
+  background: #fff;
+  border: 1px solid #e2e7ef;
+  border-radius: 10px;
+  transition:
+    border-color 0.2s ease,
+    box-shadow 0.2s ease;
+}
+
+.category-list :deep(.el-collapse-item:hover),
+.category-list :deep(.el-collapse-item.is-active) {
+  border-color: var(--el-color-primary-light-7);
+  box-shadow: 0 4px 12px rgb(31 45 61 / 6%);
+}
+
+.category-list :deep(.el-collapse-item__header) {
+  height: 44px;
+  padding: 0 11px;
+  background: transparent;
+  border: 0;
+}
+
+.category-list :deep(.el-collapse-item__arrow) {
+  margin-left: 8px;
+  color: #7d899b;
+}
+
+.category-list :deep(.el-collapse-item__wrap) {
+  background: transparent;
+  border: 0;
+}
+
+.category-list :deep(.el-collapse-item__content) {
+  padding: 0 10px 11px;
+}
+
+.category-title {
+  display: flex;
+  width: 100%;
+  min-width: 0;
+  align-items: center;
+  gap: 8px;
+}
+
+.category-title__icon {
+  display: inline-flex;
+  width: 26px;
+  height: 26px;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-radius: 7px;
+  align-items: center;
+  justify-content: center;
+}
+
+.category-title__icon svg {
+  width: 15px;
+  height: 15px;
+}
+
+.category-title__name {
+  overflow: hidden;
+  font-size: 13px;
+  font-weight: 600;
+  color: #344054;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.category-title__count {
+  min-width: 20px;
+  padding: 1px 6px;
+  margin-left: auto;
+  font-size: 11px;
+  line-height: 18px;
+  color: #667085;
+  text-align: center;
+  background: #f1f4f8;
+  border-radius: 9px;
+}
+
+.component-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 7px;
+  margin-top: 4px;
+}
+
+.component-card {
+  min-width: 0;
+  padding: 5px;
+  cursor: grab;
+  background: #f8fafc;
+  border: 1px solid #e4e9f0;
+  border-radius: 9px;
+  transition:
+    border-color 0.18s ease,
+    background 0.18s ease,
+    box-shadow 0.18s ease,
+    transform 0.18s ease;
+}
+
+.component-card:hover {
+  background: #fff;
+  border-color: var(--el-color-primary-light-5);
+  transform: translateY(-1px);
+  box-shadow: 0 5px 14px rgb(64 158 255 / 13%);
+}
+
+.component-card:active {
+  cursor: grabbing;
+  transform: translateY(0);
+}
+
+.component-card__preview {
+  display: flex;
+  height: 38px;
+  overflow: hidden;
+  background:
+    linear-gradient(45deg, #f2f5f8 25%, transparent 25%) 0 0 / 12px 12px,
+    linear-gradient(45deg, transparent 75%, #f2f5f8 75%) 0 0 / 12px 12px,
+    linear-gradient(45deg, transparent 75%, #f2f5f8 75%) 6px -6px / 12px 12px,
+    linear-gradient(45deg, #f2f5f8 25%, #fff 25%) 6px -6px / 12px 12px;
+  border-radius: 6px;
+  align-items: center;
+  justify-content: center;
+}
+
+.component-card__image {
+  width: 32px;
+  height: 32px;
+  user-select: none;
+}
+
+.component-card__name {
+  display: block;
+  margin-top: 4px;
+  overflow: hidden;
+  font-size: 11px;
+  font-weight: 500;
+  line-height: 18px;
+  color: #475467;
+  text-align: center;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.category-list :deep(.el-empty) {
+  padding: 12px 0 6px;
+}
+
+.category-list :deep(.el-empty__description) {
+  margin-top: 4px;
+}
+
+.category-list :deep(.el-empty__description p) {
+  font-size: 12px;
+}
+
+.component-library__footer {
+  padding: 12px 2px 14px;
+  border-top: 1px solid #dfe4eb;
+}
+
+.component-library__footer :deep(.el-button) {
+  width: 100%;
+  height: 36px;
+  margin: 0;
+  font-weight: 600;
+  border-radius: 8px;
+}
+
+.component-library__footer :deep(.el-button > span) {
+  gap: 6px;
+}
+
+.component-library__footer > span {
+  display: block;
+  margin-top: 6px;
+  font-size: 11px;
+  line-height: 16px;
+  color: #98a2b3;
+  text-align: center;
+}
+
+.manage-dialog__header {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.manage-dialog__header-icon {
+  display: inline-flex;
+  width: 38px;
+  height: 38px;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-radius: 10px;
+  align-items: center;
+  justify-content: center;
+}
+
+.manage-dialog__header-icon svg {
+  width: 20px;
+  height: 20px;
+}
+
+.manage-dialog__header h2,
+.manage-components__header h3 {
+  margin: 0;
+  color: #172033;
+}
+
+.manage-dialog__header h2 {
+  font-size: 16px;
+  font-weight: 700;
+  line-height: 22px;
+}
+
+.manage-dialog__header p,
+.manage-components__header p {
+  margin: 3px 0 0;
+  font-size: 12px;
+  line-height: 18px;
+  color: #7a8699;
+}
+
+.manage-dialog__layout {
+  display: grid;
+  height: min(560px, calc(100vh - 210px));
+  min-height: 420px;
+  grid-template-columns: 220px minmax(0, 1fr);
+  gap: 14px;
+}
+
+.manage-categories,
+.manage-components {
+  min-height: 0;
+  overflow: hidden;
+  background: #fff;
+  border: 1px solid #e1e7ef;
+  border-radius: 11px;
+}
+
+.manage-categories {
+  display: flex;
+  flex-direction: column;
+}
+
+.manage-categories__header,
+.manage-components__header {
+  display: flex;
+  border-bottom: 1px solid #e8ecf2;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.manage-categories__header {
+  min-height: 64px;
+  padding: 0 14px;
+}
+
+.manage-categories__header > div {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  gap: 3px;
+}
+
+.manage-categories__header strong {
+  font-size: 13px;
+  color: #263245;
+}
+
+.manage-categories__header span {
+  font-size: 11px;
+  color: #8a95a6;
+}
+
+.manage-categories__header :deep(.el-checkbox) {
+  height: 28px;
+  margin-left: 8px;
+}
+
+.manage-categories__scrollbar,
+.manage-components__scrollbar {
+  min-height: 0;
+  flex: 1;
+}
+
+.manage-category-tree {
+  padding: 8px;
+  background: transparent;
+}
+
+.manage-category-tree :deep(.el-tree-node__content) {
+  height: 38px;
+  padding-right: 8px;
+  margin-bottom: 3px;
+  border-radius: 7px;
+}
+
+.manage-category-tree :deep(.el-tree-node__content:hover) {
+  background: #f4f7fb;
+}
+
+.manage-category-tree :deep(.el-tree-node.is-current > .el-tree-node__content) {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+}
+
+.manage-category-node {
+  display: flex;
+  min-width: 0;
+  flex: 1;
+  align-items: center;
+  gap: 8px;
+}
+
+.manage-category-node > span:first-child {
+  overflow: hidden;
+  font-size: 13px;
+  font-weight: 500;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.manage-category-node > span:last-child {
+  min-width: 22px;
+  padding: 0 6px;
+  margin-left: auto;
+  font-size: 10px;
+  line-height: 18px;
+  color: #718096;
+  text-align: center;
+  background: #eef2f7;
+  border-radius: 9px;
+}
+
+.manage-components {
+  display: flex;
+  flex-direction: column;
+}
+
+.manage-components__header {
+  min-height: 64px;
+  padding: 0 16px;
+  gap: 16px;
+}
+
+.manage-components__header h3 {
+  font-size: 15px;
+  font-weight: 700;
+  line-height: 21px;
+}
+
+.manage-components__actions {
+  display: flex;
+  min-width: 0;
+  align-items: center;
+  gap: 10px;
+}
+
+.manage-components__actions > :deep(.el-input) {
+  width: 190px;
+}
+
+.manage-components__actions :deep(.el-input__wrapper) {
+  border-radius: 7px;
+}
+
+.manage-components__actions :deep(.el-button > span) {
+  gap: 6px;
+}
+
+.manage-component-grid {
+  display: grid;
+  padding: 14px;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 10px;
+}
+
+.manage-component-card {
+  position: relative;
+  min-width: 0;
+  padding: 8px;
+  background: #f8fafc;
+  border: 1px solid #e3e8f0;
+  border-radius: 9px;
+  transition:
+    border-color 0.18s ease,
+    box-shadow 0.18s ease,
+    transform 0.18s ease;
+}
+
+.manage-component-card:hover {
+  border-color: var(--el-color-primary-light-5);
+  transform: translateY(-1px);
+  box-shadow: 0 5px 14px rgb(31 45 61 / 8%);
+}
+
+.manage-component-card__preview {
+  display: flex;
+  height: 74px;
+  overflow: hidden;
+  background:
+    linear-gradient(45deg, #eef2f6 25%, transparent 25%) 0 0 / 14px 14px,
+    linear-gradient(45deg, transparent 75%, #eef2f6 75%) 0 0 / 14px 14px,
+    linear-gradient(45deg, transparent 75%, #eef2f6 75%) 7px -7px / 14px 14px,
+    linear-gradient(45deg, #eef2f6 25%, #fff 25%) 7px -7px / 14px 14px;
+  border-radius: 6px;
+  align-items: center;
+  justify-content: center;
+}
+
+.manage-component-card__image {
+  width: 64px;
+  height: 64px;
+}
+
+.manage-component-card__name {
+  display: block;
+  margin-top: 6px;
+  overflow: hidden;
+  font-size: 12px;
+  font-weight: 500;
+  line-height: 20px;
+  color: #475467;
+  text-align: center;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.manage-component-card__delete {
+  position: absolute;
+  top: 5px;
+  right: 5px;
+  width: 26px;
+  height: 26px;
+  color: var(--el-color-danger);
+  background: rgb(255 255 255 / 92%);
+  opacity: 0;
+  transform: translateY(-2px);
+  box-shadow: 0 2px 7px rgb(31 45 61 / 12%);
+  transition:
+    opacity 0.18s ease,
+    transform 0.18s ease;
+}
+
+.manage-component-card:hover .manage-component-card__delete,
+.manage-component-card__delete:focus-visible {
+  opacity: 1;
+  transform: translateY(0);
+}
+
+.manage-components__scrollbar :deep(.el-empty) {
+  height: 100%;
+  padding: 30px 0;
+}
+
+.manage-dialog__footer {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.manage-dialog__footer > span {
+  font-size: 12px;
+  color: #8a95a6;
+}
+
+.manage-dialog__footer :deep(.el-button) {
+  min-width: 82px;
+}
+
+.dark .component-library {
+  background: rgb(24 31 43 / 97%);
+}
+
+.dark .component-library__header h2,
+.dark .category-title__name {
+  color: #edf2f8;
+}
+
+.dark .component-library__header p,
+.dark .component-library__footer > span {
+  color: #8d99aa;
+}
+
+.dark .component-library__search :deep(.el-input__wrapper),
+.dark .category-list :deep(.el-collapse-item) {
+  background: #202735;
+  border-color: #303949;
+}
+
+.dark .category-title__count {
+  color: #aeb8c7;
+  background: #2b3444;
+}
+
+.dark .component-card {
+  background: #252d3b;
+  border-color: #354052;
+}
+
+.dark .component-card:hover {
+  background: #2a3445;
+  border-color: var(--el-color-primary-dark-2);
+}
+
+.dark .component-card__name {
+  color: #cad3df;
+}
+
+.dark .component-library__footer {
+  border-color: #303949;
+}
+</style>
+
 <style>
 <style>
-#mt-left-aside .el-collapse-item__header,
-#mt-left-aside .el-collapse-item__wrap {
-  background-color: transparent !important;
+.component-manage-dialog {
+  max-width: calc(100vw - 40px);
+  overflow: hidden;
+  border-radius: 14px;
+  box-shadow: 0 20px 60px rgb(15 23 42 / 20%);
+}
+
+.component-manage-dialog .el-dialog__header {
+  padding: 17px 20px;
+  margin: 0;
+  border-bottom: 1px solid #e4e9f0;
+}
+
+.component-manage-dialog .el-dialog__headerbtn {
+  top: 16px;
+  right: 16px;
+  width: 32px;
+  height: 32px;
+  border-radius: 7px;
+}
+
+.component-manage-dialog .el-dialog__headerbtn:hover {
+  background: #f1f4f8;
+}
+
+.component-manage-dialog .el-dialog__body {
+  padding: 14px 18px;
+  background: #f6f8fb;
+}
+
+.component-manage-dialog .el-dialog__footer {
+  padding: 12px 18px;
+  border-top: 1px solid #e4e9f0;
 }
 }
 </style>
 </style>

+ 295 - 46
src/components/mt-edit/components/layout/main-panel/index.vue

@@ -1,5 +1,12 @@
 <template>
 <template>
-  <div class="overflow-hidden w-1/1 h-1/1" @wheel="onMouseWheel">
+  <div
+    ref="viewportRef"
+    class="main-panel overflow-hidden w-1/1 h-1/1"
+    :class="{
+      'main-panel--pan-mode': panModeEnabled,
+      'main-panel--panning': ['beginDragCanvas', 'runDragCanvas'].includes(globalStore.intention)
+    }"
+    @wheel="onMouseWheel">
     <div
     <div
       id="mtCanvasArea"
       id="mtCanvasArea"
       ref="canvasAreaRef"
       ref="canvasAreaRef"
@@ -69,12 +76,73 @@
       :menu-info="contextMenuStore.menuInfo"
       :menu-info="contextMenuStore.menuInfo"
       :show="globalStore.intention == 'showContextMenu'"
       :show="globalStore.intention == 'showContextMenu'"
       @on-context-menu-click="onContextMenuClick" />
       @on-context-menu-click="onContextMenuClick" />
+    <div
+      class="zoom-toolbar"
+      role="toolbar"
+      aria-label="画布操作与缩放"
+      @mousedown.stop
+      @click.stop>
+      <div class="zoom-toolbar__modes" aria-label="画布操作模式">
+        <el-tooltip content="选择组件或框选" placement="top">
+          <el-button
+            class="zoom-toolbar__icon zoom-toolbar__mode"
+            :class="{ 'is-active': !panModeEnabled }"
+            text
+            aria-label="切换到选择模式"
+            @click="panModeEnabled = false">
+            <el-icon><Pointer /></el-icon>
+          </el-button>
+        </el-tooltip>
+        <el-tooltip content="按住左键拖动画布" placement="top">
+          <el-button
+            class="zoom-toolbar__icon zoom-toolbar__mode"
+            :class="{ 'is-active': panModeEnabled }"
+            text
+            aria-label="切换到画布拖拽模式"
+            @click="panModeEnabled = true">
+            <el-icon><Rank /></el-icon>
+          </el-button>
+        </el-tooltip>
+      </div>
+      <span class="zoom-toolbar__divider"></span>
+      <el-tooltip content="缩小 10%" placement="top">
+        <el-button
+          class="zoom-toolbar__icon"
+          text
+          :disabled="!canZoomOut"
+          aria-label="缩小画布"
+          @click="zoomCanvas('out')">
+          <el-icon><Minus /></el-icon>
+        </el-button>
+      </el-tooltip>
+      <span class="zoom-toolbar__value" aria-live="polite">{{ scalePercentage }}%</span>
+      <el-tooltip content="放大 10%" placement="top">
+        <el-button
+          class="zoom-toolbar__icon"
+          text
+          :disabled="!canZoomIn"
+          aria-label="放大画布"
+          @click="zoomCanvas('in')">
+          <el-icon><Plus /></el-icon>
+        </el-button>
+      </el-tooltip>
+      <span class="zoom-toolbar__divider"></span>
+      <el-tooltip content="恢复到 100%" placement="top">
+        <el-button class="zoom-toolbar__text" text @click="resetCanvasScale">100%</el-button>
+      </el-tooltip>
+      <el-tooltip content="缩放并居中显示完整画布" placement="top">
+        <el-button class="zoom-toolbar__fit" text @click="fitCanvas">
+          <el-icon><FullScreen /></el-icon>
+          <span>适应画布</span>
+        </el-button>
+      </el-tooltip>
+    </div>
   </div>
   </div>
 </template>
 </template>
 <script setup lang="ts">
 <script setup lang="ts">
 import { globalStore } from '@/components/mt-edit/store/global'
 import { globalStore } from '@/components/mt-edit/store/global'
 import { leftAsideStore } from '@/components/mt-edit/store/left-aside'
 import { leftAsideStore } from '@/components/mt-edit/store/left-aside'
-import { ElMessage } from 'element-plus'
+import { ElButton, ElIcon, ElMessage, ElTooltip } from 'element-plus'
 import RenderCore from '@/components/mt-edit/components/render-core/index.vue'
 import RenderCore from '@/components/mt-edit/components/render-core/index.vue'
 import PatternGrid from '@/components/mt-edit/components/pattern-grid/index.vue'
 import PatternGrid from '@/components/mt-edit/components/pattern-grid/index.vue'
 import { computed, ref, reactive, onMounted, onUnmounted } from 'vue'
 import { computed, ref, reactive, onMounted, onUnmounted } from 'vue'
@@ -91,6 +159,7 @@ import {
   getRectCoordinate,
   getRectCoordinate,
   getRectCenterCoordinate,
   getRectCenterCoordinate,
   handleAlign,
   handleAlign,
+  isDeleteShortcutKey,
   isSystemShortcutKey
   isSystemShortcutKey
 } from '@/components/mt-edit/utils'
 } from '@/components/mt-edit/utils'
 import type {
 import type {
@@ -110,6 +179,16 @@ import ContextMenu from '@/components/mt-edit/components/context-menu/index.vue'
 import { contextMenuStore } from '@/components/mt-edit/store/context-menu'
 import { contextMenuStore } from '@/components/mt-edit/store/context-menu'
 import DrawLineRender from '@/components/mt-edit/components/draw-line-render/index.vue'
 import DrawLineRender from '@/components/mt-edit/components/draw-line-render/index.vue'
 import { configStore } from '@/components/mt-edit/store/config'
 import { configStore } from '@/components/mt-edit/store/config'
+import { FullScreen, Minus, Plus, Pointer, Rank } from '@element-plus/icons-vue'
+import {
+  calculateFitCanvas,
+  MAX_CANVAS_SCALE,
+  MIN_CANVAS_SCALE,
+  normalizeCanvasScale,
+  stepCanvasScale,
+  type CanvasScaleDirection
+} from '@/components/mt-edit/utils/canvas-scale'
+import { shouldStartCanvasPan } from '@/components/mt-edit/utils/canvas-pan'
 type MainPanelProps = {
 type MainPanelProps = {
   groupEnabled: boolean
   groupEnabled: boolean
   unGroupEnabled: boolean
   unGroupEnabled: boolean
@@ -120,10 +199,12 @@ const mainPanelProps = withDefaults(defineProps<MainPanelProps>(), {
   lineAppendEnable: false
   lineAppendEnable: false
 })
 })
 const canvasAreaRef = ref()
 const canvasAreaRef = ref()
+const viewportRef = ref<HTMLElement>()
 const selectedAreaRef = ref<InstanceType<typeof SelectedArea>>()
 const selectedAreaRef = ref<InstanceType<typeof SelectedArea>>()
 const dragCanvasRef = ref<InstanceType<typeof DragCanvas>>()
 const dragCanvasRef = ref<InstanceType<typeof DragCanvas>>()
 const dragLineRenderRef = ref<InstanceType<typeof DrawLineRender>>()
 const dragLineRenderRef = ref<InstanceType<typeof DrawLineRender>>()
 const shortcut_drag_select_item_id = ref('')
 const shortcut_drag_select_item_id = ref('')
+const panModeEnabled = ref(false)
 // 是否需要重新计算画布缩放中心点
 // 是否需要重新计算画布缩放中心点
 const is_need_recal_center = ref(true)
 const is_need_recal_center = ref(true)
 let is_listen_keydown = false // 是否已经监听了键盘事件
 let is_listen_keydown = false // 是否已经监听了键盘事件
@@ -141,6 +222,9 @@ const done_json = computed({
     globalStore.setGlobalStoreDoneJson(val)
     globalStore.setGlobalStoreDoneJson(val)
   }
   }
 })
 })
+const scalePercentage = computed(() => Math.round(globalStore.canvasCfg.scale * 100))
+const canZoomOut = computed(() => globalStore.canvasCfg.scale > MIN_CANVAS_SCALE)
+const canZoomIn = computed(() => globalStore.canvasCfg.scale < MAX_CANVAS_SCALE)
 const commitActiveRightAsideEditor = () => {
 const commitActiveRightAsideEditor = () => {
   const activeElement = document.activeElement
   const activeElement = document.activeElement
   if (!(activeElement instanceof HTMLElement)) {
   if (!(activeElement instanceof HTMLElement)) {
@@ -247,6 +331,9 @@ const onDrop = (e: DragEvent | TouchEvent, isTouch?: boolean) => {
   const is_vertical_line = deep_find_cfg.id === 'sys-line'
   const is_vertical_line = deep_find_cfg.id === 'sys-line'
   // 竖线
   // 竖线
   const is_horizontal_line = deep_find_cfg.id === 'sys-line-vertical'
   const is_horizontal_line = deep_find_cfg.id === 'sys-line-vertical'
+  const is_status = deep_find_cfg.id === 'status-vue'
+  const is_hikvision_video = deep_find_cfg.id === 'hikvision-video-vue'
+  const is_key_value = deep_find_cfg.id === 'kv-vue'
   //根据配置创建图形
   //根据配置创建图形
   const { realityX, realityY } = getRealityXY(e, canvasAreaRef.value?.getBoundingClientRect())
   const { realityX, realityY } = getRealityXY(e, canvasAreaRef.value?.getBoundingClientRect())
   const create_item: IDoneJson = {
   const create_item: IDoneJson = {
@@ -256,8 +343,26 @@ const onDrop = (e: DragEvent | TouchEvent, isTouch?: boolean) => {
     binfo: {
     binfo: {
       left: alignToGrid(realityX / globalStore.canvasCfg.scale, grid_align_size.value),
       left: alignToGrid(realityX / globalStore.canvasCfg.scale, grid_align_size.value),
       top: alignToGrid(realityY / globalStore.canvasCfg.scale, grid_align_size.value),
       top: alignToGrid(realityY / globalStore.canvasCfg.scale, grid_align_size.value),
-      width: is_vertical_line ? 100 : is_horizontal_line ? 0 : 50,
-      height: is_horizontal_line ? 100 : is_vertical_line ? 0 : 50,
+      width: is_vertical_line
+        ? 100
+        : is_horizontal_line
+          ? 0
+          : is_status
+            ? 36
+            : is_hikvision_video
+              ? 480
+              : is_key_value
+                ? 100
+                : 50,
+      height: is_horizontal_line
+        ? 100
+        : is_vertical_line
+          ? 0
+          : is_status
+            ? 36
+            : is_hikvision_video
+              ? 270
+              : 50,
       angle: 0
       angle: 0
     },
     },
     resize: is_line ? false : true,
     resize: is_line ? false : true,
@@ -281,7 +386,7 @@ const onDrop = (e: DragEvent | TouchEvent, isTouch?: boolean) => {
   globalStore.setSingleSelect(create_item.id)
   globalStore.setSingleSelect(create_item.id)
   globalStore.setIntention('none')
   globalStore.setIntention('none')
   globalStore.setCreateItemInfo(null)
   globalStore.setCreateItemInfo(null)
-  cacheStore.addHistory(done_json_temp)
+  cacheStore.addHistory()
 }
 }
 const onDragOver = (e: DragEvent) => {
 const onDragOver = (e: DragEvent) => {
   e.preventDefault()
   e.preventDefault()
@@ -289,8 +394,19 @@ const onDragOver = (e: DragEvent) => {
 const onRenderCoreMouseDown = (item: IDoneJson, e: MouseEvent) => {
 const onRenderCoreMouseDown = (item: IDoneJson, e: MouseEvent) => {
   commitActiveRightAsideEditor()
   commitActiveRightAsideEditor()
   beginListenerKeyDown()
   beginListenerKeyDown()
-  if (isSystemShortcutKey(e)) {
-    shortcut_drag_select_item_id.value = item.lock ? '' : item.id
+  const systemShortcutPressed = isSystemShortcutKey(e)
+  if (
+    shouldStartCanvasPan({
+      button: e.button,
+      panModeEnabled: panModeEnabled.value,
+      systemShortcutPressed,
+      canvasLocked: globalStore.lock
+    })
+  ) {
+    shortcut_drag_select_item_id.value =
+      systemShortcutPressed && !panModeEnabled.value && !globalStore.lock && !item.lock
+        ? item.id
+        : ''
     beginDragCanvas(e)
     beginDragCanvas(e)
     return
     return
   }
   }
@@ -337,17 +453,19 @@ const onRenderCoreMouseDown = (item: IDoneJson, e: MouseEvent) => {
 const onMouseDown = (e: MouseEvent) => {
 const onMouseDown = (e: MouseEvent) => {
   commitActiveRightAsideEditor()
   commitActiveRightAsideEditor()
   beginListenerKeyDown()
   beginListenerKeyDown()
-  if (isSystemShortcutKey(e)) {
+  if (
+    shouldStartCanvasPan({
+      button: e.button,
+      panModeEnabled: panModeEnabled.value,
+      systemShortcutPressed: isSystemShortcutKey(e),
+      canvasLocked: globalStore.lock
+    })
+  ) {
     shortcut_drag_select_item_id.value = ''
     shortcut_drag_select_item_id.value = ''
     beginDragCanvas(e)
     beginDragCanvas(e)
     return
     return
   }
   }
   globalStore.cancelAllSelect()
   globalStore.cancelAllSelect()
-  // 锁定状态或者右键点击进行画布拖动
-  if (globalStore.lock || e.button == 2) {
-    beginDragCanvas(e)
-    return
-  }
   if (mainPanelProps.lineAppendEnable) {
   if (mainPanelProps.lineAppendEnable) {
     globalStore.setIntention('drawSysLineStart')
     globalStore.setIntention('drawSysLineStart')
     const { realityX, realityY } = getRealityXY(e, canvasAreaRef.value?.getBoundingClientRect())
     const { realityX, realityY } = getRealityXY(e, canvasAreaRef.value?.getBoundingClientRect())
@@ -430,7 +548,7 @@ const createGroupItem = () => {
   done_json_temp.push(create_group_item)
   done_json_temp.push(create_group_item)
   globalStore.setGlobalStoreDoneJson(done_json_temp)
   globalStore.setGlobalStoreDoneJson(done_json_temp)
   globalStore.setSingleSelect(create_group_item.id)
   globalStore.setSingleSelect(create_group_item.id)
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 }
 const onUngroup = () => {
 const onUngroup = () => {
   // 根据选中的组件id,找到对应的需要解除组合的组件
   // 根据选中的组件id,找到对应的需要解除组合的组件
@@ -461,7 +579,7 @@ const onUngroup = () => {
   )
   )
   globalStore.setGlobalStoreDoneJson([...done_json_temp, ...split_group_items])
   globalStore.setGlobalStoreDoneJson([...done_json_temp, ...split_group_items])
   globalStore.setSelectItems(split_group_items.map((m) => m.id))
   globalStore.setSelectItems(split_group_items.map((m) => m.id))
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 }
 const onItemMove = ({ move_item_bounding_info, move_binfo }: onItemMoveParams) => {
 const onItemMove = ({ move_item_bounding_info, move_binfo }: onItemMoveParams) => {
   // 拿到画布的边界信息
   // 拿到画布的边界信息
@@ -565,7 +683,9 @@ const onMoveMouseUp = () => {
   //移动完毕之后隐藏参考线
   //移动完毕之后隐藏参考线
   globalStore.guideCfg.x.display = false
   globalStore.guideCfg.x.display = false
   globalStore.guideCfg.y.display = false
   globalStore.guideCfg.y.display = false
-  cacheStore.addHistory(globalStore.done_json)
+  globalStore.adsorp_diff.x = 0
+  globalStore.adsorp_diff.y = 0
+  cacheStore.addHistory()
 }
 }
 /**
 /**
  * 缓存连线可以吸附的四个点
  * 缓存连线可以吸附的四个点
@@ -713,7 +833,7 @@ const onItemResizeDone = (item: IDoneJson) => {
     canvasAreaRef.value,
     canvasAreaRef.value,
     globalStore.canvasCfg.scale
     globalStore.canvasCfg.scale
   )
   )
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 }
 const onItemRotateDone = (item: IDoneJson) => {
 const onItemRotateDone = (item: IDoneJson) => {
   const update_lines = globalStore.done_json.filter(
   const update_lines = globalStore.done_json.filter(
@@ -727,7 +847,7 @@ const onItemRotateDone = (item: IDoneJson) => {
     canvasAreaRef.value,
     canvasAreaRef.value,
     globalStore.canvasCfg.scale
     globalStore.canvasCfg.scale
   )
   )
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 }
 const onAlignSelected = (
 const onAlignSelected = (
   type:
   type:
@@ -751,7 +871,7 @@ const onAlignSelected = (
     globalStore.canvasCfg.scale,
     globalStore.canvasCfg.scale,
     globalStore.done_json
     globalStore.done_json
   )
   )
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 }
 const dragCanvasMouseDown = () => {
 const dragCanvasMouseDown = () => {
   init_drag_offset.x = globalStore.canvasCfg.drag_offset.x
   init_drag_offset.x = globalStore.canvasCfg.drag_offset.x
@@ -907,7 +1027,7 @@ const onContextMenuPaste = (offset_x: number, offset_y: number) => {
   })
   })
   globalStore.setGlobalStoreDoneJson([...globalStore.done_json, ...new_items])
   globalStore.setGlobalStoreDoneJson([...globalStore.done_json, ...new_items])
   globalStore.setSelectItems(new_items.map((m) => m.id))
   globalStore.setSelectItems(new_items.map((m) => m.id))
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 }
 /**
 /**
  * 删除
  * 删除
@@ -917,7 +1037,7 @@ const onContextMenuDelete = () => {
     return
     return
   }
   }
   globalStore.deleteSelectedItems()
   globalStore.deleteSelectedItems()
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 }
 /**
 /**
  * 组合
  * 组合
@@ -946,7 +1066,7 @@ const onContextMoveTop = () => {
     ...globalStore.done_json.filter((f) => f.id !== item.id),
     ...globalStore.done_json.filter((f) => f.id !== item.id),
     item
     item
   ])
   ])
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 }
 const onContextMoveBottom = () => {
 const onContextMoveBottom = () => {
   if (globalStore.selected_items_id.length !== 1) {
   if (globalStore.selected_items_id.length !== 1) {
@@ -957,7 +1077,7 @@ const onContextMoveBottom = () => {
     item,
     item,
     ...globalStore.done_json.filter((f) => f.id !== item.id)
     ...globalStore.done_json.filter((f) => f.id !== item.id)
   ])
   ])
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 }
 const onContextMoveUp = () => {
 const onContextMoveUp = () => {
   if (globalStore.selected_items_id.length !== 1) {
   if (globalStore.selected_items_id.length !== 1) {
@@ -973,7 +1093,7 @@ const onContextMoveUp = () => {
   globalStore.done_json[index] = globalStore.done_json[index + 1]
   globalStore.done_json[index] = globalStore.done_json[index + 1]
   globalStore.done_json[index + 1] = temp
   globalStore.done_json[index + 1] = temp
   globalStore.setGlobalStoreDoneJson(globalStore.done_json)
   globalStore.setGlobalStoreDoneJson(globalStore.done_json)
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 }
 const onContextMoveDown = () => {
 const onContextMoveDown = () => {
   if (globalStore.selected_items_id.length !== 1) {
   if (globalStore.selected_items_id.length !== 1) {
@@ -990,23 +1110,13 @@ const onContextMoveDown = () => {
   globalStore.done_json[index] = globalStore.done_json[index - 1]
   globalStore.done_json[index] = globalStore.done_json[index - 1]
   globalStore.done_json[index - 1] = temp
   globalStore.done_json[index - 1] = temp
   globalStore.setGlobalStoreDoneJson(globalStore.done_json)
   globalStore.setGlobalStoreDoneJson(globalStore.done_json)
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 }
 const onUndo = () => {
 const onUndo = () => {
-  if (cacheStore.historyIndex == 0) {
-    return
-  }
-  cacheStore.historyIndex -= 1
-  globalStore.setGlobalStoreDoneJson(objectDeepClone(cacheStore.history[cacheStore.historyIndex]))
-  globalStore.setSelectItems([])
+  cacheStore.undo()
 }
 }
 const onRedo = () => {
 const onRedo = () => {
-  if (cacheStore.historyIndex == cacheStore.history.length - 1) {
-    return
-  }
-  cacheStore.historyIndex += 1
-  globalStore.setGlobalStoreDoneJson(objectDeepClone(cacheStore.history[cacheStore.historyIndex]))
-  globalStore.setSelectItems([])
+  cacheStore.redo()
 }
 }
 /**
 /**
  * 右键菜单点击事件
  * 右键菜单点击事件
@@ -1093,7 +1203,7 @@ const onKeydown = (e: KeyboardEvent) => {
     onContextMenuPaste(10, 10)
     onContextMenuPaste(10, 10)
   }
   }
   //删除
   //删除
-  else if (e.key.toLocaleLowerCase() === 'delete') {
+  else if (isDeleteShortcutKey(e)) {
     e.preventDefault()
     e.preventDefault()
     onContextMenuDelete()
     onContextMenuDelete()
   }
   }
@@ -1190,17 +1300,58 @@ const onKeydown = (e: KeyboardEvent) => {
       canvasAreaRef.value,
       canvasAreaRef.value,
       globalStore.canvasCfg.scale
       globalStore.canvasCfg.scale
     )
     )
-    cacheStore.addHistory(globalStore.done_json)
+    cacheStore.addHistory()
+  }
+}
+const updateCanvasScale = (scale: number) => {
+  const nextScale = normalizeCanvasScale(scale)
+  if (nextScale === globalStore.canvasCfg.scale) {
+    return false
   }
   }
+  globalStore.canvasCfg.scale = nextScale
+  return true
 }
 }
-const onMouseWheel = (e: any) => {
+const zoomCanvas = (direction: CanvasScaleDirection) => {
+  updateCanvasScale(stepCanvasScale(globalStore.canvasCfg.scale, direction))
+}
+const resetCanvasScale = () => {
+  updateCanvasScale(1)
+}
+const fitCanvas = () => {
+  const viewport = viewportRef.value
+  if (!viewport) {
+    return
+  }
+  const fitResult = calculateFitCanvas({
+    viewportWidth: viewport.clientWidth,
+    viewportHeight: viewport.clientHeight,
+    canvasWidth: globalStore.canvasCfg.width,
+    canvasHeight: globalStore.canvasCfg.height
+  })
+  const hasChange =
+    globalStore.canvasCfg.scale !== fitResult.scale ||
+    globalStore.canvasCfg.transform_origin.x !== fitResult.transformOrigin.x ||
+    globalStore.canvasCfg.transform_origin.y !== fitResult.transformOrigin.y ||
+    globalStore.canvasCfg.drag_offset.x !== fitResult.dragOffset.x ||
+    globalStore.canvasCfg.drag_offset.y !== fitResult.dragOffset.y
+  if (!hasChange) {
+    return
+  }
+  globalStore.canvasCfg.scale = fitResult.scale
+  globalStore.canvasCfg.transform_origin = fitResult.transformOrigin
+  globalStore.canvasCfg.drag_offset = fitResult.dragOffset
+}
+const onMouseWheel = (e: WheelEvent) => {
   if (isSystemShortcutKey(e)) {
   if (isSystemShortcutKey(e)) {
     e.preventDefault()
     e.preventDefault()
     e.stopPropagation()
     e.stopPropagation()
-    if (e.deltaY > 0) {
-      globalStore.canvasCfg.scale = (globalStore.canvasCfg.scale * 10 - 1) / 10
-    } else if (e.deltaY < 0) {
-      globalStore.canvasCfg.scale = (globalStore.canvasCfg.scale * 10 + 1) / 10
+    const direction = e.deltaY > 0 ? 'out' : e.deltaY < 0 ? 'in' : null
+    if (!direction) {
+      return
+    }
+    const didScale = updateCanvasScale(stepCanvasScale(globalStore.canvasCfg.scale, direction))
+    if (!didScale) {
+      return
     }
     }
     if (!is_need_recal_center.value) {
     if (!is_need_recal_center.value) {
       return
       return
@@ -1209,7 +1360,7 @@ const onMouseWheel = (e: any) => {
     const new_transform_origin_x = parseInt(realityX / globalStore.canvasCfg.scale + '')
     const new_transform_origin_x = parseInt(realityX / globalStore.canvasCfg.scale + '')
     const new_transform_origin_y = parseInt(realityY / globalStore.canvasCfg.scale + '')
     const new_transform_origin_y = parseInt(realityY / globalStore.canvasCfg.scale + '')
     if (
     if (
-      globalStore.canvasCfg.transform_origin.x !== new_transform_origin_x &&
+      globalStore.canvasCfg.transform_origin.x !== new_transform_origin_x ||
       globalStore.canvasCfg.transform_origin.y !== new_transform_origin_y
       globalStore.canvasCfg.transform_origin.y !== new_transform_origin_y
     ) {
     ) {
       globalStore.canvasCfg.transform_origin = {
       globalStore.canvasCfg.transform_origin = {
@@ -1249,7 +1400,7 @@ const onDrawLineEnd = (line_item: IDoneJson) => {
   globalStore.setGlobalStoreDoneJson(done_json_temp)
   globalStore.setGlobalStoreDoneJson(done_json_temp)
   globalStore.setSingleSelect(new_line_item.id)
   globalStore.setSingleSelect(new_line_item.id)
   globalStore.setIntention('none')
   globalStore.setIntention('none')
-  cacheStore.addHistory(done_json_temp)
+  cacheStore.addHistory()
 }
 }
 onMounted(() => {
 onMounted(() => {
   beginListenerKeyDown()
   beginListenerKeyDown()
@@ -1268,6 +1419,20 @@ defineExpose({
 })
 })
 </script>
 </script>
 <style scoped>
 <style scoped>
+.main-panel {
+  position: relative;
+}
+
+.main-panel--pan-mode :deep(.canvasArea),
+.main-panel--pan-mode :deep(.canvasArea *) {
+  cursor: grab !important;
+}
+
+.main-panel--panning :deep(.canvasArea),
+.main-panel--panning :deep(.canvasArea *) {
+  cursor: grabbing !important;
+}
+
 .canvasArea {
 .canvasArea {
   position: relative;
   position: relative;
   top: v-bind('globalStore.canvasCfg.drag_offset.y + "px"');
   top: v-bind('globalStore.canvasCfg.drag_offset.y + "px"');
@@ -1282,6 +1447,90 @@ defineExpose({
   );
   );
 }
 }
 
 
+.zoom-toolbar {
+  position: absolute;
+  right: 16px;
+  bottom: 16px;
+  z-index: 20;
+  display: flex;
+  height: 38px;
+  padding: 4px;
+  color: #344054;
+  background: rgb(255 255 255 / 96%);
+  border: 1px solid #e3e8ef;
+  border-radius: 10px;
+  box-shadow: 0 6px 18px rgb(31 45 61 / 12%);
+  align-items: center;
+  user-select: none;
+}
+
+.zoom-toolbar__icon,
+.zoom-toolbar__text,
+.zoom-toolbar__fit {
+  height: 30px;
+  padding: 0 8px;
+  color: #475467;
+  border-radius: 7px;
+}
+
+.zoom-toolbar__icon {
+  width: 30px;
+  padding: 0;
+}
+
+.zoom-toolbar__modes {
+  display: flex;
+  padding: 2px;
+  background: #f2f4f7;
+  border-radius: 8px;
+}
+
+.zoom-toolbar__mode.is-active {
+  color: #fff;
+  background: var(--el-color-primary);
+  box-shadow: 0 2px 5px rgb(64 158 255 / 28%);
+}
+
+.zoom-toolbar__icon:hover,
+.zoom-toolbar__text:hover,
+.zoom-toolbar__fit:hover {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+}
+
+.zoom-toolbar__mode {
+  width: 30px;
+  min-width: 30px;
+  padding: 0;
+}
+
+.zoom-toolbar__mode.is-active:hover {
+  color: #fff;
+  background: var(--el-color-primary);
+}
+
+.zoom-toolbar__value {
+  min-width: 52px;
+  font-size: 12px;
+  font-weight: 600;
+  line-height: 30px;
+  text-align: center;
+  font-variant-numeric: tabular-nums;
+}
+
+.zoom-toolbar__divider {
+  width: 1px;
+  height: 18px;
+  margin: 0 3px;
+  background: #e3e8ef;
+}
+
+.zoom-toolbar__fit {
+  display: inline-flex;
+  gap: 5px;
+  align-items: center;
+}
+
 #guide-x {
 #guide-x {
   position: absolute;
   position: absolute;
   top: v-bind('globalStore.guideCfg.x.top+"px"');
   top: v-bind('globalStore.guideCfg.x.top+"px"');

+ 268 - 0
src/components/mt-edit/components/layout/right-aside/hikvision-camera-select.vue

@@ -0,0 +1,268 @@
+<template>
+  <div class="camera-select">
+    <el-input
+      :model-value="modelValue?.name || ''"
+      readonly
+      clearable
+      placeholder="请选择监控点"
+      @click="openDialog"
+      @clear="clearSelection">
+      <template #append>
+        <el-button aria-label="选择监控点" @click="openDialog">
+          <el-icon><VideoCamera /></el-icon>
+        </el-button>
+      </template>
+    </el-input>
+
+    <el-dialog
+      v-model="dialogVisible"
+      title="选择监控点"
+      width="560px"
+      append-to-body
+      destroy-on-close
+      @opened="loadRootTree">
+      <div class="camera-dialog">
+        <el-input
+          v-model="keyword"
+          clearable
+          :prefix-icon="Search"
+          placeholder="输入监控点名称,回车搜索"
+          @keydown.enter="handleSearch"
+          @clear="loadRootTree" />
+
+        <div v-loading="loading" class="camera-tree-wrap">
+          <el-tree
+            v-if="!loading"
+            :key="treeVersion"
+            :data="treeData"
+            :props="treeProps"
+            :load="loadTreeNode"
+            :lazy="!searchMode"
+            :default-expand-all="searchMode"
+            :current-node-key="draftSelection?.indexCode"
+            node-key="indexCode"
+            highlight-current
+            :expand-on-click-node="false"
+            empty-text="暂无监控点"
+            @node-click="handleNodeClick">
+            <template #default="{ data }">
+              <div
+                class="camera-tree-node"
+                :class="{
+                  'is-camera': data.nodeType === 'camera',
+                  'is-offline': data.nodeType === 'camera' && !isCameraSelectable(data)
+                }">
+                <el-icon>
+                  <Location v-if="data.nodeType === 'region'" />
+                  <VideoCamera v-else />
+                </el-icon>
+                <span>{{ data.name }}</span>
+                <i
+                  v-if="data.nodeType === 'camera'"
+                  class="camera-status"
+                  :class="{ online: isCameraSelectable(data) }"></i>
+              </div>
+            </template>
+          </el-tree>
+        </div>
+
+        <div class="camera-selection-summary">
+          当前选择:<strong>{{ draftSelection?.name || '未选择' }}</strong>
+        </div>
+      </div>
+
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :disabled="!draftSelection" @click="confirmSelection">
+          确定
+        </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { HikvisionApi, type HikvisionCameraNode, type HikvisionTreeNode } from '@/api/pms/hikvision'
+import { Location, Search, VideoCamera } from '@element-plus/icons-vue'
+import type { LoadFunction } from 'element-plus/es/components/tree/src/tree.type'
+import { ElMessage } from 'element-plus'
+import { ref } from 'vue'
+
+const props = defineProps<{
+  modelValue?: HikvisionCameraNode | null
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: HikvisionCameraNode | null]
+}>()
+
+const dialogVisible = ref(false)
+const keyword = ref('')
+const loading = ref(false)
+const searchMode = ref(false)
+const treeVersion = ref(0)
+const treeData = ref<HikvisionTreeNode[]>([])
+const draftSelection = ref<HikvisionCameraNode | null>(null)
+let requestId = 0
+
+const treeProps = {
+  children: 'children',
+  label: 'name',
+  isLeaf: (data: HikvisionTreeNode) => data.nodeType === 'camera'
+}
+
+function isCameraSelectable(data: HikvisionTreeNode): data is HikvisionCameraNode {
+  return data.nodeType === 'camera' && data.available !== false && data.onlineStatus !== false
+}
+
+function openDialog() {
+  draftSelection.value = props.modelValue ? { ...props.modelValue } : null
+  keyword.value = ''
+  dialogVisible.value = true
+}
+
+async function loadRootTree() {
+  const currentRequestId = ++requestId
+  loading.value = true
+  try {
+    const data = await HikvisionApi.getTreeNodes('-1')
+    if (currentRequestId !== requestId) return
+    treeData.value = data
+    searchMode.value = false
+    treeVersion.value += 1
+  } catch {
+    if (currentRequestId === requestId) ElMessage.error('监控点目录加载失败')
+  } finally {
+    if (currentRequestId === requestId) loading.value = false
+  }
+}
+
+async function handleSearch(event?: KeyboardEvent) {
+  if (event?.isComposing || event?.keyCode === 229) return
+  const searchKeyword = keyword.value.trim()
+  if (!searchKeyword) {
+    await loadRootTree()
+    return
+  }
+
+  const currentRequestId = ++requestId
+  loading.value = true
+  try {
+    const data = await HikvisionApi.searchTree(searchKeyword)
+    if (currentRequestId !== requestId) return
+    treeData.value = data
+    searchMode.value = true
+    treeVersion.value += 1
+  } catch {
+    if (currentRequestId === requestId) ElMessage.error('监控点搜索失败')
+  } finally {
+    if (currentRequestId === requestId) loading.value = false
+  }
+}
+
+const loadTreeNode: LoadFunction = async (node, resolve, reject) => {
+  if (node.level === 0) {
+    resolve(treeData.value)
+    return
+  }
+
+  const data = node.data as HikvisionTreeNode
+  if (data.nodeType !== 'region') {
+    resolve([])
+    return
+  }
+  if (data.children?.length) {
+    resolve(data.children)
+    return
+  }
+
+  try {
+    resolve(await HikvisionApi.getTreeNodes(data.indexCode))
+  } catch {
+    reject()
+  }
+}
+
+function handleNodeClick(data: HikvisionTreeNode) {
+  if (data.nodeType !== 'camera') return
+  if (!isCameraSelectable(data)) {
+    ElMessage.warning(`${data.name} 不在线,不能选择`)
+    return
+  }
+  draftSelection.value = { ...data }
+}
+
+function clearSelection() {
+  emit('update:modelValue', null)
+}
+
+function confirmSelection() {
+  if (!draftSelection.value) return
+  emit('update:modelValue', { ...draftSelection.value })
+  dialogVisible.value = false
+}
+</script>
+
+<style scoped>
+.camera-select,
+.camera-select :deep(.el-input) {
+  width: 100%;
+}
+
+.camera-dialog {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.camera-tree-wrap {
+  height: 360px;
+  padding: 8px;
+  overflow: auto;
+  border: 1px solid var(--el-border-color-light);
+  border-radius: 6px;
+}
+
+.camera-tree-node {
+  display: flex;
+  min-width: 0;
+  flex: 1;
+  align-items: center;
+  gap: 7px;
+}
+
+.camera-tree-node span {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.camera-tree-node.is-camera {
+  color: var(--el-color-primary);
+}
+
+.camera-tree-node.is-offline {
+  color: var(--el-text-color-placeholder);
+}
+
+.camera-status {
+  width: 7px;
+  height: 7px;
+  margin-left: auto;
+  background: var(--el-color-danger);
+  border-radius: 50%;
+}
+
+.camera-status.online {
+  background: var(--el-color-success);
+}
+
+.camera-selection-summary {
+  color: var(--el-text-color-secondary);
+}
+
+.camera-selection-summary strong {
+  color: var(--el-text-color-primary);
+}
+</style>

+ 224 - 23
src/components/mt-edit/components/layout/right-aside/index.vue

@@ -1,28 +1,51 @@
 <template>
 <template>
-  <div id="mt-right-aside" class="px-4">
-    <select-item-setting
-      v-if="selected_item_json"
-      :key="selected_item_id"
-      :item-json="selected_item_json"
-      :done-json="globalStore.done_json"
-      @update:item-json="onUpdateItemJson"
-      @add-history="onAddHistory">
-      <template v-if="hasDeviceBindSlot" #deviceBind="{ item }">
-        <slot name="deviceBind" :item="item"></slot> </template
-    ></select-item-setting>
-    <page-setting
-      v-else
-      v-model:canvasCfg="globalStore.canvasCfg"
-      v-model:grid-cfg="globalStore.gridCfg" />
+  <div id="mt-right-aside" class="property-panel">
+    <header class="property-panel__header">
+      <div>
+        <h2>{{ selected_item_json ? '组件属性' : '页面设置' }}</h2>
+        <p>{{ selected_item_json ? '调整选中组件的样式与行为' : '配置画布和编辑辅助功能' }}</p>
+      </div>
+      <span v-if="selected_item_json" class="property-panel__selection">
+        {{ selected_item_json.title || '未命名' }}
+      </span>
+    </header>
+
+    <main class="property-panel__content">
+      <el-scrollbar :view-style="{ minHeight: '100%' }">
+        <select-item-setting
+          v-if="selected_item_json"
+          :key="selected_item_id"
+          :item-json="selected_item_json"
+          :done-json="globalStore.done_json"
+          @update:item-json="onUpdateItemJson"
+          @add-history="onAddHistory"
+          @add-history-debounced="onAddHistoryDebounced">
+          <template v-if="hasDeviceBindSlot" #deviceBind="{ item }">
+            <slot name="deviceBind" :item="item"></slot>
+          </template>
+        </select-item-setting>
+        <page-setting
+          v-else
+          :canvas-cfg="globalStore.canvasCfg"
+          :grid-cfg="globalStore.gridCfg"
+          @update:canvas-cfg="onUpdateCanvasCfg"
+          @update:grid-cfg="onUpdateGridCfg" />
+      </el-scrollbar>
+    </main>
   </div>
   </div>
 </template>
 </template>
 <script setup lang="ts">
 <script setup lang="ts">
+import { cacheStore, getEditorHistoryCanvasCfg } from '@/components/mt-edit/store/cache'
+import { globalStore } from '@/components/mt-edit/store/global'
+import type {
+  IDoneJson,
+  IGlobalStoreCanvasCfg,
+  IGlobalStoreGridCfg
+} from '@/components/mt-edit/store/types'
+import { ElScrollbar } from 'element-plus'
 import { computed, useSlots } from 'vue'
 import { computed, useSlots } from 'vue'
 import PageSetting from './page-setting.vue'
 import PageSetting from './page-setting.vue'
 import SelectItemSetting from './select-item-setting.vue'
 import SelectItemSetting from './select-item-setting.vue'
-import { globalStore } from '@/components/mt-edit/store/global'
-import { cacheStore } from '@/components/mt-edit/store/cache'
-import type { IDoneJson } from '@/components/mt-edit/store/types'
 const slots = useSlots()
 const slots = useSlots()
 const selected_item_id = computed(() => globalStore.selected_items_id[0] || '')
 const selected_item_id = computed(() => globalStore.selected_items_id[0] || '')
 const selected_item_json = computed(() => {
 const selected_item_json = computed(() => {
@@ -39,17 +62,195 @@ const onUpdateItemJson = (item: IDoneJson) => {
   const done_json_temp = [...globalStore.done_json]
   const done_json_temp = [...globalStore.done_json]
   done_json_temp[find_index_item_json] = item
   done_json_temp[find_index_item_json] = item
   globalStore.setGlobalStoreDoneJson(done_json_temp)
   globalStore.setGlobalStoreDoneJson(done_json_temp)
+  cacheStore.addHistoryDebounced(`item-setting:${item.id}`)
 }
 }
 const onAddHistory = () => {
 const onAddHistory = () => {
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
+}
+const onAddHistoryDebounced = () => {
+  cacheStore.addHistoryDebounced(`item-props:${selected_item_id.value}`)
+}
+const onUpdateCanvasCfg = (canvasCfg: IGlobalStoreCanvasCfg) => {
+  const currentHistoryCanvasCfg = getEditorHistoryCanvasCfg(globalStore.canvasCfg)
+  const nextHistoryCanvasCfg = getEditorHistoryCanvasCfg(canvasCfg)
+  globalStore.canvasCfg = canvasCfg
+  if (JSON.stringify(currentHistoryCanvasCfg) !== JSON.stringify(nextHistoryCanvasCfg)) {
+    cacheStore.addHistoryDebounced('page-setting')
+  }
+}
+const onUpdateGridCfg = (gridCfg: IGlobalStoreGridCfg) => {
+  globalStore.gridCfg = gridCfg
 }
 }
 const hasDeviceBindSlot = computed(() => {
 const hasDeviceBindSlot = computed(() => {
   return !!slots.deviceBind
   return !!slots.deviceBind
 })
 })
 </script>
 </script>
-<style>
-#mt-right-aside .el-collapse-item__header,
-#mt-right-aside .el-collapse-item__wrap {
-  background-color: transparent !important;
+<style scoped>
+.property-panel {
+  display: flex;
+  height: 100%;
+  min-height: 0;
+  padding: 14px 12px 0;
+  background: rgb(255 255 255 / 96%);
+  box-sizing: border-box;
+  flex-direction: column;
+}
+
+.property-panel__header {
+  display: flex;
+  padding: 0 4px 13px;
+  border-bottom: 1px solid #e3e8ef;
+  align-items: flex-start;
+  justify-content: space-between;
+}
+
+.property-panel__header h2 {
+  margin: 0;
+  font-size: 16px;
+  font-weight: 700;
+  line-height: 22px;
+  color: #172033;
+}
+
+.property-panel__header p {
+  margin: 3px 0 0;
+  font-size: 12px;
+  line-height: 18px;
+  color: #7a8699;
+}
+
+.property-panel__selection {
+  max-width: 100px;
+  padding: 3px 8px;
+  overflow: hidden;
+  font-size: 11px;
+  font-weight: 600;
+  line-height: 18px;
+  color: var(--el-color-primary);
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  background: var(--el-color-primary-light-9);
+  border-radius: 10px;
+}
+
+.property-panel__content {
+  min-height: 0;
+  padding-top: 10px;
+  flex: 1;
+}
+
+.property-panel__content :deep(.el-scrollbar__view) {
+  padding: 0 3px 16px 0;
+}
+
+.property-panel__content :deep(.el-tabs__header) {
+  padding: 4px;
+  margin: 0 0 12px;
+  background: #f1f4f8;
+  border-radius: 9px;
+}
+
+.property-panel__content :deep(.el-tabs__nav-wrap::after),
+.property-panel__content :deep(.el-tabs__active-bar) {
+  display: none;
+}
+
+.property-panel__content :deep(.el-tabs__nav) {
+  width: 100%;
+}
+
+.property-panel__content :deep(.el-tabs__item) {
+  height: 32px;
+  padding: 0 12px;
+  color: #667085;
+  border-radius: 7px;
+  flex: 1;
+}
+
+.property-panel__content :deep(.el-tabs__item.is-active) {
+  font-weight: 600;
+  color: var(--el-color-primary);
+  background: #fff;
+  box-shadow: 0 2px 7px rgb(31 45 61 / 8%);
+}
+
+.property-panel__content :deep(.el-form-item) {
+  margin-bottom: 14px;
+}
+
+.property-panel__content :deep(.el-form-item__label) {
+  font-size: 12px;
+  font-weight: 500;
+  color: #667085;
+}
+
+.property-panel__content :deep(.el-select),
+.property-panel__content :deep(.el-input),
+.property-panel__content :deep(.el-input-number) {
+  width: 100%;
+}
+
+.property-panel__content :deep(.el-collapse) {
+  border: 0;
+}
+
+.property-panel__content :deep(.el-collapse-item) {
+  margin-bottom: 9px;
+  overflow: hidden;
+  background: #fff;
+  border: 1px solid #e2e7ef;
+  border-radius: 10px;
+}
+
+.property-panel__content :deep(.el-collapse-item__header) {
+  height: 42px;
+  padding: 0 12px;
+  font-size: 13px;
+  font-weight: 600;
+  color: #344054;
+  background: transparent;
+  border: 0;
+}
+
+.property-panel__content :deep(.el-collapse-item__wrap) {
+  background: transparent;
+  border: 0;
+}
+
+.property-panel__content :deep(.el-collapse-item__content) {
+  padding: 4px 12px 12px;
+}
+
+.dark .property-panel {
+  background: rgb(24 31 43 / 97%);
+}
+
+.dark .property-panel__header {
+  border-color: #303949;
+}
+
+.dark .property-panel__header h2 {
+  color: #edf2f8;
+}
+
+.dark .property-panel__header p {
+  color: #8d99aa;
+}
+
+.dark .property-panel__content :deep(.el-tabs__header) {
+  background: #202735;
+}
+
+.dark .property-panel__content :deep(.el-tabs__item.is-active) {
+  background: #2a3444;
+}
+
+.dark .property-panel__content :deep(.el-collapse-item) {
+  background: #202735;
+  border-color: #303949;
+}
+
+.dark .property-panel__content :deep(.el-collapse-item__header) {
+  color: #dbe3ed;
 }
 }
 </style>
 </style>

+ 183 - 22
src/components/mt-edit/components/layout/right-aside/page-setting.vue

@@ -1,7 +1,13 @@
 <template>
 <template>
-  <el-tabs v-model="activeName" class="select-none">
-    <el-tab-pane label="页面" name="page">
-      <el-form label-width="70px" label-position="left">
+  <div class="page-setting select-none">
+    <section class="setting-card">
+      <header class="setting-card__header">
+        <div>
+          <h3>画布</h3>
+          <p>尺寸、缩放与背景</p>
+        </div>
+      </header>
+      <el-form label-width="78px" label-position="left" class="setting-form">
         <el-form-item label="画布尺寸" size="small">
         <el-form-item label="画布尺寸" size="small">
           <el-select v-model="canvas_size" placeholder="请设置画布尺寸">
           <el-select v-model="canvas_size" placeholder="请设置画布尺寸">
             <el-option-group label="自定义">
             <el-option-group label="自定义">
@@ -45,17 +51,21 @@
                 size="small"
                 size="small"
                 :step="0.1"
                 :step="0.1"
                 :min="0.1"
                 :min="0.1"
+                :max="4"
                 class="mx-5px" />
                 class="mx-5px" />
             </div>
             </div>
           </el-select>
           </el-select>
         </el-form-item>
         </el-form-item>
         <el-form-item label="背景颜色" size="small">
         <el-form-item label="背景颜色" size="small">
-          <el-color-picker v-model="canvas_bg_color" />
+          <el-color-picker
+            v-model="canvas_bg_color"
+            class="canvas-background-color"
+            size="default" />
         </el-form-item>
         </el-form-item>
         <el-form-item label="背景图片" size="small">
         <el-form-item label="背景图片" size="small">
           <el-upload
           <el-upload
             ref="canvasBgImgUploadRef"
             ref="canvasBgImgUploadRef"
-            class="w-24px h-24px"
+            class="canvas-background-upload"
             v-model:file-list="bg_img_list"
             v-model:file-list="bg_img_list"
             :auto-upload="false"
             :auto-upload="false"
             :limit="1"
             :limit="1"
@@ -64,27 +74,36 @@
             accept="image/*"
             accept="image/*"
             @mouseenter="show_clear_bg_img = true"
             @mouseenter="show_clear_bg_img = true"
             @mouseleave="show_clear_bg_img = false">
             @mouseleave="show_clear_bg_img = false">
-            <div class="flex justify-center items-center relative">
+            <div class="canvas-background-upload__trigger">
               <img
               <img
-                class="w-40px h-40px absolute left-0"
                 v-if="rightAsideProps.canvasCfg.img"
                 v-if="rightAsideProps.canvasCfg.img"
+                class="canvas-background-upload__preview"
                 :src="rightAsideProps.canvasCfg.img" />
                 :src="rightAsideProps.canvasCfg.img" />
-              <el-button v-else size="small" class="w-40px h-40px absolute left-0">
-                <el-icon title="上传" :size="20">
-                  <svg-analysis name="upload" />
-                </el-icon>
-              </el-button>
+              <el-icon v-else title="上传背景图片" :size="17">
+                <svg-analysis name="upload" />
+              </el-icon>
               <div
               <div
                 v-if="rightAsideProps.canvasCfg.img && show_clear_bg_img"
                 v-if="rightAsideProps.canvasCfg.img && show_clear_bg_img"
-                class="absolute w-40px h-40px left-0 opacity-80 bg-light-300 flex justify-center items-center"
+                class="canvas-background-upload__remove"
                 @click.stop="clearBgImg">
                 @click.stop="clearBgImg">
-                <el-icon title="删除" :size="25">
+                <el-icon title="删除背景图片" :size="16">
                   <svg-analysis name="delete" />
                   <svg-analysis name="delete" />
                 </el-icon>
                 </el-icon>
               </div>
               </div>
             </div>
             </div>
           </el-upload>
           </el-upload>
         </el-form-item>
         </el-form-item>
+      </el-form>
+    </section>
+
+    <section class="setting-card">
+      <header class="setting-card__header">
+        <div>
+          <h3>编辑辅助</h3>
+          <p>控制参考线、吸附与网格</p>
+        </div>
+      </header>
+      <el-form label-width="78px" label-position="left" class="setting-form">
         <el-form-item label="参考线" size="small">
         <el-form-item label="参考线" size="small">
           <el-switch v-model="canvas_guide" />
           <el-switch v-model="canvas_guide" />
         </el-form-item>
         </el-form-item>
@@ -101,14 +120,12 @@
           <el-input-number v-model="grid_size" :min="1" />
           <el-input-number v-model="grid_size" :min="1" />
         </el-form-item>
         </el-form-item>
       </el-form>
       </el-form>
-    </el-tab-pane>
-  </el-tabs>
+    </section>
+  </div>
 </template>
 </template>
 <script setup lang="ts">
 <script setup lang="ts">
 import type { IGlobalStoreCanvasCfg, IGlobalStoreGridCfg } from '@/components/mt-edit/store/types'
 import type { IGlobalStoreCanvasCfg, IGlobalStoreGridCfg } from '@/components/mt-edit/store/types'
 import {
 import {
-  ElTabs,
-  ElTabPane,
   ElForm,
   ElForm,
   ElFormItem,
   ElFormItem,
   ElInputNumber,
   ElInputNumber,
@@ -121,20 +138,19 @@ import {
   ElUpload,
   ElUpload,
   ElIcon,
   ElIcon,
   type UploadUserFile,
   type UploadUserFile,
-  ElButton,
   type UploadFile,
   type UploadFile,
   ElMessage
   ElMessage
 } from 'element-plus'
 } from 'element-plus'
 import { computed, ref } from 'vue'
 import { computed, ref } from 'vue'
 import SvgAnalysis from '@/components/mt-edit/components/svg-analysis/index.vue'
 import SvgAnalysis from '@/components/mt-edit/components/svg-analysis/index.vue'
 import { blobToBase64 } from '@/components/mt-edit/utils'
 import { blobToBase64 } from '@/components/mt-edit/utils'
+import { normalizeCanvasScale } from '@/components/mt-edit/utils/canvas-scale'
 type RightAsideProps = {
 type RightAsideProps = {
   canvasCfg: IGlobalStoreCanvasCfg
   canvasCfg: IGlobalStoreCanvasCfg
   gridCfg: IGlobalStoreGridCfg
   gridCfg: IGlobalStoreGridCfg
 }
 }
 const rightAsideProps = withDefaults(defineProps<RightAsideProps>(), {})
 const rightAsideProps = withDefaults(defineProps<RightAsideProps>(), {})
 const emits = defineEmits(['update:canvasCfg', 'update:gridCfg'])
 const emits = defineEmits(['update:canvasCfg', 'update:gridCfg'])
-const activeName = ref('page')
 const canvasBgImgUploadRef = ref()
 const canvasBgImgUploadRef = ref()
 const canvas_size = computed({
 const canvas_size = computed({
   get: () => {
   get: () => {
@@ -175,6 +191,14 @@ const canvas_size_options = [
   {
   {
     label: 'pc端',
     label: 'pc端',
     options: [
     options: [
+      {
+        value: '3840*2160',
+        label: '4K(3840*2160)'
+      },
+      {
+        value: '2560*1440',
+        label: '2K(2560*1440)'
+      },
       {
       {
         value: '1920*1080',
         value: '1920*1080',
         label: '1920*1080'
         label: '1920*1080'
@@ -218,7 +242,7 @@ const canvas_size_scale_input = computed({
   set: (value) => {
   set: (value) => {
     emits('update:canvasCfg', {
     emits('update:canvasCfg', {
       ...rightAsideProps.canvasCfg,
       ...rightAsideProps.canvasCfg,
-      scale: value
+      scale: normalizeCanvasScale(value)
     })
     })
   }
   }
 })
 })
@@ -229,7 +253,7 @@ const canvas_size_scale = computed({
   set: (value) => {
   set: (value) => {
     emits('update:canvasCfg', {
     emits('update:canvasCfg', {
       ...rightAsideProps.canvasCfg,
       ...rightAsideProps.canvasCfg,
-      scale: value
+      scale: normalizeCanvasScale(value)
     })
     })
   }
   }
 })
 })
@@ -348,3 +372,140 @@ const canvas_guide = computed({
   }
   }
 })
 })
 </script>
 </script>
+
+<style scoped>
+.page-setting {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.setting-card {
+  padding: 13px 13px 2px;
+  background: #fff;
+  border: 1px solid #e2e7ef;
+  border-radius: 10px;
+}
+
+.setting-card__header {
+  padding-bottom: 11px;
+  margin-bottom: 13px;
+  border-bottom: 1px solid #edf0f4;
+}
+
+.setting-card__header h3 {
+  margin: 0;
+  font-size: 13px;
+  font-weight: 700;
+  line-height: 20px;
+  color: #344054;
+}
+
+.setting-card__header p {
+  margin: 2px 0 0;
+  font-size: 11px;
+  line-height: 17px;
+  color: #98a2b3;
+}
+
+.setting-form :deep(.el-form-item) {
+  min-height: 32px;
+  margin-bottom: 12px;
+  align-items: center;
+}
+
+.setting-form :deep(.el-form-item__label) {
+  height: auto;
+  line-height: 18px;
+}
+
+.setting-form :deep(.el-form-item__content) {
+  min-width: 0;
+  justify-content: flex-end;
+}
+
+.setting-form :deep(.el-select),
+.setting-form :deep(.el-input-number) {
+  width: 100%;
+}
+
+.canvas-background-color,
+.canvas-background-color :deep(.el-color-picker__trigger) {
+  width: 32px;
+  height: 32px;
+}
+
+.canvas-background-color :deep(.el-color-picker__trigger) {
+  border-radius: 5px;
+}
+
+.canvas-background-upload {
+  width: 32px;
+  height: 32px;
+}
+
+.canvas-background-upload :deep(.el-upload) {
+  display: block;
+  width: 32px;
+  height: 32px;
+}
+
+.canvas-background-upload__trigger {
+  position: relative;
+  display: flex;
+  width: 32px;
+  height: 32px;
+  overflow: hidden;
+  color: #667085;
+  cursor: pointer;
+  background: #fff;
+  border: 1px solid #d0d5dd;
+  border-radius: 5px;
+  box-sizing: border-box;
+  transition:
+    border-color 0.2s,
+    color 0.2s;
+  align-items: center;
+  justify-content: center;
+}
+
+.canvas-background-upload__trigger:hover {
+  color: var(--el-color-primary);
+  border-color: var(--el-color-primary);
+}
+
+.canvas-background-upload__preview {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.canvas-background-upload__remove {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  color: #fff;
+  background: rgb(15 23 42 / 58%);
+  align-items: center;
+  justify-content: center;
+}
+
+.dark .setting-card {
+  background: #202735;
+  border-color: #303949;
+}
+
+.dark .setting-card__header {
+  border-color: #303949;
+}
+
+.dark .setting-card__header h3 {
+  color: #dbe3ed;
+}
+
+.dark .canvas-background-upload__trigger {
+  color: #98a2b3;
+  background: #202735;
+  border-color: #475467;
+}
+</style>

+ 687 - 136
src/components/mt-edit/components/layout/right-aside/select-item-event-setting/index.vue

@@ -1,143 +1,253 @@
 <template>
 <template>
-  <div>
-    <el-button type="primary" class="w-1/1" @click="onAddEvent">添加事件</el-button>
-    <el-form label-width="60px" label-position="left">
-      <el-collapse v-model="activeNames">
+  <div class="event-editor">
+    <header class="event-editor__header">
+      <div>
+        <strong>交互事件</strong>
+        <span>共 {{ event_list.length }} 个事件</span>
+      </div>
+      <el-button type="primary" size="small" @click="onAddEvent">
+        <el-icon><Plus /></el-icon>
+        添加事件
+      </el-button>
+    </header>
+
+    <el-empty
+      v-if="!event_list.length"
+      class="event-editor__empty"
+      :image-size="72"
+      description="还没有配置交互事件">
+      <el-button type="primary" plain size="small" @click="onAddEvent">创建第一个事件</el-button>
+    </el-empty>
+
+    <el-form v-else label-position="top" class="event-editor__form">
+      <el-collapse v-model="activeNames" class="event-list" accordion>
         <el-collapse-item
         <el-collapse-item
           v-for="(event_list_item, event_list_index) in event_list"
           v-for="(event_list_item, event_list_index) in event_list"
           :key="event_list_item.id"
           :key="event_list_item.id"
           :name="event_list_item.id">
           :name="event_list_item.id">
           <template #title>
           <template #title>
-            <div class="flex justify-between items-center w-1/1">
-              <el-text>事件{{ event_list_index + 1 }}</el-text>
-              <el-popconfirm title="删除该事件?" @confirm="onDelEvent(event_list_index)">
+            <div class="event-card__title">
+              <span class="event-card__number">{{ event_list_index + 1 }}</span>
+              <div class="event-card__heading">
+                <strong>事件 {{ String(event_list_index + 1).padStart(2, '0') }}</strong>
+                <span>{{ getEventSummary(event_list_item) }}</span>
+              </div>
+              <el-button
+                class="event-card__icon-button"
+                text
+                circle
+                size="small"
+                aria-label="复制事件"
+                @click.stop="onCopyEvent(event_list_item, event_list_index)">
+                <el-icon><CopyDocument /></el-icon>
+              </el-button>
+              <el-popconfirm
+                width="190"
+                title="确认删除这个事件吗?"
+                confirm-button-text="删除"
+                cancel-button-text="取消"
+                @confirm="onDelEvent(event_list_index)">
                 <template #reference>
                 <template #reference>
-                  <el-button text circle size="small" @click.stop>
-                    <el-icon title="删除" :class="``" :size="20">
-                      <svg-analysis name="delete" />
-                    </el-icon>
+                  <el-button
+                    class="event-card__icon-button event-card__delete"
+                    text
+                    circle
+                    size="small"
+                    aria-label="删除事件"
+                    @click.stop>
+                    <el-icon><Delete /></el-icon>
                   </el-button>
                   </el-button>
                 </template>
                 </template>
               </el-popconfirm>
               </el-popconfirm>
             </div>
             </div>
           </template>
           </template>
-          <div>
-            <el-form-item label="事件类型" size="small" class="mt-10px">
-              <el-select placeholder="事件类型" v-model="event_list[event_list_index].type">
-                <el-option
-                  v-for="item in event_type"
-                  :key="item.value"
-                  :label="item.label"
-                  :value="item.value" />
-              </el-select>
-            </el-form-item>
-            <el-form-item label="事件行为" size="small">
-              <el-select
-                placeholder="事件行为"
+          <div class="event-card__body">
+            <section class="event-step">
+              <div class="event-step__heading">
+                <span>1</span>
+                <div>
+                  <strong>触发方式</strong>
+                  <p>用户执行什么操作时触发</p>
+                </div>
+              </div>
+              <el-radio-group
+                v-model="event_list[event_list_index].type"
+                class="event-type-options"
+                size="small">
+                <el-radio-button v-for="item in event_type" :key="item.value" :value="item.value">
+                  {{ item.label }}
+                </el-radio-button>
+              </el-radio-group>
+            </section>
+
+            <section class="event-step">
+              <div class="event-step__heading">
+                <span>2</span>
+                <div>
+                  <strong>执行动作</strong>
+                  <p>事件触发后执行的操作</p>
+                </div>
+              </div>
+              <el-radio-group
                 v-model="event_list[event_list_index].action"
                 v-model="event_list[event_list_index].action"
+                class="event-action-options"
+                size="small"
                 @change="onEventActionChange(event_list_index)">
                 @change="onEventActionChange(event_list_index)">
-                <el-option
-                  v-for="item in event_action"
-                  :key="item.value"
-                  :label="item.label"
-                  :value="item.value" />
-              </el-select>
-            </el-form-item>
-            <el-form-item
-              v-if="event_list_item.action == 'changeAttr'"
-              label="属性更改"
-              size="small">
-              <el-button @click="onChangeAttrClick(event_list_item, event_list_index)"
-                >点击配置</el-button
-              >
-            </el-form-item>
-            <el-form-item
-              v-else-if="event_list_item.action == 'customCode'"
-              label="代码编写"
-              size="small">
-              <el-button @click="onCustomCodeClick(event_list_item, event_list_index)"
-                >点击编写</el-button
-              >
-            </el-form-item>
-            <el-form-item
-              v-else-if="event_list_item.action == 'openWebtopo'"
-              label="目标组态"
-              size="small">
-              <div class="webtopo-project-field">
-                <el-button @click="onWebtopoProjectClick(event_list_index)">选择组态</el-button>
-                <el-text
-                  truncated
-                  class="webtopo-project-name"
-                  :title="event_list_item.webtopo_project?.projectName">
-                  {{ event_list_item.webtopo_project?.projectName || '未选择' }}
-                </el-text>
+                <el-radio-button v-for="item in event_action" :key="item.value" :value="item.value">
+                  {{ item.label }}
+                </el-radio-button>
+              </el-radio-group>
+
+              <div class="action-config">
+                <template v-if="event_list_item.action === 'changeAttr'">
+                  <div>
+                    <strong>属性更改</strong>
+                    <span>已配置 {{ event_list_item.change_attr.length }} 项</span>
+                  </div>
+                  <el-button
+                    type="primary"
+                    plain
+                    size="small"
+                    @click="onChangeAttrClick(event_list_item, event_list_index)">
+                    {{ event_list_item.change_attr.length ? '编辑配置' : '开始配置' }}
+                  </el-button>
+                </template>
+                <template v-else-if="event_list_item.action === 'customCode'">
+                  <div>
+                    <strong>自定义代码</strong>
+                    <span>{{ event_list_item.custom_code ? '已编写代码' : '尚未编写代码' }}</span>
+                  </div>
+                  <el-button
+                    type="primary"
+                    plain
+                    size="small"
+                    @click="onCustomCodeClick(event_list_item, event_list_index)">
+                    {{ event_list_item.custom_code ? '编辑代码' : '编写代码' }}
+                  </el-button>
+                </template>
+                <template v-else>
+                  <div class="action-config__project">
+                    <strong>目标组态</strong>
+                    <span :title="event_list_item.webtopo_project?.projectName">
+                      {{ event_list_item.webtopo_project?.projectName || '尚未选择组态' }}
+                    </span>
+                  </div>
+                  <el-button
+                    type="primary"
+                    plain
+                    size="small"
+                    @click="onWebtopoProjectClick(event_list_index)">
+                    选择组态
+                  </el-button>
+                </template>
               </div>
               </div>
-            </el-form-item>
-            <el-form-item label="触发规则" size="small">
-              <el-text>(不填直接触发)</el-text>
-            </el-form-item>
-            <div>
-              <el-text>当</el-text>
-              <el-select
-                placeholder="选择图形"
-                size="small"
-                v-model="event_list[event_list_index].trigger_rule.trigger_id"
-                @change="onTriggerIdChange(event_list_index)">
-                <el-option
-                  v-for="item in all_component_options"
-                  :key="item.value"
-                  :label="item.label"
-                  :value="item.value" />
-              </el-select>
-              <el-text>图形</el-text>
-              <el-select
-                placeholder="选择属性"
-                size="small"
-                v-model="event_list[event_list_index].trigger_rule.trigger_attr"
-                @change="onTriggerAttrChange(event_list_index)">
-                <el-option
-                  v-for="item in getAttrById(event_list[event_list_index].trigger_rule.trigger_id)"
-                  :key="item.value"
-                  :label="item.label"
-                  :value="item.value" />
-              </el-select>
-              <el-text>属性</el-text>
-              <el-select
-                placeholder="运算符"
+            </section>
+
+            <section class="event-step event-condition-step">
+              <div class="event-step__heading">
+                <span>3</span>
+                <div>
+                  <strong>触发条件</strong>
+                  <p>可选,不设置条件则直接触发</p>
+                </div>
+                <el-tag
+                  class="event-step__status"
+                  size="small"
+                  :type="getTriggerConditionStatus(event_list_item).type"
+                  effect="light">
+                  {{ getTriggerConditionStatus(event_list_item).label }}
+                </el-tag>
+              </div>
+
+              <el-button
+                v-if="!isTriggerEditorVisible(event_list_item)"
+                class="add-condition-button"
+                plain
                 size="small"
                 size="small"
-                v-model="event_list[event_list_index].trigger_rule.operator">
-                <el-option
-                  v-for="item in getOperatorList(
-                    getAttrTypeByValue(
-                      getAttrById(event_list[event_list_index].trigger_rule.trigger_id),
-                      event_list[event_list_index].trigger_rule.trigger_attr
-                    )
-                  )"
-                  :key="item.value"
-                  :label="item.label"
-                  :value="item.value" />
-              </el-select>
-              <input-target-value
-                v-model="event_list[event_list_index].trigger_rule.value"
-                :form-type="
-                  getAttrTypeByValue(
-                    getAttrById(event_list[event_list_index].trigger_rule.trigger_id),
-                    event_list[event_list_index].trigger_rule.trigger_attr
-                  )
-                "
-                :options="
-                  getAttrOptionsByValue(
-                    getAttrById(event_list[event_list_index].trigger_rule.trigger_id),
-                    event_list[event_list_index].trigger_rule.trigger_attr
-                  )
-                "
-                :disabled="event_list[event_list_index].trigger_rule.trigger_attr == undefined"
-                size="small" />
-              <el-text>时</el-text>
-              <div>
-                <el-text>触发该事件</el-text>
+                @click="onAddTriggerCondition(event_list_item.id)">
+                <el-icon><Plus /></el-icon>
+                添加触发条件
+              </el-button>
+
+              <div v-else class="condition-builder">
+                <el-form-item label="当这个图形" size="small">
+                  <el-select
+                    v-model="event_list[event_list_index].trigger_rule.trigger_id"
+                    filterable
+                    placeholder="选择图形"
+                    @change="onTriggerIdChange(event_list_index)">
+                    <el-option
+                      v-for="item in all_component_options"
+                      :key="item.value"
+                      :label="item.label"
+                      :value="item.value" />
+                  </el-select>
+                </el-form-item>
+                <el-form-item label="指定属性" size="small">
+                  <el-select
+                    v-model="event_list[event_list_index].trigger_rule.trigger_attr"
+                    filterable
+                    placeholder="选择属性"
+                    :disabled="!event_list[event_list_index].trigger_rule.trigger_id"
+                    @change="onTriggerAttrChange(event_list_index)">
+                    <el-option
+                      v-for="item in getAttrById(
+                        event_list[event_list_index].trigger_rule.trigger_id
+                      )"
+                      :key="item.value"
+                      :label="item.label"
+                      :value="item.value" />
+                  </el-select>
+                </el-form-item>
+                <div class="condition-builder__comparison">
+                  <el-form-item label="满足关系" size="small">
+                    <el-select
+                      v-model="event_list[event_list_index].trigger_rule.operator"
+                      placeholder="运算符"
+                      :disabled="!event_list[event_list_index].trigger_rule.trigger_attr">
+                      <el-option
+                        v-for="item in getOperatorList(
+                          getAttrTypeByValue(
+                            getAttrById(event_list[event_list_index].trigger_rule.trigger_id),
+                            event_list[event_list_index].trigger_rule.trigger_attr
+                          )
+                        )"
+                        :key="item.value"
+                        :label="item.label"
+                        :value="item.value" />
+                    </el-select>
+                  </el-form-item>
+                  <el-form-item label="目标值" size="small">
+                    <input-target-value
+                      v-model="event_list[event_list_index].trigger_rule.value"
+                      :form-type="
+                        getAttrTypeByValue(
+                          getAttrById(event_list[event_list_index].trigger_rule.trigger_id),
+                          event_list[event_list_index].trigger_rule.trigger_attr
+                        )
+                      "
+                      :options="
+                        getAttrOptionsByValue(
+                          getAttrById(event_list[event_list_index].trigger_rule.trigger_id),
+                          event_list[event_list_index].trigger_rule.trigger_attr
+                        )
+                      "
+                      :disabled="!event_list[event_list_index].trigger_rule.trigger_attr"
+                      size="small" />
+                  </el-form-item>
+                </div>
+                <div class="condition-builder__footer">
+                  <span>以上条件满足时执行该事件</span>
+                  <el-button
+                    type="danger"
+                    text
+                    size="small"
+                    @click="onRemoveTriggerCondition(event_list_item, event_list_index)">
+                    移除条件
+                  </el-button>
+                </div>
               </div>
               </div>
-            </div>
+            </section>
           </div>
           </div>
         </el-collapse-item>
         </el-collapse-item>
       </el-collapse>
       </el-collapse>
@@ -224,19 +334,23 @@
 <script setup lang="ts">
 <script setup lang="ts">
 import {
 import {
   ElButton,
   ElButton,
-  ElForm,
-  ElFormItem,
-  ElSelect,
-  ElOption,
-  ElText,
   ElCollapse,
   ElCollapse,
   ElCollapseItem,
   ElCollapseItem,
+  ElDialog,
+  ElDrawer,
+  ElEmpty,
+  ElForm,
+  ElFormItem,
   ElIcon,
   ElIcon,
+  ElOption,
   ElPopconfirm,
   ElPopconfirm,
-  ElDrawer,
-  ElDialog
+  ElRadioButton,
+  ElRadioGroup,
+  ElSelect,
+  ElTag
 } from 'element-plus'
 } from 'element-plus'
 import { computed, ref, watch } from 'vue'
 import { computed, ref, watch } from 'vue'
+import { CopyDocument, Delete, Plus } from '@element-plus/icons-vue'
 import SvgAnalysis from '@/components/mt-edit/components/svg-analysis/index.vue'
 import SvgAnalysis from '@/components/mt-edit/components/svg-analysis/index.vue'
 import { randomString } from '@/components/mt-edit/utils'
 import { randomString } from '@/components/mt-edit/utils'
 import type {
 import type {
@@ -314,8 +428,9 @@ const all_component_options = computed(() => {
     }
     }
   })
   })
 })
 })
-const activeNames = ref([])
+const activeNames = ref('')
 const event_list = computed(() => selectItemEventSettingProps.itemEvents)
 const event_list = computed(() => selectItemEventSettingProps.itemEvents)
+const trigger_editor_event_ids = ref<string[]>([])
 const select_event_index = ref(0)
 const select_event_index = ref(0)
 const drawer_visiable = ref(false)
 const drawer_visiable = ref(false)
 const drawer_title = ref('属性更改配置')
 const drawer_title = ref('属性更改配置')
@@ -325,8 +440,9 @@ const dialog_title = ref('自定义代码编写')
 const dialog_code = ref('')
 const dialog_code = ref('')
 const webtopo_project_dialog_visible = ref(false)
 const webtopo_project_dialog_visible = ref(false)
 const onAddEvent = () => {
 const onAddEvent = () => {
+  const id = randomString()
   event_list.value.push({
   event_list.value.push({
-    id: randomString(),
+    id,
     type: 'click',
     type: 'click',
     action: 'changeAttr',
     action: 'changeAttr',
     change_attr: [],
     change_attr: [],
@@ -339,9 +455,30 @@ const onAddEvent = () => {
       value: null
       value: null
     }
     }
   })
   })
+  activeNames.value = id
 }
 }
 const onDelEvent = (event_list_index: number) => {
 const onDelEvent = (event_list_index: number) => {
+  const eventId = event_list.value[event_list_index]?.id
   event_list.value.splice(event_list_index, 1)
   event_list.value.splice(event_list_index, 1)
+  trigger_editor_event_ids.value = trigger_editor_event_ids.value.filter((id) => id !== eventId)
+  if (activeNames.value === eventId) {
+    activeNames.value = ''
+  }
+}
+const onCopyEvent = (item: IDoneJsonEventList, index: number) => {
+  const id = randomString()
+  const copiedEvent: IDoneJsonEventList = {
+    ...item,
+    id,
+    change_attr: item.change_attr.map((config) => ({ ...config, id: randomString() })),
+    trigger_rule: { ...item.trigger_rule },
+    webtopo_project: item.webtopo_project ? { ...item.webtopo_project } : undefined
+  }
+  event_list.value.splice(index + 1, 0, copiedEvent)
+  activeNames.value = id
+  if (isTriggerEditorVisible(item)) {
+    trigger_editor_event_ids.value.push(id)
+  }
 }
 }
 const onChangeAttrClick = (item: IDoneJsonEventList, index: number) => {
 const onChangeAttrClick = (item: IDoneJsonEventList, index: number) => {
   drawer_visiable.value = true
   drawer_visiable.value = true
@@ -385,6 +522,52 @@ const onWebtopoProjectClick = (index: number) => {
 const onWebtopoProjectSelected = (project: IDoneJsonEventWebtopoProject) => {
 const onWebtopoProjectSelected = (project: IDoneJsonEventWebtopoProject) => {
   event_list.value[select_event_index.value].webtopo_project = project
   event_list.value[select_event_index.value].webtopo_project = project
 }
 }
+const getEventSummary = (item: IDoneJsonEventList) => {
+  const typeLabel = event_type.find((type) => type.value === item.type)?.label || '未选择触发方式'
+  const actionLabel =
+    event_action.find((action) => action.value === item.action)?.label || '未选择执行动作'
+  return `${typeLabel} · ${actionLabel}`
+}
+const hasTriggerCondition = (item: IDoneJsonEventList) => {
+  const { trigger_id, trigger_attr, operator, value } = item.trigger_rule
+  return Boolean(
+    trigger_id && trigger_attr && operator && value !== null && value !== undefined && value !== ''
+  )
+}
+const hasTriggerRuleInput = (item: IDoneJsonEventList) => {
+  const { trigger_id, trigger_attr, operator, value } = item.trigger_rule
+  return Boolean(
+    trigger_id ||
+      trigger_attr ||
+      operator ||
+      (value !== null && value !== undefined && value !== '')
+  )
+}
+const isTriggerEditorVisible = (item: IDoneJsonEventList) =>
+  trigger_editor_event_ids.value.includes(item.id) || hasTriggerRuleInput(item)
+const getTriggerConditionStatus = (item: IDoneJsonEventList) => {
+  if (hasTriggerCondition(item)) {
+    return { label: '已设置', type: 'primary' as const }
+  }
+  if (isTriggerEditorVisible(item)) {
+    return { label: '待完善', type: 'warning' as const }
+  }
+  return { label: '直接触发', type: 'info' as const }
+}
+const onAddTriggerCondition = (eventId: string) => {
+  if (!trigger_editor_event_ids.value.includes(eventId)) {
+    trigger_editor_event_ids.value.push(eventId)
+  }
+}
+const onRemoveTriggerCondition = (item: IDoneJsonEventList, eventIndex: number) => {
+  event_list.value[eventIndex].trigger_rule = {
+    trigger_id: undefined,
+    trigger_attr: undefined,
+    operator: undefined,
+    value: null
+  }
+  trigger_editor_event_ids.value = trigger_editor_event_ids.value.filter((id) => id !== item.id)
+}
 /**
 /**
  * 根据id获取属性
  * 根据id获取属性
  * @param id
  * @param id
@@ -517,16 +700,384 @@ watch(event_list.value, (val) => {
 })
 })
 </script>
 </script>
 <style scoped>
 <style scoped>
-.webtopo-project-field {
+.event-editor {
+  min-width: 0;
+}
+
+.event-editor__header {
+  display: flex;
+  margin-bottom: 10px;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+}
+
+.event-editor__header > div {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.event-editor__header strong {
+  font-size: 13px;
+  line-height: 20px;
+  color: #344054;
+}
+
+.event-editor__header span {
+  font-size: 11px;
+  line-height: 16px;
+  color: #98a2b3;
+}
+
+.event-editor__header :deep(.el-button > span) {
+  gap: 5px;
+}
+
+.event-editor__empty {
+  padding: 44px 0 28px;
+  background: #f8fafc;
+  border: 1px dashed #d9e0e9;
+  border-radius: 10px;
+}
+
+.event-editor__empty :deep(.el-empty__description) {
+  margin-top: 8px;
+}
+
+.event-editor__form {
+  min-width: 0;
+}
+
+.event-list {
+  border: 0;
+}
+
+.event-list :deep(.el-collapse-item) {
+  margin-bottom: 10px;
+  overflow: hidden;
+  background: #fff;
+  border: 1px solid #dfe5ed;
+  border-radius: 10px;
+  transition:
+    border-color 0.18s ease,
+    box-shadow 0.18s ease;
+}
+
+.event-list :deep(.el-collapse-item.is-active) {
+  border-color: var(--el-color-primary-light-6);
+  box-shadow: 0 5px 16px rgb(31 45 61 / 7%);
+}
+
+.event-list :deep(.el-collapse-item__header) {
+  height: 56px;
+  padding: 0 8px 0 10px;
+  background: transparent;
+  border: 0;
+}
+
+.event-list :deep(.el-collapse-item__arrow) {
+  margin-left: 3px;
+  color: #7f8a9b;
+}
+
+.event-list :deep(.el-collapse-item__wrap) {
+  background: transparent;
+  border: 0;
+}
+
+.event-list :deep(.el-collapse-item__content) {
+  padding: 0;
+}
+
+.event-card__title {
+  display: flex;
+  width: 100%;
+  min-width: 0;
+  align-items: center;
+  gap: 8px;
+}
+
+.event-card__number {
+  display: inline-flex;
+  width: 26px;
+  height: 26px;
+  font-size: 11px;
+  font-weight: 700;
+  line-height: 1;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-radius: 8px;
+  align-items: center;
+  justify-content: center;
+  flex: none;
+}
+
+.event-card__heading {
+  display: flex;
+  min-width: 0;
+  margin-right: auto;
+  flex-direction: column;
+  gap: 1px;
+}
+
+.event-card__heading strong {
+  font-size: 12px;
+  font-weight: 650;
+  line-height: 18px;
+  color: #344054;
+}
+
+.event-card__heading span {
+  overflow: hidden;
+  font-size: 10px;
+  font-weight: 400;
+  line-height: 15px;
+  color: #8b96a7;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.event-card__icon-button {
+  width: 26px;
+  height: 26px;
+  margin: 0;
+  color: #7c8798;
+  flex: none;
+}
+
+.event-card__icon-button:hover {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+}
+
+.event-card__delete:hover {
+  color: var(--el-color-danger);
+  background: var(--el-color-danger-light-9);
+}
+
+.event-card__body {
+  padding: 2px 11px 12px;
+  border-top: 1px solid #edf0f4;
+}
+
+.event-step {
+  padding: 13px 0;
+  border-bottom: 1px solid #edf0f4;
+}
+
+.event-step:last-child {
+  padding-bottom: 2px;
+  border-bottom: 0;
+}
+
+.event-step__heading {
+  display: flex;
+  min-width: 0;
+  margin-bottom: 10px;
+  align-items: center;
+  gap: 8px;
+}
+
+.event-step__heading > span:first-child {
+  display: inline-flex;
+  width: 20px;
+  height: 20px;
+  font-size: 10px;
+  font-weight: 700;
+  line-height: 1;
+  color: #617087;
+  background: #eef2f7;
+  border-radius: 50%;
+  align-items: center;
+  justify-content: center;
+  flex: none;
+}
+
+.event-step__heading > div {
   display: flex;
   display: flex;
+  min-width: 0;
+  flex-direction: column;
+}
+
+.event-step__heading strong {
+  font-size: 12px;
+  line-height: 18px;
+  color: #344054;
+}
+
+.event-step__heading p {
+  margin: 0;
+  font-size: 10px;
+  line-height: 15px;
+  color: #98a2b3;
+}
+
+.event-step__status {
+  margin-left: auto;
+  flex: none;
+}
+
+.event-type-options,
+.event-action-options {
+  display: grid;
+  width: 100%;
+  gap: 6px;
+}
+
+.event-type-options {
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.event-action-options {
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.event-type-options :deep(.el-radio-button),
+.event-action-options :deep(.el-radio-button) {
+  width: 100%;
+}
+
+.event-type-options :deep(.el-radio-button__inner),
+.event-action-options :deep(.el-radio-button__inner) {
   width: 100%;
   width: 100%;
+  padding: 0 6px;
+  font-size: 11px;
+  line-height: 28px;
+  border: 1px solid #dce3ec !important;
+  border-radius: 7px !important;
+  box-shadow: none !important;
+}
+
+.event-type-options :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner),
+.event-action-options :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-color: var(--el-color-primary-light-5) !important;
+}
+
+.action-config {
+  display: flex;
+  min-width: 0;
+  padding: 9px 10px;
+  margin-top: 9px;
+  background: #f7f9fc;
+  border: 1px solid #e7ebf1;
+  border-radius: 8px;
+  align-items: center;
+  gap: 8px;
+}
+
+.action-config > div {
+  display: flex;
   min-width: 0;
   min-width: 0;
+  margin-right: auto;
   flex-direction: column;
   flex-direction: column;
-  align-items: flex-start;
+  gap: 1px;
+}
+
+.action-config strong {
+  font-size: 11px;
+  line-height: 17px;
+  color: #475467;
+}
+
+.action-config span {
+  overflow: hidden;
+  font-size: 10px;
+  line-height: 15px;
+  color: #8c97a8;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.action-config__project {
+  max-width: 122px;
+}
+
+.add-condition-button {
+  width: 100%;
+  height: 32px;
+  color: #667085;
+  border-style: dashed;
+}
+
+.add-condition-button :deep(span) {
+  gap: 5px;
+}
+
+.condition-builder {
+  padding: 10px;
+  background: #f7f9fc;
+  border: 1px solid #e4e9f1;
+  border-radius: 8px;
+}
+
+.condition-builder :deep(.el-form-item) {
+  margin-bottom: 9px;
+}
+
+.condition-builder :deep(.el-form-item__label) {
+  height: auto;
+  padding: 0 0 4px;
+  font-size: 10px;
+  line-height: 16px;
+  color: #667085;
+}
+
+.condition-builder :deep(.el-select),
+.condition-builder :deep(.el-input),
+.condition-builder :deep(.el-input-number) {
+  width: 100%;
+}
+
+.condition-builder__comparison {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+  gap: 8px;
+}
+
+.condition-builder__footer {
+  display: flex;
+  min-height: 24px;
+  padding-top: 2px;
+  align-items: center;
+  justify-content: space-between;
   gap: 8px;
   gap: 8px;
 }
 }
 
 
-.webtopo-project-name {
-  max-width: 100%;
+.condition-builder__footer > span {
+  font-size: 10px;
+  line-height: 15px;
+  color: #98a2b3;
+}
+
+.condition-builder__footer :deep(.el-button) {
+  padding: 3px 0;
+  margin: 0;
+}
+
+.dark .event-editor__header strong,
+.dark .event-card__heading strong,
+.dark .event-step__heading strong {
+  color: #dce4ee;
+}
+
+.dark .event-editor__empty,
+.dark .event-list :deep(.el-collapse-item),
+.dark .action-config,
+.dark .condition-builder {
+  background: #202735;
+  border-color: #303949;
+}
+
+.dark .event-card__body,
+.dark .event-step {
+  border-color: #303949;
+}
+
+.dark .action-config strong {
+  color: #cbd5e1;
 }
 }
 </style>
 </style>

+ 192 - 46
src/components/mt-edit/components/layout/right-aside/select-item-props-setting.vue

@@ -1,55 +1,87 @@
 <template>
 <template>
-  <div
-    v-for="(attr_item, key) in selectItemPropsSettingProps.propsInfo"
-    :key="`${selectItemPropsSettingProps.itemId}-${key}`">
-    <el-form-item
-      v-if="!attr_item.disabled"
-      class="mt-edit-prop-form-item"
-      :label="attr_item.title"
-      size="small">
-      <el-select
-        v-if="attr_item.type === 'select' && !attr_item.disabled"
-        v-model="attr_item.val"
-        placeholder="Select"
-        size="small"
-        :disabled="attr_item?.disabled">
-        <el-option
-          v-for="item in attr_item.options"
-          :key="item.value"
-          :label="item.label"
-          :value="item.value" />
-      </el-select>
-      <el-input-number
-        v-else-if="attr_item.type === 'number' && !attr_item.disabled"
-        v-model="attr_item.val"
-        :disabled="attr_item?.disabled" />
-      <el-input
-        v-else-if="attr_item.type === 'input' && !attr_item.disabled"
-        v-model="attr_item.val"
-        :disabled="attr_item?.disabled" />
-      <el-input
-        v-else-if="attr_item.type === 'textArea' && !attr_item.disabled"
-        v-model="attr_item.val"
-        :disabled="attr_item?.disabled"
-        type="textarea" />
-      <el-color-picker
-        v-else-if="attr_item.type === 'color' && !attr_item.disabled"
-        v-model="attr_item.val"
-        :show-alpha="attr_item.showAlpha"
-        :color-format="attr_item.colorFormat"
-        :disabled="attr_item?.disabled" />
-      <el-switch
-        v-else-if="attr_item.type === 'switch' && !attr_item.disabled"
-        v-model="attr_item.val"
-        :disabled="attr_item?.disabled" />
-      <div v-else-if="attr_item.type === 'jsonEdit' && !attr_item.disabled">
-        <json-edit v-model:contentObj="attr_item.val" />
+  <div class="property-groups">
+    <section
+      v-for="group in propertyGroups"
+      :key="`${selectItemPropsSettingProps.itemId}-${group.name}`"
+      class="property-group">
+      <header v-if="showGroupTitles" class="property-group__header">
+        <span>{{ group.name }}</span>
+        <small>{{ group.items.length }} 项</small>
+      </header>
+      <div class="property-group__grid">
+        <el-form-item
+          v-for="property in group.items"
+          :key="`${selectItemPropsSettingProps.itemId}-${property.key}`"
+          class="mt-edit-prop-form-item"
+          :class="{
+            'mt-edit-prop-form-item--wide': isWideProperty(property.config),
+            'mt-edit-prop-form-item--switch': property.config.type === 'switch'
+          }"
+          :label="property.config.title"
+          size="small">
+          <el-select
+            v-if="property.config.type === 'select'"
+            v-model="property.config.val"
+            placeholder="请选择"
+            size="small"
+            @update:model-value="onPropertyChange">
+            <el-option
+              v-for="item in property.config.options"
+              :key="item.value"
+              :label="item.label"
+              :value="item.value" />
+          </el-select>
+          <el-input-number
+            v-else-if="property.config.type === 'number'"
+            v-model="property.config.val"
+            :min="property.config.min"
+            :max="property.config.max"
+            :step="property.config.step"
+            :precision="property.config.precision"
+            @update:model-value="onPropertyChange" />
+          <el-input
+            v-else-if="property.config.type === 'input'"
+            v-model="property.config.val"
+            clearable
+            @update:model-value="onPropertyChange" />
+          <el-input
+            v-else-if="property.config.type === 'textArea'"
+            v-model="property.config.val"
+            type="textarea"
+            resize="none"
+            :autosize="property.config.autosize"
+            @update:model-value="onPropertyChange" />
+          <el-color-picker
+            v-else-if="property.config.type === 'color'"
+            v-model="property.config.val"
+            :show-alpha="property.config.showAlpha"
+            :color-format="property.config.colorFormat"
+            @update:model-value="onPropertyChange" />
+          <div v-else-if="property.config.type === 'switch'" class="property-switch">
+            <span>{{ property.config.val ? '已开启' : '已关闭' }}</span>
+            <el-switch v-model="property.config.val" @update:model-value="onPropertyChange" />
+          </div>
+          <div v-else-if="property.config.type === 'jsonEdit'" class="property-editor">
+            <json-edit
+              v-model:contentObj="property.config.val"
+              @update:content-obj="onPropertyChange" />
+          </div>
+          <status-color-rules-editor
+            v-else-if="property.config.type === 'statusColorRules'"
+            v-model="property.config.val"
+            @update:model-value="onPropertyChange" />
+          <hikvision-camera-select
+            v-else-if="property.config.type === 'hikvisionCamera'"
+            v-model="property.config.val"
+            @update:model-value="onPropertyChange" />
+        </el-form-item>
       </div>
       </div>
-    </el-form-item>
+    </section>
   </div>
   </div>
 </template>
 </template>
 <script setup lang="ts">
 <script setup lang="ts">
 import type { ILeftAsideConfigItemPublicProps } from '@/components/mt-edit/store/types'
 import type { ILeftAsideConfigItemPublicProps } from '@/components/mt-edit/store/types'
+import { computed } from 'vue'
 import {
 import {
   ElFormItem,
   ElFormItem,
   ElInput,
   ElInput,
@@ -60,16 +92,130 @@ import {
   ElColorPicker
   ElColorPicker
 } from 'element-plus'
 } from 'element-plus'
 import JsonEdit from './json-edit.vue'
 import JsonEdit from './json-edit.vue'
+import StatusColorRulesEditor from './status-color-rules-editor.vue'
+import HikvisionCameraSelect from './hikvision-camera-select.vue'
 type SelectItemPropsSettingProps = {
 type SelectItemPropsSettingProps = {
   itemId?: string
   itemId?: string
   propsInfo: ILeftAsideConfigItemPublicProps | undefined
   propsInfo: ILeftAsideConfigItemPublicProps | undefined
 }
 }
 const selectItemPropsSettingProps = withDefaults(defineProps<SelectItemPropsSettingProps>(), {})
 const selectItemPropsSettingProps = withDefaults(defineProps<SelectItemPropsSettingProps>(), {})
+const emits = defineEmits(['historyChange'])
+const onPropertyChange = () => emits('historyChange')
+type PropertyConfig = ILeftAsideConfigItemPublicProps[string]
+const propertyGroups = computed(() => {
+  const groups = new Map<string, { key: string; config: PropertyConfig }[]>()
+  Object.entries(selectItemPropsSettingProps.propsInfo || {}).forEach(([key, config]) => {
+    if (config.disabled) {
+      return
+    }
+    const groupName = config.group || '组件属性'
+    const items = groups.get(groupName) || []
+    items.push({ key, config })
+    groups.set(groupName, items)
+  })
+  return Array.from(groups, ([name, items]) => ({ name, items }))
+})
+const showGroupTitles = computed(
+  () => propertyGroups.value.length > 1 || propertyGroups.value[0]?.name !== '组件属性'
+)
+const isWideProperty = (config: PropertyConfig) =>
+  config.wide ||
+  ['textArea', 'jsonEdit', 'statusColorRules', 'hikvisionCamera'].includes(config.type)
 </script>
 </script>
 <style scoped>
 <style scoped>
+.property-groups {
+  display: flex;
+  gap: 16px;
+  flex-direction: column;
+}
+
+.property-group + .property-group {
+  padding-top: 15px;
+  border-top: 1px solid #edf0f5;
+}
+
+.property-group__header {
+  display: flex;
+  margin-bottom: 11px;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.property-group__header span {
+  font-size: 12px;
+  font-weight: 650;
+  color: #344054;
+}
+
+.property-group__header small {
+  font-size: 10px;
+  color: #98a2b3;
+}
+
+.property-group__grid {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+  gap: 12px 10px;
+}
+
+.mt-edit-prop-form-item {
+  min-width: 0;
+  margin: 0 !important;
+}
+
+.mt-edit-prop-form-item--wide {
+  grid-column: 1 / -1;
+}
+
 .mt-edit-prop-form-item :deep(.el-form-item__label) {
 .mt-edit-prop-form-item :deep(.el-form-item__label) {
+  height: 20px;
+  padding: 0 0 5px;
   overflow: hidden;
   overflow: hidden;
+  line-height: 20px;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
   white-space: nowrap;
   white-space: nowrap;
 }
 }
+
+.mt-edit-prop-form-item :deep(.el-form-item__content) {
+  min-width: 0;
+}
+
+.mt-edit-prop-form-item--switch :deep(.el-form-item__label) {
+  width: 100%;
+}
+
+.property-switch {
+  display: flex;
+  width: 100%;
+  height: 32px;
+  padding: 0 9px;
+  color: #98a2b3;
+  background: #f7f9fc;
+  border: 1px solid #e4e9f1;
+  border-radius: 7px;
+  box-sizing: border-box;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.property-switch span {
+  font-size: 11px;
+}
+
+.property-editor {
+  width: 100%;
+}
+
+:global(.dark) .property-group + .property-group {
+  border-color: #303949;
+}
+
+:global(.dark) .property-group__header span {
+  color: #dbe3ed;
+}
+
+:global(.dark) .property-switch {
+  background: #252e3d;
+  border-color: #374254;
+}
 </style>
 </style>

+ 241 - 49
src/components/mt-edit/components/layout/right-aside/select-item-setting.vue

@@ -1,69 +1,112 @@
 <template>
 <template>
-  <el-tabs v-model="activeName" v-if="selectItemSettingProps.itemJson" class="select-none">
+  <el-tabs
+    v-model="activeName"
+    v-if="selectItemSettingProps.itemJson"
+    class="select-item-setting select-none">
     <el-tab-pane label="配置" name="config">
     <el-tab-pane label="配置" name="config">
-      <el-collapse v-model="activeNames" accordion>
-        <el-collapse-item title="边界和属性" name="1">
-          <el-form label-width="60px" label-position="left">
-            <el-form-item label="标题" size="small">
-              <el-input size="small" v-model="item_title" />
+      <div class="setting-stack">
+        <section class="setting-card">
+          <header class="setting-card__header">
+            <span class="setting-card__index">01</span>
+            <div>
+              <h3>基本信息</h3>
+              <p>设置组件在图层中的名称</p>
+            </div>
+          </header>
+          <el-form label-position="top">
+            <el-form-item label="组件标题" size="small">
+              <el-input v-model="item_title" clearable placeholder="请输入组件标题" />
             </el-form-item>
             </el-form-item>
-            <el-form-item label="x轴坐标" size="small">
+          </el-form>
+        </section>
+
+        <section class="setting-card">
+          <header class="setting-card__header">
+            <span class="setting-card__index">02</span>
+            <div>
+              <h3>位置与尺寸</h3>
+              <p>精确调整组件的边界信息</p>
+            </div>
+          </header>
+          <el-form label-position="top" class="geometry-grid">
+            <el-form-item label="X 坐标" size="small">
               <el-input-number
               <el-input-number
-                size="small"
+                :precision="2"
                 v-model="item_binfo_left"
                 v-model="item_binfo_left"
                 @change="emits('addHistory')" />
                 @change="emits('addHistory')" />
             </el-form-item>
             </el-form-item>
-            <el-form-item label="y轴坐标" size="small">
+            <el-form-item label="Y 坐标" size="small">
               <el-input-number
               <el-input-number
-                size="small"
+                :precision="2"
                 v-model="item_binfo_top"
                 v-model="item_binfo_top"
                 @change="emits('addHistory')" />
                 @change="emits('addHistory')" />
             </el-form-item>
             </el-form-item>
             <el-form-item label="宽度" size="small" v-if="!is_line">
             <el-form-item label="宽度" size="small" v-if="!is_line">
-              <el-input-number
-                size="small"
-                v-model="item_binfo_width"
-                @change="emits('addHistory')" />
+              <el-input-number v-model="item_binfo_width" @change="emits('addHistory')" />
             </el-form-item>
             </el-form-item>
             <el-form-item label="高度" size="small" v-if="!is_line">
             <el-form-item label="高度" size="small" v-if="!is_line">
-              <el-input-number
-                size="small"
-                v-model="item_binfo_height"
-                @change="emits('addHistory')" />
-            </el-form-item>
-            <el-form-item label="旋转角度" size="small" v-if="!is_line">
-              <el-input-number
-                size="small"
-                v-model="item_binfo_angle"
-                @change="emits('addHistory')" />
-            </el-form-item>
-            <el-form-item label="隐藏" size="small">
-              <el-switch size="small" v-model="item_hide" @change="emits('addHistory')" />
+              <el-input-number v-model="item_binfo_height" @change="emits('addHistory')" />
             </el-form-item>
             </el-form-item>
-            <el-form-item label="锁定" size="small">
-              <el-switch size="small" v-model="item_lock" @change="emits('addHistory')" />
-            </el-form-item>
-            <el-form-item v-if="!item_lock && !is_line" label="可缩放" size="small">
-              <el-switch size="small" v-model="item_resize" />
-            </el-form-item>
-            <el-form-item
-              v-if="item_resize && !item_lock && !is_line"
-              label="等比缩放"
-              size="small">
-              <el-switch size="small" v-model="item_use_proportional_scaling" />
-            </el-form-item>
-            <el-form-item v-if="!item_lock && !is_line" label="可旋转" size="small">
-              <el-switch size="small" v-model="item_rotate" />
+            <el-form-item label="旋转角度" size="small" v-if="!is_line" class="geometry-grid__wide">
+              <el-input-number v-model="item_binfo_angle" @change="emits('addHistory')" />
             </el-form-item>
             </el-form-item>
+          </el-form>
+        </section>
+
+        <section class="setting-card">
+          <header class="setting-card__header">
+            <span class="setting-card__index">03</span>
+            <div>
+              <h3>组件状态</h3>
+              <p>控制组件的编辑和显示行为</p>
+            </div>
+          </header>
+          <div class="state-grid">
+            <div class="state-option">
+              <div><strong>隐藏</strong><span>不在画布中显示</span></div>
+              <el-switch v-model="item_hide" @change="emits('addHistory')" />
+            </div>
+            <div class="state-option">
+              <div><strong>锁定</strong><span>防止误移动</span></div>
+              <el-switch v-model="item_lock" @change="emits('addHistory')" />
+            </div>
+            <div v-if="!item_lock && !is_line" class="state-option">
+              <div><strong>可缩放</strong><span>允许改变尺寸</span></div>
+              <el-switch v-model="item_resize" />
+            </div>
+            <div v-if="item_resize && !item_lock && !is_line" class="state-option">
+              <div><strong>等比缩放</strong><span>保持宽高比例</span></div>
+              <el-switch v-model="item_use_proportional_scaling" />
+            </div>
+            <div v-if="!item_lock && !is_line" class="state-option state-option--wide">
+              <div><strong>可旋转</strong><span>显示旋转控制点</span></div>
+              <el-switch v-model="item_rotate" />
+            </div>
+          </div>
+        </section>
+
+        <section v-if="hasVisibleComponentProperties" class="setting-card">
+          <header class="setting-card__header">
+            <span class="setting-card__index">04</span>
+            <div>
+              <h3>{{ componentPropertiesTitle }}</h3>
+              <p>调整组件内容与展示效果</p>
+            </div>
+          </header>
+          <el-form label-position="top">
             <select-item-props-setting
             <select-item-props-setting
               :item-id="selectItemSettingProps.itemJson.id"
               :item-id="selectItemSettingProps.itemJson.id"
-              v-model:propsInfo="item_props" />
+              v-model:propsInfo="item_props"
+              @history-change="emits('addHistoryDebounced')" />
           </el-form>
           </el-form>
-        </el-collapse-item>
-        <el-collapse-item title="动画配置" name="2">
-          <select-item-animate-setting v-model:common-animates="item_common_animations" />
-        </el-collapse-item>
-      </el-collapse>
+        </section>
+
+        <el-collapse v-model="activeNames" class="setting-collapse">
+          <el-collapse-item title="动画效果" name="animation">
+            <select-item-animate-setting v-model:common-animates="item_common_animations" />
+          </el-collapse-item>
+        </el-collapse>
+      </div>
     </el-tab-pane>
     </el-tab-pane>
     <el-tab-pane label="事件" name="event">
     <el-tab-pane label="事件" name="event">
       <select-item-event-setting
       <select-item-event-setting
@@ -98,16 +141,22 @@ import SelectItemPropsSetting from './select-item-props-setting.vue'
 import SelectItemAnimateSetting from './select-item-animate-setting/index.vue'
 import SelectItemAnimateSetting from './select-item-animate-setting/index.vue'
 import SelectItemEventSetting from './select-item-event-setting/index.vue'
 import SelectItemEventSetting from './select-item-event-setting/index.vue'
 const activeName = ref('config')
 const activeName = ref('config')
-const activeNames = ref(['1'])
+const activeNames = ref<string[]>([])
 type SelectItemSettingProps = {
 type SelectItemSettingProps = {
   itemJson: IDoneJson | undefined
   itemJson: IDoneJson | undefined
   doneJson: IDoneJson[]
   doneJson: IDoneJson[]
 }
 }
 const selectItemSettingProps = withDefaults(defineProps<SelectItemSettingProps>(), {})
 const selectItemSettingProps = withDefaults(defineProps<SelectItemSettingProps>(), {})
-const emits = defineEmits(['update:itemJson', 'addHistory'])
+const emits = defineEmits(['update:itemJson', 'addHistory', 'addHistoryDebounced'])
 const slots = useSlots()
 const slots = useSlots()
 // 自由连线 直角连线都有自定义宽高以及禁止缩放和旋转
 // 自由连线 直角连线都有自定义宽高以及禁止缩放和旋转
 const is_line = computed(() => selectItemSettingProps.itemJson?.type === 'sys-line')
 const is_line = computed(() => selectItemSettingProps.itemJson?.type === 'sys-line')
+const componentPropertiesTitle = computed(() =>
+  selectItemSettingProps.itemJson?.tag === 'text-vue' ? '文字设置' : '组件属性'
+)
+const hasVisibleComponentProperties = computed(() =>
+  Object.values(selectItemSettingProps.itemJson?.props || {}).some((property) => !property.disabled)
+)
 const item_title = computed({
 const item_title = computed({
   get: () => {
   get: () => {
     return selectItemSettingProps.itemJson?.title
     return selectItemSettingProps.itemJson?.title
@@ -281,3 +330,146 @@ const hasDeviceBindSlot = computed(() => {
   return !!slots.deviceBind
   return !!slots.deviceBind
 })
 })
 </script>
 </script>
+<style scoped>
+.setting-stack {
+  display: flex;
+  gap: 12px;
+  flex-direction: column;
+}
+
+.setting-card {
+  padding: 14px;
+  background: #fff;
+  border: 1px solid #e5eaf1;
+  border-radius: 12px;
+  box-shadow: 0 1px 2px rgb(16 24 40 / 3%);
+}
+
+.setting-card__header {
+  display: flex;
+  gap: 9px;
+  margin-bottom: 14px;
+  align-items: flex-start;
+}
+
+.setting-card__index {
+  display: grid;
+  width: 26px;
+  height: 26px;
+  font-size: 10px;
+  font-weight: 700;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-radius: 8px;
+  flex: 0 0 auto;
+  place-items: center;
+}
+
+.setting-card__header h3 {
+  margin: 0;
+  font-size: 13px;
+  font-weight: 650;
+  line-height: 18px;
+  color: #253047;
+}
+
+.setting-card__header p {
+  margin: 2px 0 0;
+  font-size: 10px;
+  line-height: 15px;
+  color: #98a2b3;
+}
+
+.setting-card :deep(.el-form-item) {
+  margin-bottom: 0 !important;
+}
+
+.setting-card :deep(.el-form-item__label) {
+  height: 20px;
+  padding: 0 0 5px;
+  line-height: 20px;
+}
+
+.geometry-grid {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+  gap: 12px 10px;
+}
+
+.geometry-grid__wide {
+  grid-column: 1 / -1;
+}
+
+.state-grid {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+  gap: 8px;
+}
+
+.state-option {
+  display: flex;
+  min-width: 0;
+  min-height: 54px;
+  padding: 9px 10px;
+  background: #f7f9fc;
+  border: 1px solid #e7ebf2;
+  border-radius: 9px;
+  box-sizing: border-box;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.state-option--wide {
+  grid-column: 1 / -1;
+}
+
+.state-option > div {
+  min-width: 0;
+}
+
+.state-option strong,
+.state-option span {
+  display: block;
+}
+
+.state-option strong {
+  font-size: 11px;
+  font-weight: 600;
+  line-height: 16px;
+  color: #475467;
+}
+
+.state-option span {
+  margin-top: 1px;
+  overflow: hidden;
+  font-size: 9px;
+  line-height: 13px;
+  color: #98a2b3;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.state-option :deep(.el-switch) {
+  margin-left: 6px;
+  flex: 0 0 auto;
+}
+
+.setting-collapse {
+  margin-bottom: 4px;
+}
+
+:global(.dark) .setting-card {
+  background: #202735;
+  border-color: #303949;
+}
+
+:global(.dark) .setting-card__header h3,
+:global(.dark) .state-option strong {
+  color: #dbe3ed;
+}
+
+:global(.dark) .state-option {
+  background: #252e3d;
+  border-color: #374254;
+}
+</style>

+ 85 - 0
src/components/mt-edit/components/layout/right-aside/status-color-rules-editor.vue

@@ -0,0 +1,85 @@
+<template>
+  <div class="status-color-rules-editor">
+    <div v-for="(rule, index) in modelValue" :key="index" class="status-color-rule">
+      <el-input
+        :model-value="rule.value"
+        placeholder="匹配值"
+        @update:model-value="updateRule(index, 'value', $event)" />
+      <el-input
+        :model-value="rule.label"
+        placeholder="显示文字(可选)"
+        @update:model-value="updateRule(index, 'label', $event)" />
+      <div class="status-color-rule__actions">
+        <el-color-picker
+          :model-value="rule.color"
+          @update:model-value="updateRule(index, 'color', $event || '#909399')" />
+        <el-button type="danger" text @click="removeRule(index)">删除</el-button>
+      </div>
+    </div>
+    <el-button class="w-1/1" plain type="primary" @click="addRule">新增状态</el-button>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { IStatusColorRule } from '@/components/mt-edit/store/types'
+import { ElButton, ElColorPicker, ElInput } from 'element-plus'
+
+const props = withDefaults(
+  defineProps<{
+    modelValue?: IStatusColorRule[]
+  }>(),
+  {
+    modelValue: () => []
+  }
+)
+
+const emit = defineEmits<{
+  'update:modelValue': [value: IStatusColorRule[]]
+}>()
+
+const updateRule = (index: number, key: keyof IStatusColorRule, value: string) => {
+  const nextRules = props.modelValue.map((rule, ruleIndex) =>
+    ruleIndex === index ? { ...rule, [key]: value } : rule
+  )
+  emit('update:modelValue', nextRules)
+}
+
+const addRule = () => {
+  emit('update:modelValue', [
+    ...props.modelValue,
+    {
+      value: '',
+      label: '',
+      color: '#409eff'
+    }
+  ])
+}
+
+const removeRule = (index: number) => {
+  emit(
+    'update:modelValue',
+    props.modelValue.filter((_, ruleIndex) => ruleIndex !== index)
+  )
+}
+</script>
+
+<style scoped>
+.status-color-rules-editor {
+  width: 100%;
+}
+
+.status-color-rule {
+  display: grid;
+  padding: 8px;
+  margin-bottom: 8px;
+  background: var(--el-fill-color-light);
+  border-radius: 4px;
+  gap: 6px;
+}
+
+.status-color-rule__actions {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+</style>

+ 51 - 23
src/components/mt-edit/components/line-render/index.vue

@@ -233,6 +233,11 @@ import {
   isSystemShortcutKey,
   isSystemShortcutKey,
   positionArrarToPath
   positionArrarToPath
 } from '@/components/mt-edit/utils'
 } from '@/components/mt-edit/utils'
+import {
+  completeLineEdit,
+  hasLineDragStarted,
+  normalizeLineGeometry
+} from '@/components/mt-edit/utils/line-interaction'
 import { computed, ref, watch } from 'vue'
 import { computed, ref, watch } from 'vue'
 import { configStore } from '../../store/config'
 import { configStore } from '../../store/config'
 type LineRenderProps = {
 type LineRenderProps = {
@@ -304,9 +309,9 @@ const onMouseDown = (
     point_index !== lineRenderProps.itemJson.props.point_position.val.length - 1
     point_index !== lineRenderProps.itemJson.props.point_position.val.length - 1
   ) {
   ) {
     //删除该点
     //删除该点
-    const new_point_position = lineRenderProps.itemJson.props.point_position.val
+    const new_point_position = [...lineRenderProps.itemJson.props.point_position.val]
     new_point_position.splice(point_index, 1)
     new_point_position.splice(point_index, 1)
-    lineRenderEmits('update:itemJson', {
+    const new_item_json = {
       ...lineRenderProps.itemJson,
       ...lineRenderProps.itemJson,
       props: {
       props: {
         ...lineRenderProps.itemJson.props,
         ...lineRenderProps.itemJson.props,
@@ -315,7 +320,13 @@ const onMouseDown = (
           val: new_point_position
           val: new_point_position
         }
         }
       }
       }
-    })
+    }
+    completeLineEdit(
+      new_item_json,
+      true,
+      (value) => lineRenderEmits('update:itemJson', value),
+      () => lineRenderEmits('lineMouseUp')
+    )
     return
     return
   }
   }
   // 记录鼠标按下时实际点的坐标
   // 记录鼠标按下时实际点的坐标
@@ -326,10 +337,15 @@ const onMouseDown = (
   let new_x = 0
   let new_x = 0
   let new_y = 0
   let new_y = 0
   let first_click = true
   let first_click = true
+  let has_dragged = false
   const onMouseMove = (e: MouseTouchEvent) => {
   const onMouseMove = (e: MouseTouchEvent) => {
     // 记录鼠标移动的位置
     // 记录鼠标移动的位置
     const m_x = e instanceof MouseEvent ? e.clientX : e.touches[0].pageX
     const m_x = e instanceof MouseEvent ? e.clientX : e.touches[0].pageX
     const m_y = e instanceof MouseEvent ? e.clientY : e.touches[0].pageY
     const m_y = e instanceof MouseEvent ? e.clientY : e.touches[0].pageY
+    if (!hasLineDragStarted(m_x - d_x, m_y - d_y)) {
+      return
+    }
+    has_dragged = true
     // 移动的距离
     // 移动的距离
     const move_x = isSystemShortcutKey(de)
     const move_x = isSystemShortcutKey(de)
       ? 0
       ? 0
@@ -445,47 +461,59 @@ const onMouseDown = (
     document.removeEventListener('mouseup', onMouseUp)
     document.removeEventListener('mouseup', onMouseUp)
     document.removeEventListener('touchmove', onMouseMove)
     document.removeEventListener('touchmove', onMouseMove)
     document.removeEventListener('touchend', onMouseUp)
     document.removeEventListener('touchend', onMouseUp)
-    const itemRect = document
-      .querySelector(`#${lineRenderProps.itemJson.id} g .real`)!
-      .getBoundingClientRect()
-    const canvas_area_bounding_info = lineRenderProps.canvasDom!.getBoundingClientRect()
+    lineRenderEmits('setIntention', 'none')
+    if (!has_dragged) {
+      return
+    }
+    const line_element = document.querySelector(`#${lineRenderProps.itemJson.id} g .real`)
+    if (!line_element || !lineRenderProps.canvasDom) {
+      return
+    }
+    const itemRect = line_element.getBoundingClientRect()
+    const canvas_area_bounding_info = lineRenderProps.canvasDom.getBoundingClientRect()
     const new_left =
     const new_left =
       (itemRect?.left - canvas_area_bounding_info?.left) / lineRenderProps.canvasCfg.scale
       (itemRect?.left - canvas_area_bounding_info?.left) / lineRenderProps.canvasCfg.scale
     const new_top =
     const new_top =
       (itemRect?.top - canvas_area_bounding_info?.top) / lineRenderProps.canvasCfg.scale
       (itemRect?.top - canvas_area_bounding_info?.top) / lineRenderProps.canvasCfg.scale
     const move_x = new_left - lineRenderProps.itemJson.binfo.left
     const move_x = new_left - lineRenderProps.itemJson.binfo.left
     const move_y = new_top - lineRenderProps.itemJson.binfo.top
     const move_y = new_top - lineRenderProps.itemJson.binfo.top
-    lineRenderEmits('setIntention', 'none')
-    lineRenderEmits('update:itemJson', {
+    const normalized_geometry = normalizeLineGeometry(
+      {
+        left: new_left,
+        top: new_top,
+        width: itemRect.width / lineRenderProps.canvasCfg.scale,
+        height: itemRect.height / lineRenderProps.canvasCfg.scale
+      },
+      lineRenderProps.itemJson.props.point_position.val.map((m: { x: number; y: number }) => ({
+        x: m.x - move_x,
+        y: m.y - move_y
+      }))
+    )
+    const new_item_json = {
       ...lineRenderProps.itemJson,
       ...lineRenderProps.itemJson,
       binfo: {
       binfo: {
         ...lineRenderProps.itemJson.binfo,
         ...lineRenderProps.itemJson.binfo,
-        left: new_left,
-        top: new_top,
-        width: itemRect?.width / lineRenderProps.canvasCfg.scale,
-        height: itemRect?.height / lineRenderProps.canvasCfg.scale
+        ...normalized_geometry.binfo
       },
       },
       props: {
       props: {
         ...lineRenderProps.itemJson.props,
         ...lineRenderProps.itemJson.props,
         point_position: {
         point_position: {
           ...lineRenderProps.itemJson.props.point_position,
           ...lineRenderProps.itemJson.props.point_position,
-          val: lineRenderProps.itemJson.props.point_position.val.map(
-            (m: { x: number; y: number }) => {
-              return {
-                x: m.x - move_x,
-                y: m.y - move_y
-              }
-            }
-          )
+          val: normalized_geometry.points
         }
         }
       }
       }
-    })
+    }
+    completeLineEdit(
+      new_item_json,
+      has_dragged,
+      (value) => lineRenderEmits('update:itemJson', value),
+      () => lineRenderEmits('lineMouseUp')
+    )
   }
   }
   document.addEventListener('mousemove', onMouseMove)
   document.addEventListener('mousemove', onMouseMove)
   document.addEventListener('mouseup', onMouseUp)
   document.addEventListener('mouseup', onMouseUp)
   document.addEventListener('touchmove', onMouseMove)
   document.addEventListener('touchmove', onMouseMove)
   document.addEventListener('touchend', onMouseUp)
   document.addEventListener('touchend', onMouseUp)
-  lineRenderEmits('lineMouseUp')
 }
 }
 const getCenterPositions = (point_position: { x: number; y: number }[]) => {
 const getCenterPositions = (point_position: { x: number; y: number }[]) => {
   const res: { x: number; y: number }[] = []
   const res: { x: number; y: number }[] = []

+ 14 - 5
src/components/mt-edit/components/render-core/index.vue

@@ -43,6 +43,7 @@
           :grid="renderCoreProps.gridCfg"
           :grid="renderCoreProps.gridCfg"
           :done-json="renderCoreProps.doneJson"
           :done-json="renderCoreProps.doneJson"
           :lock-state="renderCoreProps.globalLock ? true : item.lock"
           :lock-state="renderCoreProps.globalLock ? true : item.lock"
+          :preview-mode="renderCoreProps.preivewMode"
           :line-append-enable="renderCoreProps.lineAppendEnable"
           :line-append-enable="renderCoreProps.lineAppendEnable"
           @update:item-json="onUpdateItemJson(index, $event)"
           @update:item-json="onUpdateItemJson(index, $event)"
           @set-intention="(val) => renderCoreEmits('setIntention', val)"
           @set-intention="(val) => renderCoreEmits('setIntention', val)"
@@ -63,6 +64,7 @@
       :grid="renderCoreProps.gridCfg"
       :grid="renderCoreProps.gridCfg"
       :done-json="renderCoreProps.doneJson"
       :done-json="renderCoreProps.doneJson"
       :lock-state="renderCoreProps.globalLock ? true : item.lock"
       :lock-state="renderCoreProps.globalLock ? true : item.lock"
+      :preview-mode="renderCoreProps.preivewMode"
       :line-append-enable="renderCoreProps.lineAppendEnable"
       :line-append-enable="renderCoreProps.lineAppendEnable"
       @update:item-json="onUpdateItemJson(index, $event)"
       @update:item-json="onUpdateItemJson(index, $event)"
       @set-intention="(val) => renderCoreEmits('setIntention', val)"
       @set-intention="(val) => renderCoreEmits('setIntention', val)"
@@ -92,7 +94,10 @@ import NowTimeVue from '@/components/custom-components/now-time-vue/index.vue'
 import KvVue from '@/components/custom-components/kv-vue/index.vue'
 import KvVue from '@/components/custom-components/kv-vue/index.vue'
 import SysButtonVue from '@/components/custom-components/sys-button-vue/index.vue'
 import SysButtonVue from '@/components/custom-components/sys-button-vue/index.vue'
 import WireframeVue from '@/components/custom-components/wireframe-vue/index.vue'
 import WireframeVue from '@/components/custom-components/wireframe-vue/index.vue'
+import StatusVue from '@/components/custom-components/status-vue/index.vue'
+import HikvisionVideoVue from '@/components/custom-components/hikvision-video-vue/index.vue'
 import { ElPopover } from 'element-plus'
 import { ElPopover } from 'element-plus'
+import { normalizeCoordinate } from '@/components/mt-dzr/utils/drag-position'
 const instance = getCurrentInstance()
 const instance = getCurrentInstance()
 const now_include_keys = Object.keys(instance?.appContext?.components as any)
 const now_include_keys = Object.keys(instance?.appContext?.components as any)
 if (!now_include_keys.includes('text-vue')) {
 if (!now_include_keys.includes('text-vue')) {
@@ -113,6 +118,12 @@ if (!now_include_keys.includes('sys-button-vue')) {
 if (!now_include_keys.includes('wireframe-vue')) {
 if (!now_include_keys.includes('wireframe-vue')) {
   instance?.appContext.app.component('wireframe-vue', WireframeVue)
   instance?.appContext.app.component('wireframe-vue', WireframeVue)
 }
 }
+if (!now_include_keys.includes('status-vue')) {
+  instance?.appContext.app.component('status-vue', StatusVue)
+}
+if (!now_include_keys.includes('hikvision-video-vue')) {
+  instance?.appContext.app.component('hikvision-video-vue', HikvisionVideoVue)
+}
 type RenderCoreProps = {
 type RenderCoreProps = {
   doneJson: IDoneJson[]
   doneJson: IDoneJson[]
   canvasCfg: IGlobalStoreCanvasCfg
   canvasCfg: IGlobalStoreCanvasCfg
@@ -211,8 +222,8 @@ const onItemMove = (e: any, id: string) => {
               ...m,
               ...m,
               binfo: {
               binfo: {
                 ...m.binfo,
                 ...m.binfo,
-                left: init_pos?.left + e.move_length.x,
-                top: init_pos?.top + e.move_length.y
+                left: normalizeCoordinate((init_pos?.left ?? 0) + e.move_length.x),
+                top: normalizeCoordinate((init_pos?.top ?? 0) + e.move_length.y)
               }
               }
             }
             }
           }
           }
@@ -277,8 +288,6 @@ const onRightClick = (e: MouseEvent, item: IDoneJson) => {
   renderCoreEmits('onItemRightClick', e, item)
   renderCoreEmits('onItemRightClick', e, item)
 }
 }
 const onLineMouseUp = () => {
 const onLineMouseUp = () => {
-  setTimeout(() => {
-    cacheStore.addHistory(globalStore.done_json)
-  }, 1000)
+  cacheStore.addHistory()
 }
 }
 </script>
 </script>

+ 15 - 4
src/components/mt-edit/components/render-item/index.vue

@@ -13,12 +13,13 @@
       :item-json="item_json"
       :item-json="item_json"
       :grid="renderItemProps.grid"
       :grid="renderItemProps.grid"
       :canvas-cfg="renderItemProps.canvasCfg"
       :canvas-cfg="renderItemProps.canvasCfg"
-      :canvas-dom="renderItemProps.canvasDom" />
+      :canvas-dom="renderItemProps.canvasDom"
+      :preview-mode="renderItemProps.previewMode" />
     <component
     <component
       v-else-if="item_json.type === 'vue'"
       v-else-if="item_json.type === 'vue'"
       draggable="false"
       draggable="false"
       :is="item_json.tag"
       :is="item_json.tag"
-      v-bind="prosToVBind(item_json.props)"
+      v-bind="getComponentProps(item_json)"
       @update:model-value="(val: any) => onUpdateModelValue(item_json.props, val)" />
       @update:model-value="(val: any) => onUpdateModelValue(item_json.props, val)" />
     <img
     <img
       v-else-if="item_json.type === 'img'"
       v-else-if="item_json.type === 'img'"
@@ -62,18 +63,28 @@ type RenderItemProps = {
   doneJson?: IDoneJson[]
   doneJson?: IDoneJson[]
   lockState: boolean
   lockState: boolean
   lineAppendEnable?: boolean
   lineAppendEnable?: boolean
+  previewMode?: boolean
 }
 }
 const renderItemProps = withDefaults(defineProps<RenderItemProps>(), {
 const renderItemProps = withDefaults(defineProps<RenderItemProps>(), {
   doneJson: () => [],
   doneJson: () => [],
-  lineAppendEnable: false
+  lineAppendEnable: false,
+  previewMode: false
 })
 })
-const emits = defineEmits(['update:itemJson', 'setIntention', 'lineMouseUp'])
+const emits = defineEmits<{
+  'update:itemJson': [value: IDoneJson]
+  setIntention: [value: unknown]
+  lineMouseUp: []
+}>()
 const item_json = computed({
 const item_json = computed({
   get: () => renderItemProps.itemJson,
   get: () => renderItemProps.itemJson,
   set: (value) => {
   set: (value) => {
     emits('update:itemJson', value)
     emits('update:itemJson', value)
   }
   }
 })
 })
+const getComponentProps = (item: IDoneJson) => ({
+  ...prosToVBind(item.props),
+  ...(item.tag === 'hikvision-video-vue' ? { editorMode: !renderItemProps.previewMode } : {})
+})
 const onUpdateModelValue = (props: ILeftAsideConfigItemPublicProps, value: any) => {
 const onUpdateModelValue = (props: ILeftAsideConfigItemPublicProps, value: any) => {
   if (props.modelValue) {
   if (props.modelValue) {
     emits('update:itemJson', {
     emits('update:itemJson', {

+ 5 - 1
src/components/mt-edit/composables/index.ts

@@ -9,6 +9,7 @@ import type {
   ILeftAsideConfigItemPublicPropsType
   ILeftAsideConfigItemPublicPropsType
 } from '../store/types'
 } from '../store/types'
 import { objectDeepClone } from '../utils'
 import { objectDeepClone } from '../utils'
+import { normalizeCanvasScale } from '../utils/canvas-scale'
 
 
 const getImportedPropType = (val: unknown): ILeftAsideConfigItemPublicPropsType => {
 const getImportedPropType = (val: unknown): ILeftAsideConfigItemPublicPropsType => {
   if (typeof val === 'boolean') {
   if (typeof val === 'boolean') {
@@ -95,7 +96,10 @@ export const useExportJsonToDoneJson = (json: IExportJson) => {
     }
     }
   })
   })
   return {
   return {
-    canvasCfg: json.canvasCfg,
+    canvasCfg: {
+      ...json.canvasCfg,
+      scale: normalizeCanvasScale(json.canvasCfg.scale)
+    },
     gridCfg: json.gridCfg,
     gridCfg: json.gridCfg,
     importDoneJson
     importDoneJson
   }
   }

+ 43 - 36
src/components/mt-edit/composables/sys-line.ts

@@ -1,6 +1,7 @@
 import { nextTick } from 'vue'
 import { nextTick } from 'vue'
 import type { IDoneJson, IDoneJsonBinfo } from '../store/types'
 import type { IDoneJson, IDoneJsonBinfo } from '../store/types'
 import { getRectCenterCoordinate, getRectCoordinate, rotatePoint } from '../utils'
 import { getRectCenterCoordinate, getRectCoordinate, rotatePoint } from '../utils'
+import { normalizeLineGeometry } from '../utils/line-interaction'
 
 
 /**
 /**
  * 更新系统连线实际宽高
  * 更新系统连线实际宽高
@@ -19,18 +20,22 @@ export const useUpdateSysLineRect = (
     const new_top = (itemRect?.top - canvas_area_bounding_info?.top) / scale
     const new_top = (itemRect?.top - canvas_area_bounding_info?.top) / scale
     const move_x = new_left - f.binfo.left
     const move_x = new_left - f.binfo.left
     const move_y = new_top - f.binfo.top
     const move_y = new_top - f.binfo.top
-    f.binfo.left = new_left
-    f.binfo.top = new_top
-    f.binfo.width = itemRect?.width / scale
-    f.binfo.height = itemRect?.height / scale
+    const normalized_geometry = normalizeLineGeometry(
+      {
+        left: new_left,
+        top: new_top,
+        width: itemRect.width / scale,
+        height: itemRect.height / scale
+      },
+      f.props.point_position.val.map((m: { x: number; y: number }) => ({
+        x: m.x - move_x,
+        y: m.y - move_y
+      }))
+    )
+    Object.assign(f.binfo, normalized_geometry.binfo)
     f.props.point_position = {
     f.props.point_position = {
       ...f.props.point_position,
       ...f.props.point_position,
-      val: f.props.point_position.val.map((m: { x: number; y: number }) => {
-        return {
-          x: m.x - move_x,
-          y: m.y - move_y
-        }
-      })
+      val: normalized_geometry.points
     }
     }
   })
   })
 }
 }
@@ -107,21 +112,22 @@ export const useUpdateSysLine = (
         }
         }
         const move_x = new_left - f.binfo.left
         const move_x = new_left - f.binfo.left
         const move_y = new_top - f.binfo.top
         const move_y = new_top - f.binfo.top
-        f.binfo = {
-          ...f.binfo,
-          left: new_left,
-          top: new_top,
-          width: itemRect?.width / scale,
-          height: itemRect?.height / scale
-        }
+        const normalized_geometry = normalizeLineGeometry(
+          {
+            left: new_left,
+            top: new_top,
+            width: itemRect.width / scale,
+            height: itemRect.height / scale
+          },
+          f.props.point_position.val.map((m: { x: number; y: number }) => ({
+            x: m.x - move_x,
+            y: m.y - move_y
+          }))
+        )
+        f.binfo = { ...f.binfo, ...normalized_geometry.binfo }
         f.props.point_position = {
         f.props.point_position = {
           ...f.props.point_position,
           ...f.props.point_position,
-          val: f.props.point_position.val.map((m: { x: number; y: number }) => {
-            return {
-              x: m.x - move_x,
-              y: m.y - move_y
-            }
-          })
+          val: normalized_geometry.points
         }
         }
       } else {
       } else {
         f.props.bind_anchors.val.start = null
         f.props.bind_anchors.val.start = null
@@ -176,21 +182,22 @@ export const useUpdateSysLine = (
         }
         }
         const move_x = new_left - f.binfo.left
         const move_x = new_left - f.binfo.left
         const move_y = new_top - f.binfo.top
         const move_y = new_top - f.binfo.top
-        f.binfo = {
-          ...f.binfo,
-          left: new_left,
-          top: new_top,
-          width: itemRect?.width / scale,
-          height: itemRect?.height / scale
-        }
+        const normalized_geometry = normalizeLineGeometry(
+          {
+            left: new_left,
+            top: new_top,
+            width: itemRect.width / scale,
+            height: itemRect.height / scale
+          },
+          f.props.point_position.val.map((m: { x: number; y: number }) => ({
+            x: m.x - move_x,
+            y: m.y - move_y
+          }))
+        )
+        f.binfo = { ...f.binfo, ...normalized_geometry.binfo }
         f.props.point_position = {
         f.props.point_position = {
           ...f.props.point_position,
           ...f.props.point_position,
-          val: f.props.point_position.val.map((m: { x: number; y: number }) => {
-            return {
-              x: m.x - move_x,
-              y: m.y - move_y
-            }
-          })
+          val: normalized_geometry.points
         }
         }
       } else {
       } else {
         f.props.bind_anchors.val.end = null
         f.props.bind_anchors.val.end = null

+ 140 - 37
src/components/mt-edit/index.vue

@@ -2,29 +2,36 @@
   <div id="mt-edit" class="relative flex-auto w-1/1 h-1/1 dark">
   <div id="mt-edit" class="relative flex-auto w-1/1 h-1/1 dark">
     <el-container class="h-1/1">
     <el-container class="h-1/1">
       <el-header
       <el-header
-        height="45px"
+        height="72px"
         class="dark:bg-myDarkBgColor cb-border p-0 select-none flex"
         class="dark:bg-myDarkBgColor cb-border p-0 select-none flex"
         @mousedown="mainPanelRef?.stopListenerKeyDown()">
         @mousedown="mainPanelRef?.stopListenerKeyDown()">
         <header-panel
         <header-panel
           v-model:leftAside="aside_state.left_show"
           v-model:leftAside="aside_state.left_show"
           v-model:rightAside="aside_state.right_show"
           v-model:rightAside="aside_state.right_show"
           v-model:lock-state="globalStore.lock"
           v-model:lock-state="globalStore.lock"
+          :title="mtEidtProps.title"
           :selected-items-id="globalStore.selected_items_id"
           :selected-items-id="globalStore.selected_items_id"
           :group-enabled="header_group_enabled"
           :group-enabled="header_group_enabled"
           :un-group-enabled="header_un_group_enabled"
           :un-group-enabled="header_un_group_enabled"
           :align-enabled="header_align_enabled"
           :align-enabled="header_align_enabled"
+          :layer-enabled="header_layer_enabled"
+          :can-move-layer-up="header_can_move_layer_up"
+          :can-move-layer-down="header_can_move_layer_down"
           :delete-enabled="header_delete_enabled"
           :delete-enabled="header_delete_enabled"
-          :undo-enabled="cacheStore.historyIndex > 0"
-          :redo-enabled="cacheStore.historyIndex < cacheStore.history.length - 1"
+          :undo-enabled="cacheStore.historyPending || cacheStore.historyIndex > 0"
+          :redo-enabled="
+            !cacheStore.historyPending && cacheStore.historyIndex < cacheStore.history.length - 1
+          "
           :real-time-data="globalStore.real_time_data"
           :real-time-data="globalStore.real_time_data"
           :use-thumbnail="mtEidtProps.useThumbnail"
           :use-thumbnail="mtEidtProps.useThumbnail"
           :save-loading="mtEidtProps.saveLoading"
           :save-loading="mtEidtProps.saveLoading"
+          :unsaved="is_unsaved"
           @on-group-click="mainPanelRef?.createGroupItem"
           @on-group-click="mainPanelRef?.createGroupItem"
           @on-ungroup-click="mainPanelRef?.onUngroup"
           @on-ungroup-click="mainPanelRef?.onUngroup"
           @on-delete-click="onDeleteClick"
           @on-delete-click="onDeleteClick"
           @on-export-click="onExportClick"
           @on-export-click="onExportClick"
           @on-tree-click="done_json_tree_visiable = true"
           @on-tree-click="done_json_tree_visiable = true"
-          @on-help-click="onHelpClick"
+          @change-layer="onChangeLayer"
           @align-selected="onAlignSelected"
           @align-selected="onAlignSelected"
           @on-redo-click="onRedoClick"
           @on-redo-click="onRedoClick"
           @on-undo-click="onUndoClick"
           @on-undo-click="onUndoClick"
@@ -35,9 +42,9 @@
           @on-thumbnail-click="onThumbnailClick"
           @on-thumbnail-click="onThumbnailClick"
           @on-draw-line-click="onDrawLineClick" />
           @on-draw-line-click="onDrawLineClick" />
       </el-header>
       </el-header>
-      <el-container class="h-[calc(100%-45px-40px)]">
+      <el-container class="h-[calc(100%-72px)]">
         <el-aside
         <el-aside
-          :width="aside_state.left_show ? '200px' : '0px'"
+          :width="aside_state.left_show ? '278px' : '0px'"
           class="dark:bg-myDarkBgColor cr-border mt-edit-aside h-1/1 select-none"
           class="dark:bg-myDarkBgColor cr-border mt-edit-aside h-1/1 select-none"
           @mousedown="mainPanelRef?.stopListenerKeyDown()">
           @mousedown="mainPanelRef?.stopListenerKeyDown()">
           <left-aside :leftAsideConfig="leftAsideStore.config" />
           <left-aside :leftAsideConfig="leftAsideStore.config" />
@@ -53,7 +60,7 @@
             :line-append-enable="line_append_enable" />
             :line-append-enable="line_append_enable" />
         </el-main>
         </el-main>
         <el-aside
         <el-aside
-          :width="aside_state.right_show ? '200px' : '0px'"
+          :width="aside_state.right_show ? '340px' : '0px'"
           class="dark:bg-myDarkBgColor cl-border mt-edit-aside select-none"
           class="dark:bg-myDarkBgColor cl-border mt-edit-aside select-none"
           @mousedown="mainPanelRef?.stopListenerKeyDown()">
           @mousedown="mainPanelRef?.stopListenerKeyDown()">
           <right-aside>
           <right-aside>
@@ -85,12 +92,18 @@
         :canvas-cfg="globalStore.canvasCfg"
         :canvas-cfg="globalStore.canvasCfg"
         :grid-cfg="globalStore.gridCfg" />
         :grid-cfg="globalStore.gridCfg" />
     </el-dialog>
     </el-dialog>
-    <el-drawer v-model="done_json_tree_visiable" title="图形结构树" direction="ltr" size="30%">
+    <el-drawer
+      v-model="done_json_tree_visiable"
+      title="组件树"
+      direction="ltr"
+      size="480px"
+      class="mt-layer-drawer">
       <done-tree
       <done-tree
         :done-json="globalStore.done_json"
         :done-json="globalStore.done_json"
         :selected-items-id="globalStore.selected_items_id"
         :selected-items-id="globalStore.selected_items_id"
         @update-selected-items-id="onTreeUpdateSelectedItemsId"
         @update-selected-items-id="onTreeUpdateSelectedItemsId"
         @update-selected-id-hide="onDoneTreeUpdateSelectedIdHide"
         @update-selected-id-hide="onDoneTreeUpdateSelectedIdHide"
+        @update-all-hide="onDoneTreeUpdateAllHide"
         @move-layer="onDoneTreeMoveLayer" />
         @move-layer="onDoneTreeMoveLayer" />
     </el-drawer>
     </el-drawer>
   </div>
   </div>
@@ -112,7 +125,7 @@ import {
   ElMessage
   ElMessage
 } from 'element-plus'
 } from 'element-plus'
 import { globalStore, resetGlobalStore } from '@/components/mt-edit/store/global'
 import { globalStore, resetGlobalStore } from '@/components/mt-edit/store/global'
-import { computed, reactive, ref, useSlots } from 'vue'
+import { computed, onBeforeUnmount, onMounted, reactive, ref, useSlots } from 'vue'
 import DoneTree from '@/components/mt-edit/components/done-tree/index.vue'
 import DoneTree from '@/components/mt-edit/components/done-tree/index.vue'
 import { cacheStore } from './store/cache'
 import { cacheStore } from './store/cache'
 import ExportJson from '@/components/mt-edit/components/export-json/index.vue'
 import ExportJson from '@/components/mt-edit/components/export-json/index.vue'
@@ -120,13 +133,18 @@ import ImportJson from '@/components/mt-edit/components/import-json/index.vue'
 import { objectDeepClone } from './utils'
 import { objectDeepClone } from './utils'
 import { genExportJson, useExportJsonToDoneJson } from './composables'
 import { genExportJson, useExportJsonToDoneJson } from './composables'
 import type { IExportJson } from './components/types'
 import type { IExportJson } from './components/types'
+import { serializeEditorDocument } from './utils/editor-document'
 type MtEditProps = {
 type MtEditProps = {
+  title?: string
   useThumbnail?: boolean
   useThumbnail?: boolean
   saveLoading?: boolean
   saveLoading?: boolean
+  historyLimit?: number
 }
 }
 const mtEidtProps = withDefaults(defineProps<MtEditProps>(), {
 const mtEidtProps = withDefaults(defineProps<MtEditProps>(), {
+  title: '未命名项目',
   useThumbnail: false,
   useThumbnail: false,
-  saveLoading: false
+  saveLoading: false,
+  historyLimit: 80
 })
 })
 const emits = defineEmits(['onPreviewClick', 'onReturnClick', 'onSaveClick', 'onThumbnailClick'])
 const emits = defineEmits(['onPreviewClick', 'onReturnClick', 'onSaveClick', 'onThumbnailClick'])
 const slots = useSlots()
 const slots = useSlots()
@@ -158,13 +176,39 @@ const header_align_enabled = computed(() => {
   )
   )
   return selected_items.length > 1
   return selected_items.length > 1
 })
 })
+const selected_layer_index = computed(() => {
+  if (globalStore.selected_items_id.length !== 1) {
+    return -1
+  }
+  return globalStore.done_json.findIndex((item) => item.id === globalStore.selected_items_id[0])
+})
+const header_layer_enabled = computed(() => selected_layer_index.value >= 0)
+const header_can_move_layer_up = computed(
+  () =>
+    selected_layer_index.value >= 0 && selected_layer_index.value < globalStore.done_json.length - 1
+)
+const header_can_move_layer_down = computed(() => selected_layer_index.value > 0)
 const import_visible = ref(false)
 const import_visible = ref(false)
 const export_visible = ref(false)
 const export_visible = ref(false)
 const done_json_tree_visiable = ref(false)
 const done_json_tree_visiable = ref(false)
 const line_append_enable = ref(false)
 const line_append_enable = ref(false)
+const saved_snapshot = ref('')
+const createExportJson = () => {
+  const { exportJson } = genExportJson(
+    globalStore.canvasCfg,
+    globalStore.gridCfg,
+    globalStore.done_json
+  )
+  return exportJson
+}
+const getExportJson = () => objectDeepClone<IExportJson>(createExportJson())
+const current_snapshot = computed(() => serializeEditorDocument(createExportJson()))
+const is_unsaved = computed(
+  () => saved_snapshot.value !== '' && current_snapshot.value !== saved_snapshot.value
+)
 const onDeleteClick = () => {
 const onDeleteClick = () => {
   globalStore.deleteSelectedItems()
   globalStore.deleteSelectedItems()
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 }
 const onImportClick = () => {
 const onImportClick = () => {
   import_visible.value = true
   import_visible.value = true
@@ -181,7 +225,22 @@ const onDoneTreeUpdateSelectedIdHide = (id: string) => {
   const item = globalStore.done_json.find((f) => f.id === id)
   const item = globalStore.done_json.find((f) => f.id === id)
   if (item) {
   if (item) {
     item.hide = !item.hide
     item.hide = !item.hide
+    globalStore.setGlobalStoreDoneJson([...globalStore.done_json])
+    cacheStore.addHistory()
+  }
+}
+const onDoneTreeUpdateAllHide = (hide: boolean) => {
+  const hasChange = globalStore.done_json.some((item) => item.hide !== hide)
+  if (!hasChange) {
+    return
   }
   }
+  globalStore.setGlobalStoreDoneJson(
+    globalStore.done_json.map((item) => ({
+      ...item,
+      hide
+    }))
+  )
+  cacheStore.addHistory()
 }
 }
 const onDoneTreeMoveLayer = ({ id, direction }: { id: string; direction: 'up' | 'down' }) => {
 const onDoneTreeMoveLayer = ({ id, direction }: { id: string; direction: 'up' | 'down' }) => {
   const index = globalStore.done_json.findIndex((f) => f.id === id)
   const index = globalStore.done_json.findIndex((f) => f.id === id)
@@ -201,7 +260,29 @@ const onDoneTreeMoveLayer = ({ id, direction }: { id: string; direction: 'up' |
   doneJson[targetIndex] = temp
   doneJson[targetIndex] = temp
   globalStore.setGlobalStoreDoneJson(doneJson)
   globalStore.setGlobalStoreDoneJson(doneJson)
   globalStore.setSingleSelect(id)
   globalStore.setSingleSelect(id)
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
+}
+const onChangeLayer = (type: 'top' | 'bottom') => {
+  const currentIndex = selected_layer_index.value
+  if (currentIndex < 0) {
+    return
+  }
+
+  const lastIndex = globalStore.done_json.length - 1
+  const targetIndex = type === 'top' ? lastIndex : 0
+
+  if (targetIndex < 0 || targetIndex > lastIndex || targetIndex === currentIndex) {
+    ElMessage.info(type === 'top' ? '已经是最上层了' : '已经是最下层了')
+    return
+  }
+
+  const selectedId = globalStore.selected_items_id[0]
+  const doneJson = [...globalStore.done_json]
+  const [selectedItem] = doneJson.splice(currentIndex, 1)
+  doneJson.splice(targetIndex, 0, selectedItem)
+  globalStore.setGlobalStoreDoneJson(doneJson)
+  globalStore.setSingleSelect(selectedId)
+  cacheStore.addHistory()
 }
 }
 const onAlignSelected = (
 const onAlignSelected = (
   type:
   type:
@@ -216,41 +297,28 @@ const onAlignSelected = (
 ) => {
 ) => {
   mainPanelRef.value?.onAlignSelected(type)
   mainPanelRef.value?.onAlignSelected(type)
 }
 }
-const onHelpClick = () => {
-  window.open('http://mt.yaolm.top')
-}
 const onRedoClick = () => {
 const onRedoClick = () => {
-  mainPanelRef.value?.onRedo()
+  cacheStore.redo()
 }
 }
 const onUndoClick = () => {
 const onUndoClick = () => {
-  mainPanelRef.value?.onUndo()
+  cacheStore.undo()
 }
 }
 const onImportYes = async () => {
 const onImportYes = async () => {
   const res = await importJsonRef.value?.onImport()
   const res = await importJsonRef.value?.onImport()
   if (res) {
   if (res) {
     import_visible.value = false
     import_visible.value = false
-    cacheStore.addHistory(globalStore.done_json)
+    cacheStore.addHistory()
   } else {
   } else {
     ElMessage.error('导入失败,请检查数据格式')
     ElMessage.error('导入失败,请检查数据格式')
   }
   }
 }
 }
 const onPreviewClick = () => {
 const onPreviewClick = () => {
-  // 获取导出json
-  const { exportJson } = genExportJson(
-    globalStore.canvasCfg,
-    globalStore.gridCfg,
-    globalStore.done_json
-  )
-  emits('onPreviewClick', exportJson)
+  emits('onPreviewClick', getExportJson())
 }
 }
 const onSaveClick = () => {
 const onSaveClick = () => {
-  // 获取导出json
-  const { exportJson } = genExportJson(
-    globalStore.canvasCfg,
-    globalStore.gridCfg,
-    globalStore.done_json
-  )
-  emits('onSaveClick', exportJson)
+  if (!mtEidtProps.saveLoading) {
+    emits('onSaveClick', getExportJson())
+  }
 }
 }
 const onThumbnailClick = () => {
 const onThumbnailClick = () => {
   emits('onThumbnailClick')
   emits('onThumbnailClick')
@@ -263,25 +331,44 @@ const setImportJson = (exportJson: IExportJson) => {
   globalStore.canvasCfg = canvasCfg
   globalStore.canvasCfg = canvasCfg
   globalStore.gridCfg = gridCfg
   globalStore.gridCfg = gridCfg
   globalStore.setGlobalStoreDoneJson(importDoneJson)
   globalStore.setGlobalStoreDoneJson(importDoneJson)
-  cacheStore.history = [objectDeepClone(importDoneJson)]
-  cacheStore.historyIndex = 0
+  cacheStore.resetHistory()
+  saved_snapshot.value = current_snapshot.value
   return true
   return true
 }
 }
+const markSaved = (exportJson: IExportJson = getExportJson()) => {
+  saved_snapshot.value = serializeEditorDocument(exportJson)
+}
+const hasUnsavedChanges = () => is_unsaved.value
+const commitHistory = () => cacheStore.addHistory()
+const onGlobalKeyDown = (event: KeyboardEvent) => {
+  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
+    event.preventDefault()
+    onSaveClick()
+  }
+}
 const reset = () => {
 const reset = () => {
   resetGlobalStore()
   resetGlobalStore()
   cacheStore.setBoundingBox([])
   cacheStore.setBoundingBox([])
   cacheStore.setCopy([])
   cacheStore.setCopy([])
   cacheStore.adsorbPoint = []
   cacheStore.adsorbPoint = []
-  cacheStore.history = [[]]
-  cacheStore.historyIndex = 0
+  cacheStore.resetHistory()
   import_visible.value = false
   import_visible.value = false
   export_visible.value = false
   export_visible.value = false
   done_json_tree_visiable.value = false
   done_json_tree_visiable.value = false
   line_append_enable.value = false
   line_append_enable.value = false
+  saved_snapshot.value = ''
 }
 }
+onMounted(() => {
+  cacheStore.setHistoryLimit(mtEidtProps.historyLimit)
+  document.addEventListener('keydown', onGlobalKeyDown, true)
+})
+onBeforeUnmount(() => document.removeEventListener('keydown', onGlobalKeyDown, true))
 defineExpose({
 defineExpose({
   setImportJson,
   setImportJson,
-  reset
+  reset,
+  markSaved,
+  hasUnsavedChanges,
+  commitHistory
 })
 })
 </script>
 </script>
 <style scoped>
 <style scoped>
@@ -289,3 +376,19 @@ defineExpose({
   transition: width 0.3s;
   transition: width 0.3s;
 }
 }
 </style>
 </style>
+
+<style>
+.mt-layer-drawer .el-drawer__header {
+  padding: 17px 20px;
+  margin-bottom: 0;
+  font-size: 16px;
+  font-weight: 700;
+  color: #172033;
+  border-bottom: 1px solid #e3e8ef;
+}
+
+.mt-layer-drawer .el-drawer__body {
+  padding: 0;
+  overflow: hidden;
+}
+</style>

+ 202 - 0
src/components/mt-edit/store/__tests__/cache.spec.ts

@@ -0,0 +1,202 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { cacheStore } from '../cache'
+import {
+  createDefaultCanvasCfg,
+  globalStore,
+  resetGlobalStore
+} from '../global'
+import type { IDoneJson } from '../types'
+
+const createItem = (text = '初始文本'): IDoneJson => ({
+  id: 'node-1',
+  title: '文本',
+  type: 'vue',
+  binfo: {
+    left: 10,
+    top: 20,
+    width: 120,
+    height: 40,
+    angle: 0
+  },
+  props: {
+    text: {
+      title: '文本',
+      type: 'input',
+      val: text
+    }
+  },
+  resize: true,
+  rotate: true,
+  lock: false,
+  active: false,
+  hide: false,
+  common_animations: {
+    val: '',
+    delay: 'delay-0s',
+    speed: 'slow',
+    repeat: 'infinite'
+  },
+  events: []
+})
+
+const textValue = () => globalStore.done_json[0].props.text.val
+
+describe('cacheStore 历史事务', () => {
+  beforeEach(() => {
+    vi.useFakeTimers()
+    resetGlobalStore()
+    cacheStore.setHistoryLimit(80)
+    globalStore.setGlobalStoreDoneJson([createItem()])
+    cacheStore.resetHistory()
+  })
+
+  afterEach(() => {
+    cacheStore.resetHistory()
+    vi.useRealTimers()
+  })
+
+  it('把属性、事件、设备绑定和画布内容作为一个事务撤销和重做', () => {
+    globalStore.done_json[0].props.text.val = '修改后'
+    globalStore.done_json[0].binfo.left = 88
+    globalStore.done_json[0].events.push({
+      id: 'event-1',
+      type: 'click',
+      action: 'customCode',
+      change_attr: [],
+      custom_code: 'return true',
+      trigger_rule: {}
+    })
+    globalStore.done_json[0].deviceBinds = [
+      {
+        id: 'bind-1',
+        deviceId: 1001,
+        deviceProp: 'pressure',
+        nodeProp: 'text'
+      }
+    ]
+    globalStore.canvasCfg.color = '#101828'
+    cacheStore.addHistory()
+
+    expect(cacheStore.undo()).toBe(true)
+    expect(textValue()).toBe('初始文本')
+    expect(globalStore.done_json[0].binfo.left).toBe(10)
+    expect(globalStore.done_json[0].events).toEqual([])
+    expect(globalStore.done_json[0].deviceBinds).toBeUndefined()
+    expect(globalStore.canvasCfg).toEqual(createDefaultCanvasCfg())
+
+    expect(cacheStore.redo()).toBe(true)
+    expect(textValue()).toBe('修改后')
+    expect(globalStore.done_json[0].binfo.left).toBe(88)
+    expect(globalStore.done_json[0].events).toHaveLength(1)
+    expect(globalStore.done_json[0].deviceBinds).toHaveLength(1)
+    expect(globalStore.canvasCfg.color).toBe('#101828')
+  })
+
+  it('把同一输入项的连续修改合并成一条历史', () => {
+    for (const text of ['修', '修改', '修改完成']) {
+      globalStore.done_json[0].props.text.val = text
+      cacheStore.addHistoryDebounced('property:node-1:text', 350)
+    }
+
+    expect(cacheStore.historyPending).toBe(true)
+    expect(cacheStore.history).toHaveLength(1)
+
+    vi.advanceTimersByTime(350)
+
+    expect(cacheStore.historyPending).toBe(false)
+    expect(cacheStore.history).toHaveLength(2)
+    expect(cacheStore.undo()).toBe(true)
+    expect(textValue()).toBe('初始文本')
+  })
+
+  it('不记录画布缩放、平移和编辑辅助状态,也不会通过撤销改变它们', () => {
+    globalStore.canvasCfg.scale = 1.6
+    globalStore.canvasCfg.transform_origin = { x: 240, y: 180 }
+    globalStore.canvasCfg.drag_offset = { x: 80, y: 60 }
+    globalStore.canvasCfg.guide = false
+    globalStore.canvasCfg.adsorp = false
+    globalStore.canvasCfg.adsorp_diff = 20
+    globalStore.gridCfg = { enabled: false, align: false, size: 24 }
+    cacheStore.addHistory()
+
+    expect(cacheStore.history).toHaveLength(1)
+    expect(cacheStore.undo()).toBe(false)
+    expect(globalStore.canvasCfg.scale).toBe(1.6)
+    expect(globalStore.canvasCfg.transform_origin).toEqual({ x: 240, y: 180 })
+    expect(globalStore.canvasCfg.drag_offset).toEqual({ x: 80, y: 60 })
+    expect(globalStore.canvasCfg).toMatchObject({ guide: false, adsorp: false, adsorp_diff: 20 })
+    expect(globalStore.gridCfg).toEqual({ enabled: false, align: false, size: 24 })
+  })
+
+  it('撤销内容修改时保留当前画布视图和编辑辅助状态', () => {
+    globalStore.done_json[0].props.text.val = '修改后'
+    cacheStore.addHistory()
+
+    globalStore.canvasCfg.scale = 2
+    globalStore.canvasCfg.drag_offset = { x: 100, y: 120 }
+    globalStore.canvasCfg.guide = false
+    globalStore.gridCfg.enabled = false
+
+    expect(cacheStore.undo()).toBe(true)
+    expect(textValue()).toBe('初始文本')
+    expect(globalStore.canvasCfg.scale).toBe(2)
+    expect(globalStore.canvasCfg.drag_offset).toEqual({ x: 100, y: 120 })
+    expect(globalStore.canvasCfg.guide).toBe(false)
+    expect(globalStore.gridCfg.enabled).toBe(false)
+  })
+
+  it('在防抖尚未结束时执行撤销也不会丢失当前修改', () => {
+    globalStore.done_json[0].props.text.val = '尚未到期的修改'
+    cacheStore.addHistoryDebounced('property:node-1:text', 350)
+
+    expect(cacheStore.undo()).toBe(true)
+    expect(cacheStore.historyPending).toBe(false)
+    expect(cacheStore.history).toHaveLength(2)
+    expect(textValue()).toBe('初始文本')
+  })
+
+  it('撤销后产生新修改会截断原有重做分支', () => {
+    globalStore.done_json[0].props.text.val = '版本 B'
+    cacheStore.addHistory()
+    globalStore.done_json[0].props.text.val = '版本 C'
+    cacheStore.addHistory()
+
+    expect(cacheStore.undo()).toBe(true)
+    expect(textValue()).toBe('版本 B')
+
+    globalStore.done_json[0].props.text.val = '版本 D'
+    cacheStore.addHistory()
+
+    expect(cacheStore.history).toHaveLength(3)
+    expect(cacheStore.redo()).toBe(false)
+    expect(textValue()).toBe('版本 D')
+  })
+
+  it('忽略重复快照和仅选中状态的变化', () => {
+    cacheStore.addHistory()
+    expect(cacheStore.history).toHaveLength(1)
+
+    globalStore.setSingleSelect('node-1')
+    cacheStore.addHistory()
+
+    expect(cacheStore.history).toHaveLength(1)
+    expect(cacheStore.history[0].doneJson[0].active).toBe(false)
+  })
+
+  it('将历史容量限制在可配置的 50 到 100 条之间', () => {
+    cacheStore.setHistoryLimit(10)
+    expect(cacheStore.historyLimit).toBe(50)
+
+    for (let size = 11; size <= 70; size += 1) {
+      globalStore.done_json[0].binfo.width = size
+      cacheStore.addHistory()
+    }
+
+    expect(cacheStore.history).toHaveLength(50)
+    expect(cacheStore.historyIndex).toBe(49)
+    expect(cacheStore.history[0].doneJson[0].binfo.width).toBe(21)
+
+    cacheStore.setHistoryLimit(1000)
+    expect(cacheStore.historyLimit).toBe(100)
+  })
+})

+ 74 - 0
src/components/mt-edit/store/__tests__/hikvision-video.spec.ts

@@ -0,0 +1,74 @@
+import { describe, expect, it } from 'vitest'
+import { genExportJson, useExportJsonToDoneJson } from '../../composables'
+import { configStore } from '../config'
+import type { IDoneJson } from '../types'
+
+const camera = {
+  indexCode: 'camera-001',
+  name: '一号监控点',
+  nodeType: 'camera' as const,
+  available: true,
+  onlineStatus: true
+}
+
+describe('组态视频监控组件', () => {
+  it('使用单监控点专用属性注册到系统组件中', () => {
+    const config = configStore.sysComponent.find((item) => item.id === 'hikvision-video-vue')
+
+    expect(config).toBeDefined()
+    expect(config?.type).toBe('vue')
+    expect(config?.props.camera).toMatchObject({
+      type: 'hikvisionCamera',
+      val: null,
+      bindable: false
+    })
+  })
+
+  it('导出并重新导入后保留监控点和值编辑器类型', () => {
+    const config = configStore.sysComponent.find((item) => item.id === 'hikvision-video-vue')!
+    const doneJson: IDoneJson[] = [
+      {
+        id: 'hikvision-video-vue-test',
+        title: config.title,
+        type: config.type,
+        tag: config.id,
+        binfo: { left: 0, top: 0, width: 480, height: 270, angle: 0 },
+        resize: true,
+        rotate: true,
+        lock: false,
+        active: false,
+        hide: false,
+        props: {
+          camera: {
+            ...config.props.camera,
+            val: camera
+          }
+        },
+        common_animations: config.common_animations,
+        events: []
+      }
+    ]
+
+    const { exportJson } = genExportJson(
+      {
+        width: 1920,
+        height: 1080,
+        scale: 1,
+        color: '',
+        img: '',
+        guide: true,
+        adsorp: true,
+        adsorp_diff: 3,
+        transform_origin: { x: 0, y: 0 },
+        drag_offset: { x: 0, y: 0 }
+      },
+      { enabled: true, align: true, size: 10 },
+      doneJson
+    )
+    const restored = useExportJsonToDoneJson(exportJson).importDoneJson[0]
+
+    expect(exportJson.json[0].props.camera).toEqual(camera)
+    expect(restored.props.camera.type).toBe('hikvisionCamera')
+    expect(restored.props.camera.val).toEqual(camera)
+  })
+})

+ 127 - 14
src/components/mt-edit/store/cache.ts

@@ -1,6 +1,81 @@
-import { nextTick, reactive } from 'vue'
-import type { CacheBoundingBox, ICache, IDoneJson } from './types'
+import { reactive } from 'vue'
+import type {
+  CacheBoundingBox,
+  ICache,
+  IDoneJson,
+  IEditorHistoryCanvasCfg,
+  IEditorHistorySnapshot,
+  IGlobalStoreCanvasCfg
+} from './types'
 import { objectDeepClone } from '../utils'
 import { objectDeepClone } from '../utils'
+import { globalStore } from './global'
+
+const DEFAULT_HISTORY_LIMIT = 80
+const pendingHistoryTimers = new Map<string, ReturnType<typeof setTimeout>>()
+
+export const getEditorHistoryCanvasCfg = (
+  canvasCfg: IGlobalStoreCanvasCfg
+): IEditorHistoryCanvasCfg => ({
+  width: canvasCfg.width,
+  height: canvasCfg.height,
+  color: canvasCfg.color,
+  img: canvasCfg.img
+})
+
+const createHistorySnapshot = (): IEditorHistorySnapshot => {
+  const doneJson = objectDeepClone<IDoneJson[]>(globalStore.done_json)
+  const clearActiveState = (items: IDoneJson[]) => {
+    items.forEach((item) => {
+      item.active = false
+      clearActiveState(item.children || [])
+    })
+  }
+  clearActiveState(doneJson)
+  return {
+    doneJson,
+    canvasCfg: getEditorHistoryCanvasCfg(globalStore.canvasCfg)
+  }
+}
+
+const applyHistorySnapshot = (snapshot: IEditorHistorySnapshot) => {
+  const nextSnapshot = objectDeepClone<IEditorHistorySnapshot>(snapshot)
+  globalStore.setGlobalStoreDoneJson(nextSnapshot.doneJson)
+  globalStore.canvasCfg = {
+    ...globalStore.canvasCfg,
+    ...nextSnapshot.canvasCfg
+  }
+  globalStore.setSelectItems([])
+}
+
+const clearPendingHistory = () => {
+  pendingHistoryTimers.forEach((timer) => clearTimeout(timer))
+  pendingHistoryTimers.clear()
+  cacheStore.historyPending = false
+}
+
+const commitHistory = () => {
+  const snapshot = createHistorySnapshot()
+  const currentSnapshot = cacheStore.history[cacheStore.historyIndex]
+  if (currentSnapshot && JSON.stringify(currentSnapshot) === JSON.stringify(snapshot)) {
+    return
+  }
+  if (cacheStore.historyIndex + 1 < cacheStore.history.length) {
+    cacheStore.history.splice(cacheStore.historyIndex + 1)
+  }
+  cacheStore.history.push(snapshot)
+  if (cacheStore.history.length > cacheStore.historyLimit) {
+    cacheStore.history.splice(0, cacheStore.history.length - cacheStore.historyLimit)
+  }
+  cacheStore.historyIndex = cacheStore.history.length - 1
+}
+
+const flushPendingHistory = () => {
+  if (!pendingHistoryTimers.size) {
+    return
+  }
+  clearPendingHistory()
+  commitHistory()
+}
 
 
 export const cacheStore: ICache = reactive({
 export const cacheStore: ICache = reactive({
   boundingBox: [],
   boundingBox: [],
@@ -15,19 +90,57 @@ export const cacheStore: ICache = reactive({
   setCopy(val) {
   setCopy(val) {
     cacheStore.copy = val
     cacheStore.copy = val
   },
   },
-  history: [[]],
+  history: [],
   historyIndex: 0,
   historyIndex: 0,
-  addHistory(val: IDoneJson[]) {
-    nextTick(() => {
-      if (cacheStore.historyIndex + 1 < cacheStore.history.length) {
-        cacheStore.history.splice(cacheStore.historyIndex + 1)
-      }
-      cacheStore.history.push(objectDeepClone(val))
+  historyLimit: DEFAULT_HISTORY_LIMIT,
+  historyPending: false,
+  setHistoryLimit(limit: number) {
+    cacheStore.historyLimit = Math.min(100, Math.max(50, Math.round(limit)))
+    if (cacheStore.history.length > cacheStore.historyLimit) {
+      cacheStore.history.splice(0, cacheStore.history.length - cacheStore.historyLimit)
       cacheStore.historyIndex = cacheStore.history.length - 1
       cacheStore.historyIndex = cacheStore.history.length - 1
-      if (cacheStore.history.length > 20) {
-        cacheStore.history.shift()
-        cacheStore.historyIndex = cacheStore.history.length - 1
-      }
-    })
+    }
+  },
+  resetHistory() {
+    clearPendingHistory()
+    cacheStore.history = [createHistorySnapshot()]
+    cacheStore.historyIndex = 0
+  },
+  addHistory() {
+    clearPendingHistory()
+    commitHistory()
+  },
+  addHistoryDebounced(key = 'default', delay = 350) {
+    const pendingTimer = pendingHistoryTimers.get(key)
+    if (pendingTimer) {
+      clearTimeout(pendingTimer)
+    }
+    cacheStore.historyPending = true
+    pendingHistoryTimers.set(
+      key,
+      setTimeout(() => {
+        pendingHistoryTimers.delete(key)
+        commitHistory()
+        cacheStore.historyPending = pendingHistoryTimers.size > 0
+      }, delay)
+    )
+  },
+  undo() {
+    flushPendingHistory()
+    if (cacheStore.historyIndex <= 0) {
+      return false
+    }
+    cacheStore.historyIndex -= 1
+    applyHistorySnapshot(cacheStore.history[cacheStore.historyIndex])
+    return true
+  },
+  redo() {
+    flushPendingHistory()
+    if (cacheStore.historyIndex >= cacheStore.history.length - 1) {
+      return false
+    }
+    cacheStore.historyIndex += 1
+    applyHistorySnapshot(cacheStore.history[cacheStore.historyIndex])
+    return true
   }
   }
 })
 })

+ 182 - 8
src/components/mt-edit/store/config.ts

@@ -225,8 +225,52 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
     props: {
     props: {
       text: {
       text: {
         title: '文字内容',
         title: '文字内容',
+        type: 'textArea',
+        val: '文字',
+        autosize: { minRows: 2, maxRows: 6 },
+        group: '内容',
+        wide: true,
+        bindable: true
+      },
+      prefix: {
+        title: '显示前缀',
         type: 'input',
         type: 'input',
-        val: '文字'
+        val: '',
+        group: '数据显示',
+        bindable: false
+      },
+      suffix: {
+        title: '显示后缀',
+        type: 'input',
+        val: '',
+        group: '数据显示',
+        bindable: false
+      },
+      emptyText: {
+        title: '空值文字',
+        type: 'input',
+        val: '--',
+        group: '数据显示',
+        bindable: false
+      },
+      decimalPlaces: {
+        title: '数据小数位',
+        type: 'number',
+        val: 2,
+        min: 0,
+        max: 6,
+        step: 1,
+        precision: 0,
+        group: '数据显示',
+        bindable: false
+      },
+      showUnit: {
+        title: '显示数据单位',
+        type: 'switch',
+        val: true,
+        group: '数据显示',
+        wide: true,
+        bindable: false
       },
       },
       fontFamily: {
       fontFamily: {
         title: '字体',
         title: '字体',
@@ -277,22 +321,53 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
             value: 'Consolas, Monaco, monospace',
             value: 'Consolas, Monaco, monospace',
             label: '等宽字体'
             label: '等宽字体'
           }
           }
-        ]
+        ],
+        group: '文字样式',
+        wide: true,
+        bindable: false
       },
       },
       fontSize: {
       fontSize: {
         title: '文字大小',
         title: '文字大小',
         type: 'number',
         type: 'number',
-        val: 18
+        val: 18,
+        min: 1,
+        max: 300,
+        step: 1,
+        precision: 0,
+        group: '文字样式',
+        bindable: false
       },
       },
       fill: {
       fill: {
         title: '文字颜色',
         title: '文字颜色',
         type: 'color',
         type: 'color',
-        val: '#FFF700'
+        val: '#000000',
+        group: '文字样式',
+        bindable: false
       },
       },
       bold: {
       bold: {
         title: '文字加粗',
         title: '文字加粗',
         type: 'switch',
         type: 'switch',
-        val: false
+        val: false,
+        group: '文字样式',
+        bindable: false
+      },
+      italic: {
+        title: '文字斜体',
+        type: 'switch',
+        val: false,
+        group: '文字样式',
+        bindable: false
+      },
+      lineHeight: {
+        title: '文字行高',
+        type: 'number',
+        val: 1.5,
+        min: 0.5,
+        max: 5,
+        step: 0.1,
+        precision: 1,
+        group: '文字样式',
+        bindable: false
       },
       },
       textAlign: {
       textAlign: {
         title: '文字对齐',
         title: '文字对齐',
@@ -311,12 +386,89 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
             value: 'right',
             value: 'right',
             label: '右对齐'
             label: '右对齐'
           }
           }
-        ]
+        ],
+        group: '文字样式',
+        bindable: false
+      },
+      verticalAlign: {
+        title: '上下对齐',
+        type: 'select',
+        val: 'top',
+        options: [
+          {
+            value: 'top',
+            label: '顶部'
+          },
+          {
+            value: 'center',
+            label: '垂直居中'
+          },
+          {
+            value: 'bottom',
+            label: '底部'
+          }
+        ],
+        group: '文字样式',
+        bindable: false
       },
       },
       vertical: {
       vertical: {
         title: '竖排展示',
         title: '竖排展示',
         type: 'switch',
         type: 'switch',
-        val: false
+        val: false,
+        group: '文字样式',
+        bindable: false
+      }
+    },
+    common_animations: {
+      val: '',
+      delay: 'delay-0s',
+      speed: 'slow',
+      repeat: 'infinite'
+    }
+  },
+  {
+    id: 'status-vue',
+    title: '状态显示',
+    type: 'vue',
+    thumbnail: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgdmlld0JveD0iMCAwIDY0IDY0Ij48cmVjdCB4PSI2IiB5PSIxOCIgd2lkdGg9IjUyIiBoZWlnaHQ9IjI4IiByeD0iMTQiIGZpbGw9IiM2N2MyM2EiLz48Y2lyY2xlIGN4PSIyMCIgY3k9IjMyIiByPSI2IiBmaWxsPSJ3aGl0ZSIvPjxwYXRoIGQ9Ik0zMSAyOGgxOE0zMSAzNmgxMyIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L3N2Zz4=`,
+    props: {
+      value: {
+        title: '状态值',
+        type: 'input',
+        val: '1'
+      },
+      colorRules: {
+        title: '状态颜色',
+        type: 'statusColorRules',
+        bindable: false,
+        val: [
+          { value: '1', label: '正常', color: '#67c23a' },
+          { value: '0', label: '异常', color: '#f56c6c' }
+        ]
+      },
+      defaultColor: {
+        title: '默认颜色',
+        type: 'color',
+        bindable: false,
+        val: '#909399'
+      },
+      fontColor: {
+        title: '文字颜色',
+        type: 'color',
+        bindable: false,
+        val: '#ffffff'
+      },
+      fontSize: {
+        title: '文字大小',
+        type: 'number',
+        bindable: false,
+        val: 14
+      },
+      borderRadius: {
+        title: '圆角',
+        type: 'number',
+        bindable: false,
+        val: 36
       }
       }
     },
     },
     common_animations: {
     common_animations: {
@@ -577,7 +729,7 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
       color: {
       color: {
         title: '文字颜色',
         title: '文字颜色',
         type: 'color',
         type: 'color',
-        val: '#FFF700'
+        val: '#000000'
       },
       },
       borderColor: {
       borderColor: {
         title: '边框颜色',
         title: '边框颜色',
@@ -597,6 +749,28 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
       repeat: 'infinite'
       repeat: 'infinite'
     }
     }
   },
   },
+  {
+    id: 'hikvision-video-vue',
+    title: '视频监控',
+    type: 'vue',
+    thumbnail: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgdmlld0JveD0iMCAwIDY0IDY0Ij48cmVjdCB4PSI2IiB5PSIxMiIgd2lkdGg9IjUyIiBoZWlnaHQ9IjM2IiByeD0iNCIgZmlsbD0iIzBmMTcyYSIgc3Ryb2tlPSIjNjBiNWZhIiBzdHJva2Utd2lkdGg9IjIiLz48cGF0aCBkPSJNMjYgMjJsMTQgOC0xNCA4VjIyeiIgZmlsbD0iI2ZmZiIvPjxwYXRoIGQ9Ik0xOCA1MmgxME0zNiA1MmgxMCIgc3Ryb2tlPSIjNjBiNWZhIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvc3ZnPg==`,
+    props: {
+      camera: {
+        title: '监控点',
+        type: 'hikvisionCamera',
+        val: null,
+        group: '视频配置',
+        wide: true,
+        bindable: false
+      }
+    },
+    common_animations: {
+      val: '',
+      delay: 'delay-0s',
+      speed: 'slow',
+      repeat: 'infinite'
+    }
+  },
   {
   {
     id: 'sys-button-vue',
     id: 'sys-button-vue',
     title: '按钮',
     title: '按钮',

+ 2 - 2
src/components/mt-edit/store/context-menu.ts

@@ -1,6 +1,6 @@
 import { reactive } from 'vue'
 import { reactive } from 'vue'
 import type { ContextMenuInfoType, IContextMenu } from './types'
 import type { ContextMenuInfoType, IContextMenu } from './types'
-import { getSystemShortcutKeyName } from '../utils'
+import { getDeleteShortcutKeyName, getSystemShortcutKeyName } from '../utils'
 
 
 const systemShortcutKey = getSystemShortcutKeyName()
 const systemShortcutKey = getSystemShortcutKeyName()
 
 
@@ -27,7 +27,7 @@ export const contextMenuStore: IContextMenu = reactive({
       },
       },
       delete: {
       delete: {
         title: '删除',
         title: '删除',
-        hot_key: 'Delete',
+        hot_key: getDeleteShortcutKeyName(),
         enable: false
         enable: false
       },
       },
       group: {
       group: {

+ 39 - 2
src/components/mt-edit/store/types.ts

@@ -6,7 +6,14 @@ export type ILeftAsideConfigItemPublicPropsType =
   | 'switch'
   | 'switch'
   | 'number'
   | 'number'
   | 'jsonEdit'
   | 'jsonEdit'
+  | 'statusColorRules'
+  | 'hikvisionCamera'
   | 'textArea'
   | 'textArea'
+export interface IStatusColorRule {
+  value: string
+  label?: string
+  color: string
+}
 // 开放注册配置
 // 开放注册配置
 export type ILeftAsideConfigItemPublicProps = Record<
 export type ILeftAsideConfigItemPublicProps = Record<
   string,
   string,
@@ -17,7 +24,15 @@ export type ILeftAsideConfigItemPublicProps = Record<
     options?: any //比如说修改属性的时候用到了下拉框,这里面就可以放下拉框的选项
     options?: any //比如说修改属性的时候用到了下拉框,这里面就可以放下拉框的选项
     showAlpha?: boolean //颜色选择器是否支持透明度
     showAlpha?: boolean //颜色选择器是否支持透明度
     colorFormat?: string //颜色选择器的输出格式
     colorFormat?: string //颜色选择器的输出格式
+    min?: number //数字输入的最小值
+    max?: number //数字输入的最大值
+    step?: number //数字输入的步长
+    precision?: number //数字输入保留的小数位数
+    autosize?: boolean | { minRows?: number; maxRows?: number } //多行输入框自动高度
+    group?: string //属性面板中的分组名称
+    wide?: boolean //属性在双列布局中是否独占整行
     disabled?: boolean //如果禁用了将不会显示到右侧属性面板里,但是仍然可以通过代码修改属性
     disabled?: boolean //如果禁用了将不会显示到右侧属性面板里,但是仍然可以通过代码修改属性
+    bindable?: boolean //是否允许在设备绑定面板中选择,默认允许
   }
   }
 >
 >
 export type ILeftAsideConfigItemPublicType = 'svg' | 'vue' | 'img' | 'custom-svg'
 export type ILeftAsideConfigItemPublicType = 'svg' | 'vue' | 'img' | 'custom-svg'
@@ -227,6 +242,21 @@ export interface ILeftAside {
   registerConfig: (title: string, config: ILeftAsideConfigItemPublic[]) => void
   registerConfig: (title: string, config: ILeftAsideConfigItemPublic[]) => void
 }
 }
 // 缓存配置
 // 缓存配置
+export type IEditorDocumentCanvasCfg = Omit<
+  IGlobalStoreCanvasCfg,
+  'scale' | 'transform_origin' | 'drag_offset'
+>
+
+export type IEditorHistoryCanvasCfg = Pick<
+  IGlobalStoreCanvasCfg,
+  'width' | 'height' | 'color' | 'img'
+>
+
+export interface IEditorHistorySnapshot {
+  doneJson: IDoneJson[]
+  canvasCfg: IEditorHistoryCanvasCfg
+}
+
 export interface ICache {
 export interface ICache {
   boundingBox: CacheBoundingBox[]
   boundingBox: CacheBoundingBox[]
   setBoundingBox: (val: CacheBoundingBox[]) => void
   setBoundingBox: (val: CacheBoundingBox[]) => void
@@ -234,9 +264,16 @@ export interface ICache {
   setAdsorbPoint: (val: { type: AdsorbPointType; x: number; y: number; id: string }[]) => void
   setAdsorbPoint: (val: { type: AdsorbPointType; x: number; y: number; id: string }[]) => void
   copy: IDoneJson[]
   copy: IDoneJson[]
   setCopy: (val: IDoneJson[]) => void
   setCopy: (val: IDoneJson[]) => void
-  history: IDoneJson[][]
+  history: IEditorHistorySnapshot[]
   historyIndex: number
   historyIndex: number
-  addHistory: (done_json: IDoneJson[]) => void
+  historyLimit: number
+  historyPending: boolean
+  setHistoryLimit: (limit: number) => void
+  resetHistory: () => void
+  addHistory: () => void
+  addHistoryDebounced: (key?: string, delay?: number) => void
+  undo: () => boolean
+  redo: () => boolean
 }
 }
 // 杂项配置
 // 杂项配置
 export interface IConfig {
 export interface IConfig {

+ 39 - 0
src/components/mt-edit/utils/__tests__/canvas-pan.spec.ts

@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest'
+import { shouldStartCanvasPan } from '../canvas-pan'
+
+describe('画布拖拽手势', () => {
+  it('在拖拽模式下支持左键从画布或组件上开始拖动', () => {
+    expect(
+      shouldStartCanvasPan({
+        button: 0,
+        panModeEnabled: true,
+        systemShortcutPressed: false,
+        canvasLocked: false
+      })
+    ).toBe(true)
+  })
+
+  it('保留原有快捷键、右键和锁定状态拖拽', () => {
+    const baseGesture = {
+      panModeEnabled: false,
+      systemShortcutPressed: false,
+      canvasLocked: false
+    }
+
+    expect(shouldStartCanvasPan({ ...baseGesture, button: 2 })).toBe(true)
+    expect(shouldStartCanvasPan({ ...baseGesture, button: 0, systemShortcutPressed: true })).toBe(
+      true
+    )
+  })
+
+  it('普通左键仍然交给组件选择或画布框选', () => {
+    expect(
+      shouldStartCanvasPan({
+        button: 0,
+        panModeEnabled: false,
+        systemShortcutPressed: false,
+        canvasLocked: false
+      })
+    ).toBe(false)
+  })
+})

+ 60 - 0
src/components/mt-edit/utils/__tests__/canvas-scale.spec.ts

@@ -0,0 +1,60 @@
+import { describe, expect, it } from 'vitest'
+import {
+  calculateFitCanvas,
+  MAX_CANVAS_SCALE,
+  MIN_CANVAS_SCALE,
+  normalizeCanvasScale,
+  stepCanvasScale
+} from '../canvas-scale'
+
+describe('画布缩放计算', () => {
+  it('将所有缩放入口限制在 10% 到 400%', () => {
+    expect(normalizeCanvasScale(0)).toBe(MIN_CANVAS_SCALE)
+    expect(normalizeCanvasScale(-10)).toBe(MIN_CANVAS_SCALE)
+    expect(normalizeCanvasScale(4.01)).toBe(MAX_CANVAS_SCALE)
+    expect(normalizeCanvasScale(Number.NaN)).toBe(1)
+  })
+
+  it('按 10% 步长缩放且不会越过边界', () => {
+    expect(stepCanvasScale(1, 'out')).toBe(0.9)
+    expect(stepCanvasScale(1, 'in')).toBe(1.1)
+    expect(stepCanvasScale(MIN_CANVAS_SCALE, 'out')).toBe(MIN_CANVAS_SCALE)
+    expect(stepCanvasScale(MAX_CANVAS_SCALE, 'in')).toBe(MAX_CANVAS_SCALE)
+  })
+
+  it('按视口可用空间等比例缩放并居中画布', () => {
+    expect(
+      calculateFitCanvas({
+        viewportWidth: 1000,
+        viewportHeight: 700,
+        canvasWidth: 1920,
+        canvasHeight: 1080,
+        padding: { top: 20, right: 20, bottom: 80, left: 20 }
+      })
+    ).toEqual({
+      scale: 0.5,
+      transformOrigin: { x: 0, y: 0 },
+      dragOffset: { x: 20, y: 50 }
+    })
+  })
+
+  it('适应画布同样遵守缩放上下限', () => {
+    expect(
+      calculateFitCanvas({
+        viewportWidth: 100,
+        viewportHeight: 100,
+        canvasWidth: 10000,
+        canvasHeight: 10000
+      }).scale
+    ).toBe(MIN_CANVAS_SCALE)
+
+    expect(
+      calculateFitCanvas({
+        viewportWidth: 2000,
+        viewportHeight: 2000,
+        canvasWidth: 10,
+        canvasHeight: 10
+      }).scale
+    ).toBe(MAX_CANVAS_SCALE)
+  })
+})

+ 30 - 0
src/components/mt-edit/utils/__tests__/editor-document.spec.ts

@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest'
+import { serializeEditorDocument } from '../editor-document'
+import { createDefaultCanvasCfg, createDefaultGridCfg } from '../../store/global'
+import type { IExportJson } from '../../components/types'
+
+const createExportJson = (): IExportJson => ({
+  canvasCfg: createDefaultCanvasCfg(),
+  gridCfg: createDefaultGridCfg(),
+  json: []
+})
+
+describe('编辑器文档快照', () => {
+  it('忽略缩放、缩放中心和画布平移等视图状态', () => {
+    const saved = createExportJson()
+    const viewed = createExportJson()
+    viewed.canvasCfg.scale = 2
+    viewed.canvasCfg.transform_origin = { x: 300, y: 200 }
+    viewed.canvasCfg.drag_offset = { x: 120, y: 90 }
+
+    expect(serializeEditorDocument(viewed)).toBe(serializeEditorDocument(saved))
+  })
+
+  it('仍然识别画布内容配置变化', () => {
+    const saved = createExportJson()
+    const changed = createExportJson()
+    changed.canvasCfg.color = '#101828'
+
+    expect(serializeEditorDocument(changed)).not.toBe(serializeEditorDocument(saved))
+  })
+})

+ 12 - 0
src/components/mt-edit/utils/__tests__/keyboard-shortcut.spec.ts

@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest'
+import { isDeleteShortcutKey } from '../index'
+
+describe('删除快捷键', () => {
+  it.each(['Delete', 'Backspace'])('支持 %s 键', (key) => {
+    expect(isDeleteShortcutKey({ key } as KeyboardEvent)).toBe(true)
+  })
+
+  it('不会把普通按键识别成删除快捷键', () => {
+    expect(isDeleteShortcutKey({ key: 'd' } as KeyboardEvent)).toBe(false)
+  })
+})

+ 49 - 0
src/components/mt-edit/utils/__tests__/line-interaction.spec.ts

@@ -0,0 +1,49 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+  completeLineEdit,
+  hasLineDragStarted,
+  normalizeLineGeometry
+} from '@/components/mt-edit/utils/line-interaction'
+
+describe('自由连线拖动', () => {
+  it('鼠标轻微抖动时不开始拖动', () => {
+    expect(hasLineDragStarted(1, 1)).toBe(false)
+    expect(hasLineDragStarted(2, 2)).toBe(false)
+    expect(hasLineDragStarted(3, 0)).toBe(true)
+  })
+
+  it('只在真实拖动结束并写回最终数据后记录历史', () => {
+    const calls: string[] = []
+    const update = vi.fn(() => calls.push('update'))
+    const complete = vi.fn(() => calls.push('history'))
+
+    completeLineEdit({ id: 'line-1' }, false, update, complete)
+    expect(calls).toEqual([])
+
+    completeLineEdit({ id: 'line-1' }, true, update, complete)
+    expect(calls).toEqual(['update', 'history'])
+  })
+
+  it('将 DOM 和缩放计算产生的坐标统一保留两位小数', () => {
+    expect(
+      normalizeLineGeometry(
+        {
+          left: 1480.034912109375,
+          top: Number('669.9048461914062'),
+          width: 100.00000000003,
+          height: Number('50.99999999997')
+        },
+        [
+          { x: 0.00000000003, y: 0 },
+          { x: 99.99999999997, y: 50.123456 }
+        ]
+      )
+    ).toEqual({
+      binfo: { left: 1480.03, top: 669.9, width: 100, height: 51 },
+      points: [
+        { x: 0, y: 0 },
+        { x: 100, y: 50.12 }
+      ]
+    })
+  })
+})

+ 36 - 0
src/components/mt-edit/utils/__tests__/move-completion.spec.ts

@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest'
+import { completeModelMove } from '@/components/mt-dzr/utils/move-completion'
+import {
+  hasExceededDragThreshold,
+  normalizeCoordinate
+} from '@/components/mt-dzr/utils/drag-position'
+
+describe('元素拖动完成时序', () => {
+  it('先写回最终坐标,再通知上层记录历史', () => {
+    let currentPosition = { left: 10, top: 20 }
+    let recordedPosition = { ...currentPosition }
+    const finalPosition = { left: 80, top: 120 }
+
+    completeModelMove(
+      finalPosition,
+      (position) => {
+        currentPosition = position
+      },
+      () => {
+        recordedPosition = { ...currentPosition }
+      }
+    )
+
+    expect(recordedPosition).toEqual(finalPosition)
+  })
+
+  it('忽略点击时的轻微鼠标抖动', () => {
+    expect(hasExceededDragThreshold(1, 1)).toBe(false)
+    expect(hasExceededDragThreshold(3, 0)).toBe(true)
+  })
+
+  it('将缩放和吸附计算产生的坐标收敛到两位小数', () => {
+    expect(normalizeCoordinate(1480.034912109375)).toBe(1480.03)
+    expect(normalizeCoordinate(Number('669.9048461914062'))).toBe(669.9)
+  })
+})

+ 21 - 0
src/components/mt-edit/utils/__tests__/text-format.spec.ts

@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest'
+import {
+  formatTextDeviceValue,
+  formatTextDisplayValue
+} from '@/components/mt-edit/utils/text-format'
+
+describe('文字组件格式化', () => {
+  it('保留用户主动输入的换行', () => {
+    expect(formatTextDisplayValue('第一行\n第二行')).toBe('第一行\n第二行')
+  })
+
+  it('支持前后缀和空值文字', () => {
+    expect(formatTextDisplayValue('23.5', { prefix: '温度:', suffix: '℃' })).toBe('温度:23.5℃')
+    expect(formatTextDisplayValue('', { emptyText: '暂无数据' })).toBe('暂无数据')
+  })
+
+  it('支持设备数值精度和单位显示', () => {
+    expect(formatTextDeviceValue(23.456, '℃', { decimalPlaces: 2, showUnit: true })).toBe('23.46 ℃')
+    expect(formatTextDeviceValue(23.456, '℃', { decimalPlaces: 1, showUnit: false })).toBe('23.5')
+  })
+})

+ 14 - 0
src/components/mt-edit/utils/canvas-pan.ts

@@ -0,0 +1,14 @@
+export interface CanvasPanGesture {
+  button: number
+  panModeEnabled: boolean
+  systemShortcutPressed: boolean
+  canvasLocked: boolean
+}
+
+export const shouldStartCanvasPan = ({
+  button,
+  panModeEnabled,
+  systemShortcutPressed,
+  canvasLocked
+}: CanvasPanGesture) =>
+  canvasLocked || systemShortcutPressed || button === 2 || (panModeEnabled && button === 0)

+ 58 - 0
src/components/mt-edit/utils/canvas-scale.ts

@@ -0,0 +1,58 @@
+export const MIN_CANVAS_SCALE = 0.1
+export const MAX_CANVAS_SCALE = 4
+export const CANVAS_SCALE_STEP = 0.1
+
+export type CanvasScaleDirection = 'in' | 'out'
+
+export interface FitCanvasOptions {
+  viewportWidth: number
+  viewportHeight: number
+  canvasWidth: number
+  canvasHeight: number
+  padding?: Partial<{
+    top: number
+    right: number
+    bottom: number
+    left: number
+  }>
+}
+
+export const normalizeCanvasScale = (scale: number) => {
+  const safeScale = Number.isFinite(scale) ? scale : 1
+  const clampedScale = Math.min(MAX_CANVAS_SCALE, Math.max(MIN_CANVAS_SCALE, safeScale))
+  return Math.round(clampedScale * 100) / 100
+}
+
+export const stepCanvasScale = (scale: number, direction: CanvasScaleDirection) =>
+  normalizeCanvasScale(scale + (direction === 'in' ? CANVAS_SCALE_STEP : -CANVAS_SCALE_STEP))
+
+export const calculateFitCanvas = ({
+  viewportWidth,
+  viewportHeight,
+  canvasWidth,
+  canvasHeight,
+  padding
+}: FitCanvasOptions) => {
+  const safePadding = {
+    top: Math.max(0, padding?.top ?? 24),
+    right: Math.max(0, padding?.right ?? 24),
+    bottom: Math.max(0, padding?.bottom ?? 72),
+    left: Math.max(0, padding?.left ?? 24)
+  }
+  const availableWidth = Math.max(1, viewportWidth - safePadding.left - safePadding.right)
+  const availableHeight = Math.max(1, viewportHeight - safePadding.top - safePadding.bottom)
+  const safeCanvasWidth = Math.max(1, canvasWidth)
+  const safeCanvasHeight = Math.max(1, canvasHeight)
+  const scale = normalizeCanvasScale(
+    Math.min(availableWidth / safeCanvasWidth, availableHeight / safeCanvasHeight)
+  )
+
+  return {
+    scale,
+    transformOrigin: { x: 0, y: 0 },
+    dragOffset: {
+      x: Math.round(safePadding.left + (availableWidth - safeCanvasWidth * scale) / 2),
+      y: Math.round(safePadding.top + (availableHeight - safeCanvasHeight * scale) / 2)
+    }
+  }
+}

+ 20 - 0
src/components/mt-edit/utils/editor-document.ts

@@ -0,0 +1,20 @@
+import type { IExportJson } from '../components/types'
+import type { IEditorDocumentCanvasCfg, IGlobalStoreCanvasCfg } from '../store/types'
+
+export const getEditorDocumentCanvasCfg = (
+  canvasCfg: IGlobalStoreCanvasCfg
+): IEditorDocumentCanvasCfg => ({
+  width: canvasCfg.width,
+  height: canvasCfg.height,
+  color: canvasCfg.color,
+  img: canvasCfg.img,
+  guide: canvasCfg.guide,
+  adsorp: canvasCfg.adsorp,
+  adsorp_diff: canvasCfg.adsorp_diff
+})
+
+export const serializeEditorDocument = (exportJson: IExportJson) =>
+  JSON.stringify({
+    ...exportJson,
+    canvasCfg: getEditorDocumentCanvasCfg(exportJson.canvasCfg)
+  })

+ 23 - 3
src/components/mt-edit/utils/index.ts

@@ -16,6 +16,12 @@ export const isMacPlatform = () => {
 
 
 export const getSystemShortcutKeyName = () => (isMacPlatform() ? 'Cmd' : 'Ctrl')
 export const getSystemShortcutKeyName = () => (isMacPlatform() ? 'Cmd' : 'Ctrl')
 
 
+export const getDeleteShortcutKeyName = () =>
+  isMacPlatform() ? 'Delete (⌫)' : 'Delete / Backspace'
+
+export const isDeleteShortcutKey = (event: KeyboardEvent) =>
+  event.key === 'Delete' || event.key === 'Backspace'
+
 export const isSystemShortcutKey = (event: MouseEvent | KeyboardEvent | WheelEvent) =>
 export const isSystemShortcutKey = (event: MouseEvent | KeyboardEvent | WheelEvent) =>
   isMacPlatform() ? event.metaKey : event.ctrlKey
   isMacPlatform() ? event.metaKey : event.ctrlKey
 
 
@@ -783,6 +789,20 @@ export const handleAlign = (
   useUpdateSysLine(sys_lines, selected_done_json, canvasDom, scale)
   useUpdateSysLine(sys_lines, selected_done_json, canvasDom, scale)
   return selected_done_json
   return selected_done_json
 }
 }
+/**
+ * 展平组态节点,包含组合内的所有子节点
+ */
+export const flattenDoneJson = (json_arr: IDoneJson[]): IDoneJson[] => {
+  return json_arr.flatMap((item) => [item, ...flattenDoneJson(item.children || [])])
+}
+
+/**
+ * 按 ID 查找顶层或组合内的组态节点
+ */
+export const findDoneJsonItem = (json_arr: IDoneJson[], id: string) => {
+  return flattenDoneJson(json_arr).find((item) => item.id === id)
+}
+
 /**
 /**
  * 设置图形属性
  * 设置图形属性
  * @param id
  * @param id
@@ -793,9 +813,9 @@ export const handleAlign = (
  */
  */
 export const setItemAttr = (id: string, key: string, val: any, json_arr: IDoneJson[]) => {
 export const setItemAttr = (id: string, key: string, val: any, json_arr: IDoneJson[]) => {
   return new Promise((res) => {
   return new Promise((res) => {
-    const find_item = json_arr.find((f) => f.id === id)
+    const find_item = findDoneJsonItem(json_arr, id)
     if (!find_item) {
     if (!find_item) {
-      res({
+      return res({
         status: false,
         status: false,
         msg: '要设置的id不存在'
         msg: '要设置的id不存在'
       })
       })
@@ -808,7 +828,7 @@ export const setItemAttr = (id: string, key: string, val: any, json_arr: IDoneJs
   })
   })
 }
 }
 export const getItemAttr = (id: string, key: string, json_arr: IDoneJson[]) => {
 export const getItemAttr = (id: string, key: string, json_arr: IDoneJson[]) => {
-  const find_item = json_arr.find((f) => f.id === id)
+  const find_item = findDoneJsonItem(json_arr, id)
   if (!find_item) {
   if (!find_item) {
     return null
     return null
   }
   }

+ 46 - 0
src/components/mt-edit/utils/line-interaction.ts

@@ -0,0 +1,46 @@
+import {
+  hasExceededDragThreshold,
+  normalizeCoordinate
+} from '@/components/mt-dzr/utils/drag-position'
+
+type LinePoint = {
+  x: number
+  y: number
+}
+
+type LineBinfo = {
+  left: number
+  top: number
+  width: number
+  height: number
+}
+
+export const hasLineDragStarted = (deltaX: number, deltaY: number) =>
+  hasExceededDragThreshold(deltaX, deltaY)
+
+export const normalizeLineGeometry = (binfo: LineBinfo, points: LinePoint[]) => ({
+  binfo: {
+    left: normalizeCoordinate(binfo.left),
+    top: normalizeCoordinate(binfo.top),
+    width: normalizeCoordinate(binfo.width),
+    height: normalizeCoordinate(binfo.height)
+  },
+  points: points.map((point) => ({
+    x: normalizeCoordinate(point.x),
+    y: normalizeCoordinate(point.y)
+  }))
+})
+
+export const completeLineEdit = <T>(
+  value: T,
+  changed: boolean,
+  update: (value: T) => void,
+  complete: () => void
+) => {
+  if (!changed) {
+    return
+  }
+
+  update(value)
+  complete()
+}

+ 40 - 0
src/components/mt-edit/utils/text-format.ts

@@ -0,0 +1,40 @@
+export type TextDisplayFormat = {
+  prefix?: string
+  suffix?: string
+  emptyText?: string
+}
+
+export type TextDeviceFormat = {
+  decimalPlaces?: number
+  showUnit?: boolean
+}
+
+export const formatTextDisplayValue = (
+  value: unknown,
+  { prefix = '', suffix = '', emptyText = '--' }: TextDisplayFormat = {}
+) => {
+  const text = value === null || value === undefined ? '' : String(value)
+  if (text === '') {
+    return emptyText
+  }
+  return `${prefix}${text}${suffix}`
+}
+
+export const formatTextDeviceValue = (
+  value: unknown,
+  unit: string | undefined,
+  { decimalPlaces = 2, showUnit = true }: TextDeviceFormat = {}
+) => {
+  if (value === null || value === undefined || value === '') {
+    return ''
+  }
+
+  const precision = Math.min(Math.max(Math.trunc(decimalPlaces), 0), 6)
+  const text =
+    typeof value === 'number' && Number.isFinite(value) ? value.toFixed(precision) : String(value)
+
+  if (!showUnit || !unit) {
+    return text
+  }
+  return `${text} ${unit}`
+}

+ 5 - 4
src/locales/en.ts

@@ -656,7 +656,8 @@ export default {
     typeCreate: 'Dictionary type added',
     typeCreate: 'Dictionary type added',
     typeUpdate: 'Dictionary type editing',
     typeUpdate: 'Dictionary type editing',
     dataCreate: 'Dictionary data added',
     dataCreate: 'Dictionary data added',
-    dataUpdate: 'Dictionary data editing'
+    dataUpdate: 'Dictionary data editing',
+    reset: 'Reset'
   },
   },
   dialog: {
   dialog: {
     dialog: 'pop-up window',
     dialog: 'pop-up window',
@@ -1848,7 +1849,7 @@ export default {
       'Device location, online status and monitoring portal, support departmental screening, aggregation amplification and one-click tracking.',
       'Device location, online status and monitoring portal, support departmental screening, aggregation amplification and one-click tracking.',
     'mapvue383073-10': 'Map',
     'mapvue383073-10': 'Map',
     'mapvue383073-11': 'Satellites',
     'mapvue383073-11': 'Satellites',
-    'mapvue383073-12': 'over Total Devices per:',
+    'mapvue383073-12': 'Total Devices per:',
     'mapvue383073-13': 'Online Device',
     'mapvue383073-13': 'Online Device',
     'mapvue383073-14': 'Offline Devices',
     'mapvue383073-14': 'Offline Devices',
     'mapvue383073-15': 'Screening Results',
     'mapvue383073-15': 'Screening Results',
@@ -2954,7 +2955,7 @@ export default {
     'import-record086254-5': 'Search',
     'import-record086254-5': 'Search',
     'import-record086254-6': 'Reset',
     'import-record086254-6': 'Reset',
     'import-record086254-7': 'Batch No.',
     'import-record086254-7': 'Batch No.',
-    'import-record086254-8': 'over Total Devices per:',
+    'import-record086254-8': 'Total Devices per:',
     'import-record086254-9': 'Success Count',
     'import-record086254-9': 'Success Count',
     'import-record086254-10': 'Failure Count',
     'import-record086254-10': 'Failure Count',
     'import-record086254-11': 'Task Status',
     'import-record086254-11': 'Task Status',
@@ -3524,7 +3525,7 @@ export default {
     'devicestat181259-0': 'Device Status',
     'devicestat181259-0': 'Device Status',
     'devicestat181259-1': 'Total',
     'devicestat181259-1': 'Total',
     'devicetota735835-0': 'Device Overview',
     'devicetota735835-0': 'Device Overview',
-    'devicetota735835-1': 'over Total Devices per:',
+    'devicetota735835-1': 'Total Devices',
     'devicetota735835-2': 'Device Ledger',
     'devicetota735835-2': 'Device Ledger',
     'brandlistv345436-0': 'Choose brand',
     'brandlistv345436-0': 'Choose brand',
     'brandlistv345436-1': 'Dictionary tags',
     'brandlistv345436-1': 'Dictionary tags',

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

@@ -656,7 +656,8 @@ export default {
     typeCreate: '字典类型新增',
     typeCreate: '字典类型新增',
     typeUpdate: '字典类型编辑',
     typeUpdate: '字典类型编辑',
     dataCreate: '字典数据新增',
     dataCreate: '字典数据新增',
-    dataUpdate: '字典数据编辑'
+    dataUpdate: '字典数据编辑',
+    reset: '重置'
   },
   },
   dialog: {
   dialog: {
     dialog: '弹窗',
     dialog: '弹窗',
@@ -7144,6 +7145,7 @@ export default {
     'ryformvue846251-66': '取 消',
     'ryformvue846251-66': '取 消',
     'ryformvue846251-67': '审批通过',
     'ryformvue846251-67': '审批通过',
     'ryformvue846251-68': '审批拒绝',
     'ryformvue846251-68': '审批拒绝',
+    'ryformvue846251-69': '设备属性',
     'rysummaryv399759-0': '项目',
     'rysummaryv399759-0': '项目',
     'rysummaryv399759-1': '请输入项目',
     'rysummaryv399759-1': '请输入项目',
     'rysummaryv399759-2': '任务',
     'rysummaryv399759-2': '任务',

+ 2 - 0
src/plugins/echarts/index.ts

@@ -22,6 +22,7 @@ import {
   TooltipComponent,
   TooltipComponent,
   VisualMapComponent
   VisualMapComponent
 } from 'echarts/components'
 } from 'echarts/components'
+import { LabelLayout } from 'echarts/features'
 
 
 import { CanvasRenderer } from 'echarts/renderers'
 import { CanvasRenderer } from 'echarts/renderers'
 
 
@@ -35,6 +36,7 @@ echarts.use([
   AriaComponent,
   AriaComponent,
   ParallelComponent,
   ParallelComponent,
   VisualMapComponent,
   VisualMapComponent,
+  LabelLayout,
   BarChart,
   BarChart,
   LineChart,
   LineChart,
   PieChart,
   PieChart,

+ 38 - 0
src/utils/thresholdAlarmNoticeTracker.ts

@@ -0,0 +1,38 @@
+export function createThresholdAlarmNoticeTracker() {
+  const currentNotices = new Map<string, { noticeKey: string; fingerprint: string }>()
+  const acknowledgedFingerprints = new Map<string, string>()
+
+  return {
+    shouldCreate(alarmKey: string, fingerprint: string) {
+      return (
+        currentNotices.get(alarmKey)?.fingerprint !== fingerprint &&
+        acknowledgedFingerprints.get(alarmKey) !== fingerprint
+      )
+    },
+    isAcknowledged(alarmKey: string, fingerprint: string) {
+      return acknowledgedFingerprints.get(alarmKey) === fingerprint
+    },
+    record(alarmKey: string, noticeKey: string, fingerprint: string) {
+      const previousNoticeKey = currentNotices.get(alarmKey)?.noticeKey
+      acknowledgedFingerprints.delete(alarmKey)
+      currentNotices.set(alarmKey, { noticeKey, fingerprint })
+      return previousNoticeKey
+    },
+    close(alarmKey: string, noticeKey: string) {
+      if (currentNotices.get(alarmKey)?.noticeKey !== noticeKey) return false
+
+      const currentNotice = currentNotices.get(alarmKey)
+      currentNotices.delete(alarmKey)
+      acknowledgedFingerprints.set(alarmKey, currentNotice!.fingerprint)
+      return true
+    },
+    pass(alarmKey: string) {
+      currentNotices.delete(alarmKey)
+      acknowledgedFingerprints.delete(alarmKey)
+    },
+    clear() {
+      currentNotices.clear()
+      acknowledgedFingerprints.clear()
+    }
+  }
+}

+ 49 - 0
src/utils/thresholdAlarmWindow.ts

@@ -0,0 +1,49 @@
+export interface AlarmPoint {
+  ts: number
+  value: number
+}
+
+export interface WindowChangeAlarm {
+  change: number
+  direction: 'increase' | 'decrease'
+  fromValue: number
+  fromTs: number
+  toTs: number
+  toValue: number
+}
+
+export function getWindowChangeAlarm(
+  points: AlarmPoint[],
+  anchorTs: number,
+  windowSeconds: number,
+  range: number
+): WindowChangeAlarm | undefined {
+  if (windowSeconds <= 0 || range < 0) return undefined
+
+  const startTs = anchorTs - windowSeconds * 1000
+  const windowPoints = points
+    .filter((point) => point.ts > startTs && point.ts <= anchorTs)
+    .sort((left, right) => left.ts - right.ts)
+
+  let alarm: WindowChangeAlarm | undefined
+
+  for (let index = 1; index < windowPoints.length; index += 1) {
+    const previousPoint = windowPoints[index - 1]
+    const currentPoint = windowPoints[index]
+    const delta = currentPoint.value - previousPoint.value
+    const change = Math.abs(delta)
+
+    if (change <= range) continue
+
+    alarm = {
+      change,
+      direction: delta > 0 ? 'increase' : 'decrease',
+      fromValue: previousPoint.value,
+      fromTs: previousPoint.ts,
+      toTs: currentPoint.ts,
+      toValue: currentPoint.value
+    }
+  }
+
+  return alarm
+}

+ 8 - 0
src/utils/useSocketBus.ts

@@ -117,6 +117,8 @@ export interface Dimensions {
   isText?: boolean
   isText?: boolean
   minValue?: number
   minValue?: number
   maxValue?: number
   maxValue?: number
+  periodSecond?: number
+  changeValue?: number
   axisMin?: number
   axisMin?: number
   axisMax?: number
   axisMax?: number
   id?: number
   id?: number
@@ -155,6 +157,8 @@ export interface IotTdItem {
   alarmSettingId?: number
   alarmSettingId?: number
   maxValue?: unknown
   maxValue?: unknown
   minValue?: unknown
   minValue?: unknown
+  periodSecond?: unknown
+  changeValue?: unknown
   axisMax?: unknown
   axisMax?: unknown
   axisMin?: unknown
   axisMin?: unknown
 }
 }
@@ -172,6 +176,8 @@ export function mapToDimension(item: IotTdItem): RawDimension {
     id: item.alarmSettingId,
     id: item.alarmSettingId,
     maxValue: item.alarmSettingId ? normalizeRangeValue(item.maxValue) : undefined,
     maxValue: item.alarmSettingId ? normalizeRangeValue(item.maxValue) : undefined,
     minValue: item.alarmSettingId ? normalizeRangeValue(item.minValue) : undefined,
     minValue: item.alarmSettingId ? normalizeRangeValue(item.minValue) : undefined,
+    periodSecond: item.alarmSettingId ? normalizeRangeValue(item.periodSecond) : undefined,
+    changeValue: item.alarmSettingId ? normalizeRangeValue(item.changeValue) : undefined,
     axisMax: item.alarmSettingId ? normalizeRangeValue(item.axisMax) : undefined,
     axisMax: item.alarmSettingId ? normalizeRangeValue(item.axisMax) : undefined,
     axisMin: item.alarmSettingId ? normalizeRangeValue(item.axisMin) : undefined
     axisMin: item.alarmSettingId ? normalizeRangeValue(item.axisMin) : undefined
   }
   }
@@ -194,6 +200,8 @@ export interface RangeSettingDraft {
   color: string
   color: string
   minValue?: number
   minValue?: number
   maxValue?: number
   maxValue?: number
+  periodSecond?: number
+  changeValue?: number
   axisMin?: number
   axisMin?: number
   axisMax?: number
   axisMax?: number
 }
 }

+ 827 - 106
src/views/maotu/components/DeviceBindPanel.vue

@@ -1,91 +1,246 @@
 <template>
 <template>
-  <div class="w-1/1">
-    <el-button class="w-1/1" @click="openDrawer">点击配置</el-button>
-
-    <el-drawer v-model="drawerVisible" title="配置设备绑定" direction="ltr" size="520px">
-      <el-empty v-if="!devices.length" description="当前组态项目未绑定设备" />
-
-      <template v-else>
-        <el-form ref="formRef" :model="formModel" label-position="top" size="small">
-          <div
-            v-for="(bindItem, index) in formModel.deviceBinds"
-            :key="bindItem.id"
-            class="mb-12px">
-            <el-form-item
-              label="选择设备"
-              :prop="`deviceBinds.${index}.deviceId`"
-              :rules="rules.deviceId">
-              <el-select
-                v-model="bindItem.deviceId"
-                filterable
-                clearable
-                class="w-1/1"
-                placeholder="请选择设备"
-                :disabled="!bindItem.editing"
-                @change="onDeviceChange(bindItem)">
-                <el-option
-                  v-for="device in devices"
-                  :key="device.id"
-                  :label="formatDeviceLabel(device)"
-                  :value="device.id" />
-              </el-select>
-            </el-form-item>
-
-            <el-form-item
-              label="设备属性"
-              :prop="`deviceBinds.${index}.deviceProp`"
-              :rules="rules.deviceProp">
-              <el-select
-                v-model="bindItem.deviceProp"
-                filterable
-                clearable
-                class="w-1/1"
-                placeholder="请选择设备属性"
-                :disabled="!bindItem.editing || !bindItem.deviceId">
-                <el-option
-                  v-for="deviceProp in getDeviceProps(bindItem.deviceId)"
-                  :key="deviceProp.value"
-                  :label="deviceProp.label"
-                  :value="deviceProp.value" />
-              </el-select>
-            </el-form-item>
-
-            <el-form-item
-              label="图形属性"
-              :prop="`deviceBinds.${index}.nodeProp`"
-              :rules="rules.nodeProp">
-              <el-select
-                v-model="bindItem.nodeProp"
-                filterable
-                clearable
-                class="w-1/1"
-                placeholder="请选择图形属性"
-                :disabled="!bindItem.editing">
-                <el-option
-                  v-for="nodeProp in nodePropOptions"
-                  :key="nodeProp.value"
-                  :label="nodeProp.label"
-                  :value="nodeProp.value" />
-              </el-select>
-            </el-form-item>
-
-            <el-button
-              v-if="bindItem.editing"
-              type="success"
-              size="small"
-              class="w-1/1"
-              @click="saveBind(index)">
-              绑定
-            </el-button>
-            <el-button v-else type="danger" size="small" class="w-1/1" @click="removeBind(index)">
-              解绑
-            </el-button>
+  <div class="device-bind-panel">
+    <div class="device-bind-overview" :class="{ 'is-empty': !bindCount }">
+      <div class="device-bind-overview__icon">
+        <el-icon><Connection /></el-icon>
+      </div>
+      <div class="device-bind-overview__content">
+        <strong>设备绑定</strong>
+        <span class="device-bind-overview__status" :class="{ 'is-active': bindCount }">
+          {{ bindCount ? `已绑定 ${bindCount} 项` : '尚未绑定' }}
+        </span>
+      </div>
+      <el-button type="primary" plain size="small" @click="openDrawer">
+        {{ bindCount ? '管理' : '绑定' }}
+      </el-button>
+    </div>
+
+    <el-drawer
+      v-model="drawerVisible"
+      title="配置设备绑定"
+      direction="rtl"
+      size="600px"
+      class="device-bind-drawer"
+      destroy-on-close>
+      <template #header>
+        <div class="device-bind-drawer__header">
+          <span class="device-bind-drawer__header-icon">
+            <el-icon><Connection /></el-icon>
+          </span>
+          <div>
+            <h2>设备绑定</h2>
+            <p>将设备数据点映射到“{{ item.title || '当前组件' }}”的可绑定属性</p>
+          </div>
+        </div>
+      </template>
 
 
-            <el-divider />
+      <div class="device-bind-workspace">
+        <div class="device-bind-toolbar">
+          <div>
+            <strong>绑定关系</strong>
+            <span>{{ savedBindCount }} 项已生效</span>
           </div>
           </div>
-        </el-form>
+          <el-button
+            type="primary"
+            size="small"
+            :disabled="!devices.length || !nodePropOptions.length || hasEditingBind"
+            @click="addBind">
+            <el-icon><Plus /></el-icon>
+            新增绑定
+          </el-button>
+        </div>
+
+        <el-empty
+          v-if="!devices.length"
+          class="device-bind-empty"
+          :image-size="92"
+          description="当前组态项目还没有可用设备">
+          <p>请先返回项目管理,为当前组态关联设备</p>
+        </el-empty>
+
+        <el-empty
+          v-else-if="!nodePropOptions.length"
+          class="device-bind-empty"
+          :image-size="92"
+          description="当前组件没有可绑定属性">
+          <p>该组件的属性暂不支持设备数据绑定</p>
+        </el-empty>
+
+        <template v-else>
+          <el-empty
+            v-if="!formModel.deviceBinds.length"
+            class="device-bind-empty"
+            :image-size="82"
+            description="暂未建立绑定关系">
+            <el-button type="primary" plain size="small" @click="addBind">
+              创建第一条绑定
+            </el-button>
+          </el-empty>
+
+          <el-form
+            v-else
+            ref="formRef"
+            :model="formModel"
+            label-position="top"
+            size="small"
+            class="device-bind-list">
+            <article
+              v-for="(bindItem, index) in formModel.deviceBinds"
+              :key="bindItem.id"
+              class="device-bind-card"
+              :class="{ 'is-editing': bindItem.editing }">
+              <template v-if="bindItem.editing">
+                <header class="device-bind-card__editor-header">
+                  <div>
+                    <span>{{ bindItem.isNew ? '新增绑定' : '编辑绑定' }}</span>
+                    <strong>配置数据来源与目标属性</strong>
+                  </div>
+                  <el-tag size="small" type="warning" effect="light">编辑中</el-tag>
+                </header>
+
+                <div class="device-bind-card__editor-body">
+                  <section class="bind-editor-step">
+                    <div class="bind-editor-step__heading">
+                      <span>1</span>
+                      <div>
+                        <strong>选择数据来源</strong>
+                        <p>先选择设备,再选择设备数据点</p>
+                      </div>
+                    </div>
+                    <el-form-item
+                      label="设备"
+                      :prop="`deviceBinds.${index}.deviceId`"
+                      :rules="rules.deviceId">
+                      <el-select
+                        v-model="bindItem.deviceId"
+                        filterable
+                        clearable
+                        placeholder="搜索或选择设备"
+                        @change="onDeviceChange(bindItem)">
+                        <el-option
+                          v-for="device in devices"
+                          :key="device.id"
+                          :label="formatDeviceLabel(device)"
+                          :value="device.id" />
+                      </el-select>
+                    </el-form-item>
+                    <el-form-item
+                      label="设备数据点"
+                      :prop="`deviceBinds.${index}.deviceProp`"
+                      :rules="rules.deviceProp">
+                      <el-select
+                        v-model="bindItem.deviceProp"
+                        filterable
+                        clearable
+                        placeholder="选择需要读取的数据点"
+                        :disabled="!bindItem.deviceId">
+                        <el-option
+                          v-for="deviceProp in getDeviceProps(bindItem.deviceId)"
+                          :key="deviceProp.value"
+                          :label="deviceProp.label"
+                          :value="deviceProp.value" />
+                      </el-select>
+                    </el-form-item>
+                  </section>
 
 
-        <el-button type="primary" size="small" class="w-1/1" @click="addBind">新增</el-button>
+                  <section class="bind-editor-step">
+                    <div class="bind-editor-step__heading">
+                      <span>2</span>
+                      <div>
+                        <strong>选择映射目标</strong>
+                        <p>设备数据会实时写入所选组件属性</p>
+                      </div>
+                    </div>
+                    <el-form-item
+                      label="组件属性"
+                      :prop="`deviceBinds.${index}.nodeProp`"
+                      :rules="rules.nodeProp">
+                      <el-select
+                        :model-value="getBindTargetValue(bindItem)"
+                        filterable
+                        clearable
+                        placeholder="选择要驱动的组件属性"
+                        @update:model-value="onNodePropChange(bindItem, $event)">
+                        <el-option
+                          v-for="nodeProp in nodePropOptions"
+                          :key="nodeProp.value"
+                          :label="nodeProp.label"
+                          :value="nodeProp.value" />
+                      </el-select>
+                    </el-form-item>
+                  </section>
+                </div>
+
+                <footer class="device-bind-card__editor-footer">
+                  <el-button size="small" @click="cancelEdit(index)">取消</el-button>
+                  <el-button type="primary" size="small" @click="saveBind(index)">
+                    <el-icon><Check /></el-icon>
+                    保存绑定
+                  </el-button>
+                </footer>
+              </template>
+
+              <template v-else>
+                <header class="device-bind-card__summary-header">
+                  <span class="device-bind-card__device-icon">
+                    <el-icon><Monitor /></el-icon>
+                  </span>
+                  <div>
+                    <strong>{{ getDeviceLabel(bindItem.deviceId) }}</strong>
+                    <span>{{ getDeviceCode(bindItem.deviceId) }}</span>
+                  </div>
+                  <el-button
+                    text
+                    circle
+                    size="small"
+                    aria-label="编辑绑定"
+                    :disabled="hasEditingBind"
+                    @click="editBind(index)">
+                    <el-icon><EditPen /></el-icon>
+                  </el-button>
+                  <el-popconfirm
+                    width="210"
+                    title="确认解除这条设备绑定吗?"
+                    confirm-button-text="解绑"
+                    cancel-button-text="取消"
+                    @confirm="removeBind(index)">
+                    <template #reference>
+                      <el-button
+                        text
+                        circle
+                        size="small"
+                        type="danger"
+                        aria-label="解除绑定"
+                        :disabled="hasEditingBind"
+                        @click.stop>
+                        <el-icon><Delete /></el-icon>
+                      </el-button>
+                    </template>
+                  </el-popconfirm>
+                </header>
+
+                <div class="device-bind-card__mapping">
+                  <div>
+                    <span>设备数据点</span>
+                    <strong>{{ getDevicePropLabel(bindItem) }}</strong>
+                  </div>
+                  <span class="device-bind-card__mapping-arrow">→</span>
+                  <div>
+                    <span>组件属性</span>
+                    <strong>{{ getNodePropLabel(bindItem) }}</strong>
+                  </div>
+                </div>
+              </template>
+            </article>
+          </el-form>
+        </template>
+      </div>
+
+      <template #footer>
+        <div class="device-bind-drawer__footer">
+          <span>保存单条绑定后立即生效</span>
+          <el-button type="primary" @click="drawerVisible = false">完成</el-button>
+        </div>
       </template>
       </template>
     </el-drawer>
     </el-drawer>
   </div>
   </div>
@@ -94,11 +249,22 @@
 <script setup lang="ts">
 <script setup lang="ts">
 import type { WebtopoProjectLinkedDeviceVO } from '@/api/pms/maotu'
 import type { WebtopoProjectLinkedDeviceVO } from '@/api/pms/maotu'
 import type { IDoneJson, IDoneJsonDeviceBind } from '@/components/mt-edit/store/types'
 import type { IDoneJson, IDoneJsonDeviceBind } from '@/components/mt-edit/store/types'
+import { flattenDoneJson } from '@/components/mt-edit/utils'
+import { Check, Connection, Delete, EditPen, Monitor, Plus } from '@element-plus/icons-vue'
 import { ElMessage, type FormInstance, type FormItemRule } from 'element-plus'
 import { ElMessage, type FormInstance, type FormItemRule } from 'element-plus'
-import { computed, reactive, ref } from 'vue'
+import { computed, nextTick, reactive, ref } from 'vue'
 
 
 type DeviceBindRow = IDoneJsonDeviceBind & {
 type DeviceBindRow = IDoneJsonDeviceBind & {
+  targetNodeId?: string
   editing?: boolean
   editing?: boolean
+  isNew?: boolean
+}
+
+type NodePropOption = {
+  label: string
+  value: string
+  nodeId: string
+  nodeProp: string
 }
 }
 
 
 type Props = {
 type Props = {
@@ -109,12 +275,16 @@ type Props = {
 const props = withDefaults(defineProps<Props>(), {
 const props = withDefaults(defineProps<Props>(), {
   devices: () => []
   devices: () => []
 })
 })
+const emits = defineEmits<{
+  historyCommit: []
+}>()
 
 
 const drawerVisible = ref(false)
 const drawerVisible = ref(false)
 const formRef = ref<FormInstance>()
 const formRef = ref<FormInstance>()
 const formModel = reactive({
 const formModel = reactive({
   deviceBinds: [] as DeviceBindRow[]
   deviceBinds: [] as DeviceBindRow[]
 })
 })
+const editBackup = ref<DeviceBindRow>()
 
 
 const rules: Record<string, FormItemRule | FormItemRule[]> = {
 const rules: Record<string, FormItemRule | FormItemRule[]> = {
   deviceId: [{ required: true, message: '请选择设备', trigger: 'change' }],
   deviceId: [{ required: true, message: '请选择设备', trigger: 'change' }],
@@ -122,14 +292,43 @@ const rules: Record<string, FormItemRule | FormItemRule[]> = {
   nodeProp: [{ required: true, message: '请选择图形属性', trigger: 'change' }]
   nodeProp: [{ required: true, message: '请选择图形属性', trigger: 'change' }]
 }
 }
 
 
-const nodePropOptions = computed(() => {
-  return Object.entries(props.item.props || {})
-    .filter(([, prop]) => !prop.disabled)
-    .map(([key, prop]) => ({
-      label: prop.title,
-      value: `props.${key}.val`
-    }))
-})
+function collectNodePropOptions(
+  item: IDoneJson,
+  path: string[] = [],
+  options: NodePropOption[] = []
+) {
+  Object.entries(item.props || {})
+    .filter(
+      ([key, prop]) =>
+        !prop.disabled && prop.bindable !== false && (item.tag !== 'text-vue' || key === 'text')
+    )
+    .forEach(([key, prop]) => {
+      const nodeProp = `props.${key}.val`
+      options.push({
+        label: path.length ? `${path.join(' / ')} · ${prop.title}` : prop.title,
+        value: `${item.id}::${nodeProp}`,
+        nodeId: item.id,
+        nodeProp
+      })
+    })
+
+  item.children?.forEach((child, index) => {
+    collectNodePropOptions(
+      child,
+      [...path, `${index + 1}. ${child.title || '未命名组件'}`],
+      options
+    )
+  })
+  return options
+}
+
+const allNodes = computed(() => flattenDoneJson([props.item]))
+const nodePropOptions = computed(() => collectNodePropOptions(props.item))
+const bindCount = computed(() =>
+  allNodes.value.reduce((count, item) => count + (item.deviceBinds?.length || 0), 0)
+)
+const savedBindCount = computed(() => bindCount.value)
+const hasEditingBind = computed(() => formModel.deviceBinds.some((item) => item.editing))
 
 
 function randomId() {
 function randomId() {
   return `device-bind-${Date.now()}-${Math.random().toString(36).slice(2)}`
   return `device-bind-${Date.now()}-${Math.random().toString(36).slice(2)}`
@@ -143,22 +342,69 @@ function formatDeviceLabel(device: WebtopoProjectLinkedDeviceVO) {
   return [device.deviceCode, getChineseName(device.deviceName)].filter(Boolean).join(' - ')
   return [device.deviceCode, getChineseName(device.deviceName)].filter(Boolean).join(' - ')
 }
 }
 
 
-function updateItemDeviceBinds(item: IDoneJson, deviceBinds: IDoneJsonDeviceBind[]) {
-  item.deviceBinds = deviceBinds
+function getDevice(deviceId?: number) {
+  return props.devices.find((item) => item.id === deviceId)
 }
 }
 
 
-function syncDeviceBinds() {
-  updateItemDeviceBinds(
-    props.item,
-    formModel.deviceBinds.map(({ editing, ...item }) => item)
+function getDeviceLabel(deviceId?: number) {
+  const device = getDevice(deviceId)
+  return getChineseName(device?.deviceName) || device?.deviceCode || '未知设备'
+}
+
+function getDeviceCode(deviceId?: number) {
+  return getDevice(deviceId)?.deviceCode || '暂无设备编码'
+}
+
+function getDevicePropLabel(bindItem: DeviceBindRow) {
+  return (
+    getDeviceProps(bindItem.deviceId).find((item) => item.value === bindItem.deviceProp)?.label ||
+    bindItem.deviceProp ||
+    '未知数据点'
   )
   )
 }
 }
 
 
+function getBindTargetValue(bindItem: DeviceBindRow) {
+  return (
+    nodePropOptions.value.find(
+      (item) => item.nodeId === bindItem.targetNodeId && item.nodeProp === bindItem.nodeProp
+    )?.value || ''
+  )
+}
+
+function onNodePropChange(bindItem: DeviceBindRow, value?: string) {
+  const option = nodePropOptions.value.find((item) => item.value === value)
+  bindItem.targetNodeId = option?.nodeId
+  bindItem.nodeProp = option?.nodeProp || ''
+}
+
+function getNodePropLabel(bindItem: DeviceBindRow) {
+  return (
+    nodePropOptions.value.find(
+      (item) => item.nodeId === bindItem.targetNodeId && item.nodeProp === bindItem.nodeProp
+    )?.label ||
+    bindItem.nodeProp ||
+    '未知属性'
+  )
+}
+
+function syncDeviceBinds() {
+  allNodes.value.forEach((node) => {
+    node.deviceBinds = formModel.deviceBinds
+      .filter((item) => item.targetNodeId === node.id)
+      .map(({ targetNodeId, editing, isNew, ...item }) => item)
+  })
+}
+
 async function openDrawer() {
 async function openDrawer() {
-  formModel.deviceBinds = (props.item.deviceBinds || []).map((item) => ({
-    ...item,
-    editing: false
-  }))
+  formModel.deviceBinds = allNodes.value.flatMap((node) =>
+    (node.deviceBinds || []).map((item) => ({
+      ...item,
+      targetNodeId: node.id,
+      editing: false,
+      isNew: false
+    }))
+  )
+  editBackup.value = undefined
   drawerVisible.value = true
   drawerVisible.value = true
 }
 }
 
 
@@ -173,8 +419,11 @@ function addBind() {
     deviceId: undefined,
     deviceId: undefined,
     deviceProp: '',
     deviceProp: '',
     nodeProp: '',
     nodeProp: '',
-    editing: true
+    targetNodeId: undefined,
+    editing: true,
+    isNew: true
   })
   })
+  editBackup.value = undefined
 }
 }
 
 
 function onDeviceChange(bindItem: DeviceBindRow) {
 function onDeviceChange(bindItem: DeviceBindRow) {
@@ -182,15 +431,39 @@ function onDeviceChange(bindItem: DeviceBindRow) {
 }
 }
 
 
 function getDeviceProps(deviceId?: number) {
 function getDeviceProps(deviceId?: number) {
-  const device = props.devices.find((item) => item.id === deviceId)
+  const device = getDevice(deviceId)
   return (device?.pointParams || [])
   return (device?.pointParams || [])
     .filter((item) => item.paramCode)
     .filter((item) => item.paramCode)
     .map((item) => ({
     .map((item) => ({
-      label: item.paramName || item.paramCode!,
+      label: `${item.paramName || item.paramCode!}${item.paramUnit ? ` (${item.paramUnit})` : ''}`,
       value: item.paramCode!
       value: item.paramCode!
     }))
     }))
 }
 }
 
 
+function editBind(index: number) {
+  if (hasEditingBind.value) {
+    ElMessage.warning('请先完成当前绑定')
+    return
+  }
+  editBackup.value = { ...formModel.deviceBinds[index] }
+  formModel.deviceBinds[index].editing = true
+}
+
+function cancelEdit(index: number) {
+  const currentItem = formModel.deviceBinds[index]
+  if (currentItem.isNew) {
+    formModel.deviceBinds.splice(index, 1)
+  } else if (editBackup.value?.id === currentItem.id) {
+    formModel.deviceBinds[index] = {
+      ...editBackup.value,
+      editing: false,
+      isNew: false
+    }
+  }
+  editBackup.value = undefined
+  nextTick(() => formRef.value?.clearValidate())
+}
+
 async function saveBind(index: number) {
 async function saveBind(index: number) {
   await formRef.value?.validateField([
   await formRef.value?.validateField([
     `deviceBinds.${index}.deviceId`,
     `deviceBinds.${index}.deviceId`,
@@ -198,14 +471,462 @@ async function saveBind(index: number) {
     `deviceBinds.${index}.nodeProp`
     `deviceBinds.${index}.nodeProp`
   ])
   ])
 
 
+  const currentItem = formModel.deviceBinds[index]
+  const duplicateNodeProp = formModel.deviceBinds.some(
+    (item, itemIndex) =>
+      itemIndex !== index &&
+      item.targetNodeId === currentItem.targetNodeId &&
+      item.nodeProp === currentItem.nodeProp
+  )
+  if (duplicateNodeProp) {
+    ElMessage.warning('该组件属性已经存在设备绑定,请选择其他属性')
+    return
+  }
+
   formModel.deviceBinds[index].editing = false
   formModel.deviceBinds[index].editing = false
+  formModel.deviceBinds[index].isNew = false
+  editBackup.value = undefined
   syncDeviceBinds()
   syncDeviceBinds()
+  emits('historyCommit')
   ElMessage.success('绑定成功')
   ElMessage.success('绑定成功')
 }
 }
 
 
 function removeBind(index: number) {
 function removeBind(index: number) {
   formModel.deviceBinds.splice(index, 1)
   formModel.deviceBinds.splice(index, 1)
   syncDeviceBinds()
   syncDeviceBinds()
+  emits('historyCommit')
   ElMessage.success('解绑成功')
   ElMessage.success('解绑成功')
 }
 }
 </script>
 </script>
+
+<style scoped>
+.device-bind-panel {
+  min-width: 0;
+}
+
+.device-bind-overview {
+  display: flex;
+  min-width: 0;
+  padding: 10px 12px;
+  background: linear-gradient(145deg, #f7fbff 0%, #fff 75%);
+  border: 1px solid var(--el-color-primary-light-7);
+  border-radius: 10px;
+  align-items: center;
+  gap: 10px;
+}
+
+.device-bind-overview.is-empty {
+  background: #f8fafc;
+  border-color: #e0e6ee;
+}
+
+.device-bind-overview__icon {
+  display: inline-flex;
+  width: 32px;
+  height: 32px;
+  font-size: 17px;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-radius: 8px;
+  align-items: center;
+  justify-content: center;
+  flex: none;
+}
+
+.device-bind-overview__content {
+  display: flex;
+  min-width: 0;
+  margin-right: auto;
+  flex-direction: column;
+  gap: 1px;
+}
+
+.device-bind-overview__content strong {
+  overflow: hidden;
+  font-size: 13px;
+  font-weight: 600;
+  line-height: 18px;
+  color: #25324a;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.device-bind-overview__status {
+  position: relative;
+  padding-left: 10px;
+  font-size: 10px;
+  line-height: 16px;
+  color: #8a95a6;
+}
+
+.device-bind-overview__status::before {
+  position: absolute;
+  top: 50%;
+  left: 0;
+  width: 5px;
+  height: 5px;
+  background: #c3cad5;
+  border-radius: 50%;
+  content: '';
+  transform: translateY(-50%);
+}
+
+.device-bind-overview__status.is-active {
+  color: var(--el-color-primary);
+}
+
+.device-bind-overview__status.is-active::before {
+  background: var(--el-color-primary);
+}
+
+.device-bind-overview > :deep(.el-button) {
+  min-width: 50px;
+  padding: 5px 10px;
+  margin: 0;
+  border-radius: 7px;
+  flex: none;
+}
+
+.device-bind-drawer__header {
+  display: flex;
+  min-width: 0;
+  align-items: center;
+  gap: 11px;
+}
+
+.device-bind-drawer__header-icon {
+  display: inline-flex;
+  width: 38px;
+  height: 38px;
+  font-size: 20px;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-radius: 10px;
+  align-items: center;
+  justify-content: center;
+  flex: none;
+}
+
+.device-bind-drawer__header h2 {
+  margin: 0;
+  font-size: 16px;
+  font-weight: 700;
+  line-height: 22px;
+  color: #172033;
+}
+
+.device-bind-drawer__header p {
+  max-width: 430px;
+  margin: 3px 0 0;
+  overflow: hidden;
+  font-size: 12px;
+  line-height: 18px;
+  color: #7a8699;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.device-bind-workspace {
+  min-height: 100%;
+}
+
+.device-bind-toolbar {
+  display: flex;
+  min-height: 42px;
+  padding-bottom: 12px;
+  border-bottom: 1px solid #e5eaf1;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.device-bind-toolbar > div {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.device-bind-toolbar strong {
+  font-size: 14px;
+  line-height: 20px;
+  color: #344054;
+}
+
+.device-bind-toolbar span {
+  font-size: 11px;
+  line-height: 16px;
+  color: #8a95a6;
+}
+
+.device-bind-toolbar :deep(.el-button > span),
+.device-bind-card__editor-footer :deep(.el-button > span) {
+  gap: 5px;
+}
+
+.device-bind-empty {
+  padding: 70px 0 40px;
+}
+
+.device-bind-empty p {
+  margin: -5px 0 12px;
+  font-size: 12px;
+  color: #98a2b3;
+}
+
+.device-bind-list {
+  display: flex;
+  padding-top: 12px;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.device-bind-card {
+  overflow: hidden;
+  background: #fff;
+  border: 1px solid #e0e6ee;
+  border-radius: 11px;
+  transition:
+    border-color 0.18s ease,
+    box-shadow 0.18s ease;
+}
+
+.device-bind-card:hover {
+  border-color: var(--el-color-primary-light-6);
+  box-shadow: 0 5px 16px rgb(31 45 61 / 6%);
+}
+
+.device-bind-card.is-editing {
+  border-color: var(--el-color-primary-light-5);
+  box-shadow: 0 6px 18px rgb(64 158 255 / 9%);
+}
+
+.device-bind-card__summary-header,
+.device-bind-card__editor-header {
+  display: flex;
+  min-width: 0;
+  padding: 11px 13px;
+  border-bottom: 1px solid #edf0f4;
+  align-items: center;
+  gap: 9px;
+}
+
+.device-bind-card__device-icon {
+  display: inline-flex;
+  width: 32px;
+  height: 32px;
+  font-size: 17px;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-radius: 8px;
+  align-items: center;
+  justify-content: center;
+  flex: none;
+}
+
+.device-bind-card__summary-header > div,
+.device-bind-card__editor-header > div {
+  display: flex;
+  min-width: 0;
+  margin-right: auto;
+  flex-direction: column;
+  gap: 1px;
+}
+
+.device-bind-card__summary-header strong,
+.device-bind-card__editor-header strong {
+  overflow: hidden;
+  font-size: 13px;
+  line-height: 19px;
+  color: #344054;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.device-bind-card__summary-header span,
+.device-bind-card__editor-header span {
+  overflow: hidden;
+  font-size: 10px;
+  line-height: 15px;
+  color: #8b96a7;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.device-bind-card__summary-header > :deep(.el-button) {
+  width: 28px;
+  height: 28px;
+  margin: 0;
+  flex: none;
+}
+
+.device-bind-card__mapping {
+  display: grid;
+  padding: 13px;
+  background: #fafbfd;
+  grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr);
+  align-items: center;
+  gap: 8px;
+}
+
+.device-bind-card__mapping > div {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  gap: 3px;
+}
+
+.device-bind-card__mapping span {
+  font-size: 10px;
+  line-height: 15px;
+  color: #98a2b3;
+}
+
+.device-bind-card__mapping strong {
+  overflow: hidden;
+  font-size: 12px;
+  font-weight: 600;
+  line-height: 18px;
+  color: #475467;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.device-bind-card__mapping-arrow {
+  display: inline-flex;
+  width: 28px;
+  height: 28px;
+  font-size: 15px !important;
+  color: var(--el-color-primary) !important;
+  background: var(--el-color-primary-light-9);
+  border-radius: 50%;
+  align-items: center;
+  justify-content: center;
+}
+
+.device-bind-card__editor-header {
+  background: #fbfcfe;
+}
+
+.device-bind-card__editor-body {
+  display: grid;
+  padding: 13px;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 14px;
+}
+
+.bind-editor-step {
+  min-width: 0;
+  padding: 11px;
+  background: #f7f9fc;
+  border: 1px solid #e6ebf2;
+  border-radius: 9px;
+}
+
+.bind-editor-step__heading {
+  display: flex;
+  margin-bottom: 10px;
+  align-items: center;
+  gap: 8px;
+}
+
+.bind-editor-step__heading > span {
+  display: inline-flex;
+  width: 22px;
+  height: 22px;
+  font-size: 10px;
+  font-weight: 700;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-radius: 50%;
+  align-items: center;
+  justify-content: center;
+  flex: none;
+}
+
+.bind-editor-step__heading > div {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+}
+
+.bind-editor-step__heading strong {
+  font-size: 12px;
+  line-height: 18px;
+  color: #475467;
+}
+
+.bind-editor-step__heading p {
+  margin: 0;
+  font-size: 10px;
+  line-height: 15px;
+  color: #98a2b3;
+}
+
+.bind-editor-step :deep(.el-form-item) {
+  margin-bottom: 10px;
+}
+
+.bind-editor-step :deep(.el-form-item:last-child) {
+  margin-bottom: 0;
+}
+
+.bind-editor-step :deep(.el-form-item__label) {
+  height: auto;
+  padding: 0 0 4px;
+  font-size: 11px;
+  line-height: 16px;
+  color: #667085;
+}
+
+.bind-editor-step :deep(.el-select) {
+  width: 100%;
+}
+
+.device-bind-card__editor-footer {
+  display: flex;
+  padding: 10px 13px;
+  background: #fbfcfe;
+  border-top: 1px solid #edf0f4;
+  justify-content: flex-end;
+}
+
+.device-bind-drawer__footer {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.device-bind-drawer__footer > span {
+  font-size: 12px;
+  color: #8a95a6;
+}
+
+@media (width <= 700px) {
+  .device-bind-card__editor-body {
+    grid-template-columns: 1fr;
+  }
+}
+</style>
+
+<style>
+.device-bind-drawer {
+  max-width: 100vw;
+}
+
+.device-bind-drawer .el-drawer__header {
+  padding: 17px 20px;
+  margin: 0;
+  border-bottom: 1px solid #e4e9f0;
+}
+
+.device-bind-drawer .el-drawer__body {
+  padding: 14px 18px;
+  background: #f6f8fb;
+}
+
+.device-bind-drawer .el-drawer__footer {
+  padding: 12px 18px;
+  border-top: 1px solid #e4e9f0;
+}
+</style>

+ 151 - 6
src/views/maotu/edit.vue

@@ -8,10 +8,10 @@ import type { IExportJson } from '@/components/mt-edit/components/types'
 import { useGenThumbnail } from '@/components/mt-edit/composables/thumbnail'
 import { useGenThumbnail } from '@/components/mt-edit/composables/thumbnail'
 import { MtEdit } from '@/export'
 import { MtEdit } from '@/export'
 import { Canvg } from 'canvg'
 import { Canvg } from 'canvg'
+import { ElMessage, ElMessageBox } from 'element-plus'
 import html2canvas from 'html2canvas'
 import html2canvas from 'html2canvas'
-import { ElMessage } from 'element-plus'
-import { nextTick, onMounted, ref, watch } from 'vue'
-import { useRoute, useRouter } from 'vue-router'
+import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
 import DeviceBindPanel from './components/DeviceBindPanel.vue'
 import DeviceBindPanel from './components/DeviceBindPanel.vue'
 const route = useRoute()
 const route = useRoute()
 const router = useRouter()
 const router = useRouter()
@@ -48,6 +48,8 @@ const loadProject = async () => {
     if (data?.dataModel) {
     if (data?.dataModel) {
       await nextTick()
       await nextTick()
       MtEditRef.value?.setImportJson(data.dataModel as IExportJson)
       MtEditRef.value?.setImportJson(data.dataModel as IExportJson)
+    } else {
+      MtEditRef.value?.markSaved()
     }
     }
   } catch {
   } catch {
     ElMessage.error('拓扑数据加载失败')
     ElMessage.error('拓扑数据加载失败')
@@ -119,6 +121,9 @@ const onPreviewClick = (exportJson: IExportJson) => {
   window.open(routeUrl.href, '_blank')
   window.open(routeUrl.href, '_blank')
 }
 }
 const onSaveClick = async (dataModel: IExportJson) => {
 const onSaveClick = async (dataModel: IExportJson) => {
+  if (saveLoading.value) {
+    return
+  }
   const detail = projectDetail.value
   const detail = projectDetail.value
   if (!detail?.id) {
   if (!detail?.id) {
     ElMessage.error('项目信息不存在,无法保存')
     ElMessage.error('项目信息不存在,无法保存')
@@ -147,20 +152,68 @@ const onSaveClick = async (dataModel: IExportJson) => {
       thumbnail,
       thumbnail,
       dataModel: JSON.stringify(dataModel)
       dataModel: JSON.stringify(dataModel)
     }
     }
+    MtEditRef.value?.markSaved(dataModel)
     ElMessage.success('更新成功')
     ElMessage.success('更新成功')
   } finally {
   } finally {
     saveLoading.value = false
     saveLoading.value = false
   }
   }
 }
 }
 const onReturnClick = () => {
 const onReturnClick = () => {
-  router.go(-1)
+  router.push('/maotu/list')
 }
 }
 const onThumbnailClick = () => {
 const onThumbnailClick = () => {
   useGenThumbnail()
   useGenThumbnail()
 }
 }
 
 
+const hasUnsavedChanges = () => MtEditRef.value?.hasUnsavedChanges() ?? false
+const confirmLeave = async () => {
+  if (!hasUnsavedChanges()) {
+    return true
+  }
+
+  try {
+    await ElMessageBox.confirm(
+      '当前项目还有未保存的内容。离开后,这些修改将无法恢复。',
+      '未保存的修改',
+      {
+        confirmButtonText: '确定离开',
+        cancelButtonText: '继续编辑',
+        type: 'warning',
+        distinguishCancelAndClose: true,
+        customClass: 'maotu-unsaved-message-box',
+        modalClass: 'maotu-unsaved-overlay',
+        confirmButtonClass: 'maotu-unsaved-message-box__confirm',
+        cancelButtonClass: 'maotu-unsaved-message-box__cancel',
+        showClose: false,
+        closeOnClickModal: false,
+        roundButton: true,
+        autofocus: false
+      }
+    )
+    return true
+  } catch {
+    return false
+  }
+}
+const onBeforeUnload = (event: BeforeUnloadEvent) => {
+  if (!hasUnsavedChanges()) {
+    return
+  }
+  event.preventDefault()
+  event.returnValue = ''
+}
+
 onMounted(() => {
 onMounted(() => {
   resetAndLoadProject()
   resetAndLoadProject()
+  window.addEventListener('beforeunload', onBeforeUnload)
+})
+onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload))
+onBeforeRouteLeave(confirmLeave)
+onBeforeRouteUpdate((to, from) => {
+  if (to.params.id !== from.params.id) {
+    return confirmLeave()
+  }
+  return true
 })
 })
 
 
 watch(() => route.params.id, resetAndLoadProject)
 watch(() => route.params.id, resetAndLoadProject)
@@ -173,6 +226,7 @@ watch(() => route.params.id, resetAndLoadProject)
     class="w-1/1 h-100vh">
     class="w-1/1 h-100vh">
     <mt-edit
     <mt-edit
       ref="MtEditRef"
       ref="MtEditRef"
+      :title="projectDetail?.projectName || '拓扑项目'"
       :use-thumbnail="true"
       :use-thumbnail="true"
       :save-loading="saveLoading"
       :save-loading="saveLoading"
       @on-preview-click="onPreviewClick"
       @on-preview-click="onPreviewClick"
@@ -180,10 +234,101 @@ watch(() => route.params.id, resetAndLoadProject)
       @on-save-click="onSaveClick"
       @on-save-click="onSaveClick"
       @on-thumbnail-click="onThumbnailClick">
       @on-thumbnail-click="onThumbnailClick">
       <template #deviceBind="{ item }">
       <template #deviceBind="{ item }">
-        <DeviceBindPanel :item="item" :devices="projectDetail?.linkedDevices || []" />
+        <DeviceBindPanel
+          :item="item"
+          :devices="projectDetail?.linkedDevices || []"
+          @history-commit="MtEditRef?.commitHistory()" />
       </template>
       </template>
     </mt-edit>
     </mt-edit>
   </div>
   </div>
 </template>
 </template>
 
 
-<style scoped></style>
+<style>
+.maotu-unsaved-overlay {
+  background: rgb(15 23 42 / 46%);
+  backdrop-filter: blur(2px);
+}
+
+.maotu-unsaved-message-box.el-message-box {
+  --el-messagebox-width: 440px;
+  --el-messagebox-border-radius: 16px;
+
+  padding: 0;
+  overflow: hidden;
+  background: #fff;
+  border: 1px solid rgb(226 232 240 / 92%);
+  box-shadow: 0 24px 64px rgb(15 23 42 / 22%);
+}
+
+.maotu-unsaved-message-box .el-message-box__header {
+  padding: 22px 24px 10px;
+}
+
+.maotu-unsaved-message-box .el-message-box__title {
+  font-size: 18px;
+  font-weight: 700;
+  line-height: 26px;
+  color: #172033;
+}
+
+.maotu-unsaved-message-box .el-message-box__content {
+  padding: 4px 24px 22px;
+}
+
+.maotu-unsaved-message-box .el-message-box__container {
+  align-items: flex-start;
+  gap: 14px;
+}
+
+.maotu-unsaved-message-box .el-message-box__status {
+  display: inline-flex;
+  width: 40px;
+  height: 40px;
+  font-size: 21px;
+  color: #f59e0b;
+  background: #fff7e6;
+  border-radius: 12px;
+  flex: 0 0 40px;
+  align-items: center;
+  justify-content: center;
+}
+
+.maotu-unsaved-message-box .el-message-box__message {
+  padding-top: 1px;
+  font-size: 14px;
+  line-height: 22px;
+  color: #667085;
+}
+
+.maotu-unsaved-message-box .el-message-box__btns {
+  padding: 11px 20px 13px;
+  background: #f8fafc;
+  border-top: 1px solid #edf1f5;
+  gap: 8px;
+}
+
+.maotu-unsaved-message-box .el-message-box__btns .el-button {
+  height: 34px;
+  padding: 0 16px;
+  margin: 0;
+  font-weight: 600;
+  border-radius: 9px;
+}
+
+.maotu-unsaved-message-box .maotu-unsaved-message-box__cancel {
+  color: #475467;
+  background: #fff;
+  border-color: #d8dee8;
+}
+
+.maotu-unsaved-message-box .maotu-unsaved-message-box__cancel:hover {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border-color: var(--el-color-primary-light-7);
+}
+
+.maotu-unsaved-message-box .maotu-unsaved-message-box__confirm {
+  min-width: 96px;
+  box-shadow: 0 6px 14px rgb(64 158 255 / 24%);
+}
+</style>

+ 29 - 10
src/views/maotu/preview.vue

@@ -22,6 +22,8 @@
 import { WebtopoProjectApi, parseWebtopoDataModel } from '@/api/pms/maotu'
 import { WebtopoProjectApi, parseWebtopoDataModel } from '@/api/pms/maotu'
 import { IotDeviceApi } from '@/api/pms/device'
 import { IotDeviceApi } from '@/api/pms/device'
 import type { IExportJson } from '@/components/mt-edit/components/types'
 import type { IExportJson } from '@/components/mt-edit/components/types'
+import { flattenDoneJson } from '@/components/mt-edit/utils'
+import { formatTextDeviceValue } from '@/components/mt-edit/utils/text-format'
 import { MtPreview } from '@/export'
 import { MtPreview } from '@/export'
 import { isAppWebtopoPreview } from '@/utils/auth'
 import { isAppWebtopoPreview } from '@/utils/auth'
 import { ElMessage } from 'element-plus'
 import { ElMessage } from 'element-plus'
@@ -62,14 +64,22 @@ const getDeviceBindItems = () => {
     ? previewData.value.pages.flatMap((page) => page.json)
     ? previewData.value.pages.flatMap((page) => page.json)
     : previewData.value?.json || []
     : previewData.value?.json || []
 
 
-  return previewItems.flatMap((item) =>
+  return flattenDoneJson(previewItems).flatMap((item) =>
     (item.deviceBinds || [])
     (item.deviceBinds || [])
       .filter((bind) => bind.deviceId && bind.deviceProp && bind.nodeProp)
       .filter((bind) => bind.deviceId && bind.deviceProp && bind.nodeProp)
       .map((bind) => ({
       .map((bind) => ({
         itemId: item.id,
         itemId: item.id,
+        itemTag: item.tag,
         deviceId: bind.deviceId!,
         deviceId: bind.deviceId!,
         deviceProp: bind.deviceProp!,
         deviceProp: bind.deviceProp!,
-        nodeProp: bind.nodeProp!
+        nodeProp: bind.nodeProp!,
+        textFormat:
+          item.tag === 'text-vue'
+            ? {
+                decimalPlaces: Number(item.props.decimalPlaces?.val ?? 2),
+                showUnit: item.props.showUnit?.val !== false
+              }
+            : undefined
       }))
       }))
   )
   )
 }
 }
@@ -100,23 +110,32 @@ const refreshDeviceBindValues = async () => {
         return { deviceId, data }
         return { deviceId, data }
       })
       })
     )
     )
-    const valueMap = new Map<string, unknown>()
+    const pointMap = new Map<string, DevicePointData>()
     deviceDataList.forEach(({ deviceId, data }) => {
     deviceDataList.forEach(({ deviceId, data }) => {
       data.forEach((item) => {
       data.forEach((item) => {
         if (item.identifier) {
         if (item.identifier) {
-          valueMap.set(`${deviceId}:${item.identifier}`, formatDevicePointValue(item))
+          const key = `${deviceId}:${item.identifier}`
+          pointMap.set(key, item)
         }
         }
       })
       })
     })
     })
 
 
     MtPreviewRef.value?.setItemAttrs(
     MtPreviewRef.value?.setItemAttrs(
       bindItems
       bindItems
-        .filter((item) => valueMap.has(`${item.deviceId}:${item.deviceProp}`))
-        .map((item) => ({
-          id: item.itemId,
-          key: item.nodeProp,
-          val: valueMap.get(`${item.deviceId}:${item.deviceProp}`)
-        }))
+        .filter((item) => pointMap.has(`${item.deviceId}:${item.deviceProp}`))
+        .map((item) => {
+          const point = pointMap.get(`${item.deviceId}:${item.deviceProp}`)!
+          return {
+            id: item.itemId,
+            key: item.nodeProp,
+            val:
+              item.itemTag === 'status-vue'
+                ? point.value
+                : item.itemTag === 'text-vue'
+                  ? formatTextDeviceValue(point.value, point.unit, item.textFormat)
+                  : formatDevicePointValue(point)
+          }
+        })
     )
     )
   } finally {
   } finally {
     polling.value = false
     polling.value = false

+ 418 - 88
src/views/oli-connection/monitoring-board/chart.vue

@@ -4,6 +4,8 @@ import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
 import { useTableComponents } from '@/components/ZmTable/useTableComponents'
 import { useTableComponents } from '@/components/ZmTable/useTableComponents'
 import { useAlarmSound } from '@/utils/useAlarmSound'
 import { useAlarmSound } from '@/utils/useAlarmSound'
 import { useMqtt } from '@/utils/useMqtt'
 import { useMqtt } from '@/utils/useMqtt'
+import { getWindowChangeAlarm } from '@/utils/thresholdAlarmWindow'
+import { createThresholdAlarmNoticeTracker } from '@/utils/thresholdAlarmNoticeTracker'
 import {
 import {
   Dimensions,
   Dimensions,
   formatChartValue,
   formatChartValue,
@@ -87,29 +89,56 @@ const message = useMessage()
 const { setAlarmActive } = useAlarmSound()
 const { setAlarmActive } = useAlarmSound()
 
 
 interface ThresholdAlarm {
 interface ThresholdAlarm {
+  alarmAt: number
+  alarmKey: string
+  fingerprint: string
   key: string
   key: string
   name: string
   name: string
-  value: number
-  threshold: number
-  direction: 'high' | 'low'
+  change: number
+  direction: 'increase' | 'decrease'
+  fromValue: number
+  minValue: number
+  maxValue: number
+  toValue: number
+  duration: number
+  range: number
   suffix?: string
   suffix?: string
 }
 }
 
 
 const thresholdAlarms = shallowRef<Record<string, ThresholdAlarm>>({})
 const thresholdAlarms = shallowRef<Record<string, ThresholdAlarm>>({})
 const activeThresholdAlarms = computed(() => Object.values(thresholdAlarms.value))
 const activeThresholdAlarms = computed(() => Object.values(thresholdAlarms.value))
+const thresholdAlarmNotices = shallowRef<Record<string, ThresholdAlarm>>({})
+const activeThresholdAlarmNotices = computed(() => Object.values(thresholdAlarmNotices.value))
+const thresholdAlarmNoticeTracker = createThresholdAlarmNoticeTracker()
+let thresholdAlarmNoticeSequence = 0
 
 
 function getThresholdAlarmText(alarm: ThresholdAlarm) {
 function getThresholdAlarmText(alarm: ThresholdAlarm) {
-  const directionText = alarm.direction === 'high' ? '高于上限' : '低于下限'
   const suffix = alarm.suffix || ''
   const suffix = alarm.suffix || ''
+  const directionText = alarm.direction === 'increase' ? '增加' : '减少'
 
 
-  return `${alarm.name}:当前 ${formatChartValue(alarm.value)}${suffix},${directionText} ${formatChartValue(alarm.threshold)}${suffix}`
+  return `${dayjs(alarm.alarmAt).format('YYYY-MM-DD HH:mm:ss')} 告警,${alarm.name}:最近 ${formatChartValue(alarm.duration)} 秒内从 ${formatChartValue(alarm.fromValue)}${suffix}${directionText}到 ${formatChartValue(alarm.toValue)}${suffix},${directionText}了 ${formatChartValue(alarm.change)}${suffix},超过超限范围 ${formatChartValue(alarm.range)}${suffix}`
 }
 }
 
 
-function clearThresholdAlarms() {
+function clearActiveThresholdAlarms() {
   thresholdAlarms.value = {}
   thresholdAlarms.value = {}
+  thresholdAlarmNoticeTracker.clear()
   setAlarmActive(false)
   setAlarmActive(false)
 }
 }
 
 
+function closeThresholdAlarmNotice(key: string) {
+  const alarm = thresholdAlarmNotices.value[key]
+  const nextNotices = { ...thresholdAlarmNotices.value }
+  delete nextNotices[key]
+  thresholdAlarmNotices.value = nextNotices
+
+  if (!alarm || !thresholdAlarmNoticeTracker.close(alarm.alarmKey, key)) return
+
+  const nextAlarms = { ...thresholdAlarms.value }
+  delete nextAlarms[alarm.alarmKey]
+  thresholdAlarms.value = nextAlarms
+  setAlarmActive(activeThresholdAlarms.value.length > 0)
+}
+
 const currentOtherName = ref(props.otherName)
 const currentOtherName = ref(props.otherName)
 
 
 const displayDeviceName = computed(() => currentOtherName.value || props.deviceName)
 const displayDeviceName = computed(() => currentOtherName.value || props.deviceName)
@@ -125,6 +154,10 @@ const { client, connect, destroy, isConnected, subscribe } = useMqtt()
 
 
 const REALTIME_WINDOW_MS = 10 * 60 * 1000
 const REALTIME_WINDOW_MS = 10 * 60 * 1000
 const REALTIME_RIGHT_BLANK_MS = 60 * 1000
 const REALTIME_RIGHT_BLANK_MS = 60 * 1000
+const SUSPENSION_WEIGHT_IDENTIFIER = '1001'
+const SUSPENSION_LIGHT_IDENTIFIER = '1008'
+const SUSPENSION_COMBINED_NAME = '悬重/悬轻'
+const suspensionWeightAndLightEnabled = ref(false)
 
 
 function getPointTimestamp(value: unknown) {
 function getPointTimestamp(value: unknown) {
   if (value === null || value === undefined || value === '') {
   if (value === null || value === undefined || value === '') {
@@ -142,35 +175,88 @@ function getPointTimestamp(value: unknown) {
   return Number.isFinite(parsedValue) ? parsedValue : Date.now()
   return Number.isFinite(parsedValue) ? parsedValue : Date.now()
 }
 }
 
 
-const handleMessageUpdate = (_topic: string, data: any) => {
+const handleMessageUpdate = (topic: string, data: any) => {
+  console.log('[监控看板 MQTT 返回数据]', {
+    deviceCode: props.deviceCode,
+    topic,
+    receivedAt: dayjs().format('YYYY-MM-DD HH:mm:ss.SSS'),
+    data
+  })
+
   const list = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : []
   const list = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : []
   const valueMap = new Map<string, number>()
   const valueMap = new Map<string, number>()
+  const pointMap = new Map<string, ChartPoint>()
   const updatedNames = new Set<string>()
   const updatedNames = new Set<string>()
 
 
   for (const item of list) {
   for (const item of list) {
     const identity = String(item.id ?? item.identity ?? '')
     const identity = String(item.id ?? item.identity ?? '')
-    const value = Number(item.value ?? item.logValue ?? 0)
+    // 【修复前】缺少 value/logValue 时会被转换成 0。
+    // 【修复后】缺少数值直接跳过,但真实上报的数值 0 仍然有效。
+    const rawValue = item.value ?? item.logValue
+    const value = Number(rawValue)
 
 
-    if (!identity || !Number.isFinite(value)) continue
+    if (
+      !identity ||
+      rawValue === null ||
+      rawValue === undefined ||
+      rawValue === '' ||
+      !Number.isFinite(value)
+    ) {
+      continue
+    }
 
 
     valueMap.set(identity, value)
     valueMap.set(identity, value)
+    const point = {
+      ts: normalizeChartTimestamp(
+        getPointTimestamp(item.remark || item.readTime || item.ts || item.time)
+      ),
+      value
+    }
+    pointMap.set(identity, point)
+
+    if (
+      suspensionWeightAndLightEnabled.value &&
+      (identity === SUSPENSION_WEIGHT_IDENTIFIER || identity === SUSPENSION_LIGHT_IDENTIFIER)
+    ) {
+      continue
+    }
 
 
     const modelName = dimensions.value.find((item) => item.identifier === identity)?.name
     const modelName = dimensions.value.find((item) => item.identifier === identity)?.name
 
 
     if (modelName && chartData.value[modelName]) {
     if (modelName && chartData.value[modelName]) {
-      const ts = getPointTimestamp(item.remark || item.readTime || item.ts || item.time)
-
-      appendChartPoint(modelName, {
-        ts,
-        value
-      })
+      appendChartPoint(modelName, point)
 
 
       updatedNames.add(modelName)
       updatedNames.add(modelName)
     }
     }
   }
   }
 
 
+  if (suspensionWeightAndLightEnabled.value) {
+    const combinedPoint = getCombinedSuspensionPoint(pointMap)
+
+    if (combinedPoint) {
+      valueMap.set(SUSPENSION_WEIGHT_IDENTIFIER, combinedPoint.value)
+      pointMap.set(SUSPENSION_WEIGHT_IDENTIFIER, combinedPoint)
+      pointMap.delete(SUSPENSION_LIGHT_IDENTIFIER)
+
+      const modelName = dimensions.value.find(
+        (item) => String(item.identifier) === SUSPENSION_WEIGHT_IDENTIFIER
+      )?.name
+
+      if (modelName && chartData.value[modelName]) {
+        appendChartPoint(modelName, combinedPoint)
+        updatedNames.add(modelName)
+      }
+    } else {
+      // 当前消息没有有效的悬重或悬轻数据时,不更新组合值。
+      valueMap.delete(SUSPENSION_WEIGHT_IDENTIFIER)
+      valueMap.delete(SUSPENSION_LIGHT_IDENTIFIER)
+      pointMap.delete(SUSPENSION_WEIGHT_IDENTIFIER)
+      pointMap.delete(SUSPENSION_LIGHT_IDENTIFIER)
+    }
+  }
+
   updateDimensionValues(valueMap)
   updateDimensionValues(valueMap)
-  updateThresholdAlarm(valueMap)
+  updateThresholdAlarm(pointMap)
 
 
   if (!updatedNames.size) return
   if (!updatedNames.size) return
 
 
@@ -203,19 +289,41 @@ watch(isConnected, (newVal) => {
     subscribe(props.mqttUrl)
     subscribe(props.mqttUrl)
     // subscribe('/636/YF649/property/post')
     // subscribe('/636/YF649/property/post')
   } else {
   } else {
-    clearThresholdAlarms()
+    clearActiveThresholdAlarms()
   }
   }
 })
 })
 
 
 async function loadDimensions() {
 async function loadDimensions() {
   if (!props.id) return
   if (!props.id) return
   try {
   try {
-    dimensions.value = (
+    const dimensionList = (
       ((await IotDeviceApi.getIotDeviceTds(Number(props.id))) as IotTdItem[]) ?? []
       ((await IotDeviceApi.getIotDeviceTds(Number(props.id))) as IotTdItem[]) ?? []
+    ).map((item) => ({ ...item, identifier: String(item.identifier).trim() }))
+    const identifiers = new Set(dimensionList.map((item) => item.identifier))
+
+    // 以属性接口的完整列表决定是否合并,不能以某条 MQTT 消息是否包含悬轻来判断。
+    suspensionWeightAndLightEnabled.value =
+      identifiers.has(SUSPENSION_WEIGHT_IDENTIFIER) && identifiers.has(SUSPENSION_LIGHT_IDENTIFIER)
+
+    const weightDimension = dimensionList.find(
+      (item) => item.identifier === SUSPENSION_WEIGHT_IDENTIFIER
     )
     )
+
+    if (weightDimension) {
+      weightDimension.modelName = suspensionWeightAndLightEnabled.value
+        ? SUSPENSION_COMBINED_NAME
+        : '悬重'
+    }
+
+    dimensions.value = dimensionList
       .sort((a, b) => (b.modelOrder ?? 0) - (a.modelOrder ?? 0))
       .sort((a, b) => (b.modelOrder ?? 0) - (a.modelOrder ?? 0))
       .map(mapToDimension)
       .map(mapToDimension)
       .filter((item) => item.isText === false)
       .filter((item) => item.isText === false)
+      .filter(
+        (item) =>
+          !suspensionWeightAndLightEnabled.value ||
+          String(item.identifier) !== SUSPENSION_LIGHT_IDENTIFIER
+      )
       .map(withDisplayStyle)
       .map(withDisplayStyle)
 
 
     selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, true]))
     selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, true]))
@@ -235,6 +343,46 @@ interface ChartData {
 
 
 const chartData = shallowRef<ChartData>({})
 const chartData = shallowRef<ChartData>({})
 
 
+// 【修复前】每条 MQTT 消息只在当前 payload 内查找 1001(悬重)和 1008(悬轻)。
+// 如果设备把两个测点拆成两条消息上报,当前消息缺少的那一侧会被当成 0,
+// 组合曲线就会出现错误的 0 点。
+//
+// 保存两侧最近一次有效点。尚未收到另一侧时展示已收到的单侧数据,
+// 悬重为正向、悬轻为负向;两侧齐全后再计算差值,不丢弃有效的单侧上报。
+const latestSuspensionPoints = new Map<string, ChartPoint>()
+
+function normalizeChartTimestamp(timestamp: number) {
+  // 所有曲线使用相同的秒级坐标,避免组合曲线与其他曲线因毫秒差拆成两个 tooltip。
+  return Math.floor(timestamp / 1000) * 1000
+}
+
+function getCombinedSuspensionPoint(pointMap: Map<string, ChartPoint>) {
+  if (!pointMap.has(SUSPENSION_WEIGHT_IDENTIFIER) && !pointMap.has(SUSPENSION_LIGHT_IDENTIFIER)) {
+    return null
+  }
+
+  // MQTT 可能把悬重和悬轻拆成不同消息,因此先更新最近值缓存,再计算组合值。
+  for (const identifier of [SUSPENSION_WEIGHT_IDENTIFIER, SUSPENSION_LIGHT_IDENTIFIER]) {
+    const point = pointMap.get(identifier)
+    if (point) latestSuspensionPoints.set(identifier, point)
+  }
+
+  const weightPoint = latestSuspensionPoints.get(SUSPENSION_WEIGHT_IDENTIFIER)
+  const lightPoint = latestSuspensionPoints.get(SUSPENSION_LIGHT_IDENTIFIER)
+
+  if (!weightPoint && !lightPoint) return null
+
+  return {
+    ts: normalizeChartTimestamp(Math.max(weightPoint?.ts ?? 0, lightPoint?.ts ?? 0)),
+    value:
+      weightPoint && lightPoint
+        ? weightPoint.value - lightPoint.value
+        : weightPoint
+          ? weightPoint.value
+          : -lightPoint!.value
+  }
+}
+
 const chartRef = ref<HTMLDivElement | null>(null)
 const chartRef = ref<HTMLDivElement | null>(null)
 let chart: echarts.ECharts | null = null
 let chart: echarts.ECharts | null = null
 
 
@@ -249,7 +397,7 @@ watch(
     if (!active) {
     if (!active) {
       destroy()
       destroy()
       cancelRealtimeChartUpdate()
       cancelRealtimeChartUpdate()
-      clearThresholdAlarms()
+      clearActiveThresholdAlarms()
       return
       return
     }
     }
 
 
@@ -280,7 +428,10 @@ function getSeriesRange(name: string) {
   const configuredMax = normalizeRangeValue(dimension?.axisMax)
   const configuredMax = normalizeRangeValue(dimension?.axisMax)
   const configured =
   const configured =
     configuredMin !== undefined && configuredMax !== undefined && configuredMin < configuredMax
     configuredMin !== undefined && configuredMax !== undefined && configuredMin < configuredMax
-  const min = configured ? configuredMin : 0
+  const isSuspensionCurve =
+    suspensionWeightAndLightEnabled.value &&
+    String(dimension?.identifier) === SUSPENSION_WEIGHT_IDENTIFIER
+  const min = configured ? configuredMin : isSuspensionCurve ? -100 : 0
   const max = configured ? configuredMax : 100
   const max = configured ? configuredMax : 100
 
 
   return {
   return {
@@ -459,40 +610,77 @@ function updateDimensionValues(valueMap: Map<string, number>) {
   })
   })
 }
 }
 
 
-function updateThresholdAlarm(valueMap: Map<string, number>) {
+function updateThresholdAlarm(pointMap: Map<string, ChartPoint>) {
   const nextAlarms = { ...thresholdAlarms.value }
   const nextAlarms = { ...thresholdAlarms.value }
+  const nextNotices = { ...thresholdAlarmNotices.value }
 
 
   dimensions.value.forEach((item) => {
   dimensions.value.forEach((item) => {
-    const value = valueMap.get(item.identifier)
-    if (value === undefined) return
+    const point = pointMap.get(item.identifier)
+    if (!point) return
 
 
-    const min = normalizeRangeValue(item.minValue)
-    const max = normalizeRangeValue(item.maxValue)
     const alarmKey = `${item.identifier}|${item.name}`
     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 {
+    const duration = normalizeRangeValue(item.periodSecond)
+    const range = normalizeRangeValue(item.changeValue)
+
+    if (duration === undefined || duration <= 0 || range === undefined || range < 0) {
+      delete nextAlarms[alarmKey]
+      thresholdAlarmNoticeTracker.pass(alarmKey)
+      return
+    }
+
+    const series = chartData.value[item.name] || [point]
+    const anchorTs = series[series.length - 1]?.ts ?? point.ts
+    const alarm = getWindowChangeAlarm(series, anchorTs, duration, range)
+
+    if (!alarm) {
       delete nextAlarms[alarmKey]
       delete nextAlarms[alarmKey]
+      thresholdAlarmNoticeTracker.pass(alarmKey)
+      return
+    }
+
+    const fingerprint = `${alarm.fromTs}:${alarm.fromValue}|${alarm.toTs}:${alarm.toValue}`
+
+    if (thresholdAlarmNoticeTracker.isAcknowledged(alarmKey, fingerprint)) {
+      delete nextAlarms[alarmKey]
+      return
+    }
+
+    const previousAlarm = thresholdAlarms.value[alarmKey]
+    const activeAlarm: ThresholdAlarm = {
+      alarmAt: previousAlarm?.fingerprint === fingerprint ? previousAlarm.alarmAt : anchorTs,
+      alarmKey,
+      fingerprint,
+      key: alarmKey,
+      name: item.name,
+      ...alarm,
+      duration,
+      range,
+      suffix: item.suffix
+    }
+
+    nextAlarms[alarmKey] = activeAlarm
+
+    if (thresholdAlarmNoticeTracker.shouldCreate(alarmKey, fingerprint)) {
+      const noticeKey = `${alarmKey}|${anchorTs}|${++thresholdAlarmNoticeSequence}`
+
+      // 告警状态可能已经恢复过,追踪器会清理当前键,但历史提示仍需保留到手动关闭。
+      // 新一轮告警创建时,先清理同一曲线的历史提示,确保每条曲线只展示最新一条。
+      Object.values(nextNotices).forEach((notice) => {
+        if (notice.alarmKey === alarmKey) delete nextNotices[notice.key]
+      })
+
+      nextNotices[noticeKey] = {
+        ...activeAlarm,
+        alarmAt: anchorTs,
+        key: noticeKey
+      }
+      const previousNoticeKey = thresholdAlarmNoticeTracker.record(alarmKey, noticeKey, fingerprint)
+      if (previousNoticeKey) delete nextNotices[previousNoticeKey]
     }
     }
   })
   })
 
 
   thresholdAlarms.value = nextAlarms
   thresholdAlarms.value = nextAlarms
+  thresholdAlarmNotices.value = nextNotices
   setAlarmActive(activeThresholdAlarms.value.length > 0)
   setAlarmActive(activeThresholdAlarms.value.length > 0)
 }
 }
 
 
@@ -520,6 +708,7 @@ function updateSeriesByNames(names: string[]) {
       series: names.map((name) => ({
       series: names.map((name) => ({
         id: name,
         id: name,
         name,
         name,
+        showSymbol: chartData.value[name]?.length === 1,
         data: buildSeriesData(name)
         data: buildSeriesData(name)
       }))
       }))
     },
     },
@@ -555,6 +744,7 @@ function flushRealtimeChartUpdate() {
       series: names.map((name) => ({
       series: names.map((name) => ({
         id: name,
         id: name,
         name,
         name,
+        showSymbol: chartData.value[name]?.length === 1,
         data: buildSeriesData(name)
         data: buildSeriesData(name)
       }))
       }))
     },
     },
@@ -738,7 +928,7 @@ function render() {
         type: 'line',
         type: 'line',
         sampling: 'lttb',
         sampling: 'lttb',
         smooth: 0.24,
         smooth: 0.24,
-        showSymbol: false,
+        showSymbol: chartData.value[item.name]?.length === 1,
         connectNulls: true,
         connectNulls: true,
         endLabel: {
         endLabel: {
           show: true,
           show: true,
@@ -770,12 +960,70 @@ function render() {
 
 
 const chartLoading = ref(false)
 const chartLoading = ref(false)
 
 
+function normalizeHistoryPoints(data: unknown): ChartPoint[] {
+  if (!Array.isArray(data)) return []
+
+  const points = data
+    .filter(
+      (item) => item.value !== null && item.value !== undefined && String(item.value).trim() !== ''
+    )
+    .map((item) => ({
+      ts: Number(item.ts),
+      value: Number(item.value)
+    }))
+    .filter((item) => Number.isFinite(item.ts) && Number.isFinite(item.value))
+    .sort((a, b) => a.ts - b.ts)
+
+  // 同一秒保留最后采样值,历史和实时均使用同一套坐标精度。
+  const pointsBySecond = new Map<number, ChartPoint>()
+  points.forEach((point) => {
+    const ts = normalizeChartTimestamp(point.ts)
+    pointsBySecond.set(ts, { ts, value: point.value })
+  })
+  return Array.from(pointsBySecond.values())
+}
+
+function mergeSuspensionHistory(weightPoints: ChartPoint[], lightPoints: ChartPoint[]) {
+  const mergedPoints = new Map<number, { weight?: number; light?: number }>()
+
+  weightPoints.forEach((point) => {
+    const timestamp = normalizeChartTimestamp(point.ts)
+    mergedPoints.set(timestamp, { ...mergedPoints.get(timestamp), weight: point.value })
+  })
+  lightPoints.forEach((point) => {
+    const timestamp = normalizeChartTimestamp(point.ts)
+    mergedPoints.set(timestamp, { ...mergedPoints.get(timestamp), light: point.value })
+  })
+
+  let latestWeight: number | undefined
+  let latestLight: number | undefined
+
+  return Array.from(mergedPoints)
+    .sort(([a], [b]) => a - b)
+    .map(([ts, value]) => {
+      // 按时间保留最近值,允许两侧分开上报;尚未收到另一侧时显示单侧值。
+      if (value.weight !== undefined) latestWeight = value.weight
+      if (value.light !== undefined) latestLight = value.light
+
+      return {
+        ts,
+        value:
+          latestWeight !== undefined && latestLight !== undefined
+            ? latestWeight - latestLight
+            : latestWeight !== undefined
+              ? latestWeight
+              : -latestLight!
+      }
+    })
+}
+
 async function initLoadChartData(real_time: boolean = true) {
 async function initLoadChartData(real_time: boolean = true) {
   if (!dimensions.value.length) return
   if (!dimensions.value.length) return
 
 
   const loadVersion = ++chartLoadVersion
   const loadVersion = ++chartLoadVersion
   chartRealtimeMode.value = real_time
   chartRealtimeMode.value = real_time
   chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
   chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
+  latestSuspensionPoints.clear()
   chartLoading.value = true
   chartLoading.value = true
 
 
   try {
   try {
@@ -790,9 +1038,33 @@ async function initLoadChartData(real_time: boolean = true) {
             props.date[1]
             props.date[1]
           )
           )
 
 
-          chartData.value[item.name] = (((res as any[]) ?? []) as any[])
-            .sort((a, b) => a.ts - b.ts)
-            .map((item) => ({ ts: item.ts, value: item.value }))
+          if (
+            suspensionWeightAndLightEnabled.value &&
+            String(item.identifier) === SUSPENSION_WEIGHT_IDENTIFIER
+          ) {
+            const lightRes = await IotStatApi.getDeviceInfoChartly(
+              props.deviceCode,
+              SUSPENSION_LIGHT_IDENTIFIER,
+              props.date[0],
+              props.date[1]
+            )
+
+            const weightPoints = normalizeHistoryPoints(res)
+            const lightPoints = normalizeHistoryPoints(lightRes)
+            const latestWeightPoint = weightPoints[weightPoints.length - 1]
+            const latestLightPoint = lightPoints[lightPoints.length - 1]
+
+            if (latestWeightPoint) {
+              latestSuspensionPoints.set(SUSPENSION_WEIGHT_IDENTIFIER, latestWeightPoint)
+            }
+            if (latestLightPoint) {
+              latestSuspensionPoints.set(SUSPENSION_LIGHT_IDENTIFIER, latestLightPoint)
+            }
+
+            chartData.value[item.name] = mergeSuspensionHistory(weightPoints, lightPoints)
+          } else {
+            chartData.value[item.name] = normalizeHistoryPoints(res)
+          }
         } catch (error) {
         } catch (error) {
           console.error(error)
           console.error(error)
         } finally {
         } finally {
@@ -862,6 +1134,7 @@ watch(
 
 
 onUnmounted(() => {
 onUnmounted(() => {
   chartLoadVersion += 1
   chartLoadVersion += 1
+  latestSuspensionPoints.clear()
   destroy()
   destroy()
   cancelRealtimeChartUpdate()
   cancelRealtimeChartUpdate()
 
 
@@ -902,8 +1175,8 @@ function buildRangeSettingDrafts() {
     name: item.name,
     name: item.name,
     identifier: item.identifier,
     identifier: item.identifier,
     color: item.color,
     color: item.color,
-    minValue: normalizeRangeValue(item.minValue),
-    maxValue: normalizeRangeValue(item.maxValue),
+    periodSecond: normalizeRangeValue(item.periodSecond),
+    changeValue: normalizeRangeValue(item.changeValue),
     axisMin: normalizeRangeValue(item.axisMin),
     axisMin: normalizeRangeValue(item.axisMin),
     axisMax: normalizeRangeValue(item.axisMax)
     axisMax: normalizeRangeValue(item.axisMax)
   }))
   }))
@@ -940,39 +1213,46 @@ async function saveDeviceName() {
 
 
 async function saveRangeSetting(
 async function saveRangeSetting(
   item: Dimensions,
   item: Dimensions,
-  minValue?: number,
-  maxValue?: number,
+  periodSecondValue?: number,
+  changeValueValue?: number,
   axisMinValue?: number,
   axisMinValue?: number,
   axisMaxValue?: number
   axisMaxValue?: number
 ) {
 ) {
-  const min = normalizeRangeValue(minValue)
-  const max = normalizeRangeValue(maxValue)
+  const periodSecond = normalizeRangeValue(periodSecondValue)
+  const changeValue = normalizeRangeValue(changeValueValue)
   const axisMin = normalizeRangeValue(axisMinValue)
   const axisMin = normalizeRangeValue(axisMinValue)
   const axisMax = normalizeRangeValue(axisMaxValue)
   const axisMax = normalizeRangeValue(axisMaxValue)
 
 
-  if (min === undefined && max === undefined && axisMin === undefined && axisMax === undefined) {
+  if (
+    periodSecond === undefined &&
+    changeValue === undefined &&
+    axisMin === undefined &&
+    axisMax === undefined
+  ) {
     if (item.id) {
     if (item.id) {
       await IotDeviceApi.deleteMaxMin({ id: item.id })
       await IotDeviceApi.deleteMaxMin({ id: item.id })
     }
     }
 
 
     item.minValue = undefined
     item.minValue = undefined
     item.maxValue = undefined
     item.maxValue = undefined
+    item.periodSecond = undefined
+    item.changeValue = undefined
     item.axisMin = undefined
     item.axisMin = undefined
     item.axisMax = undefined
     item.axisMax = undefined
     item.id = undefined
     item.id = undefined
     return
     return
   }
   }
 
 
-  if ((min === undefined) !== (max === undefined)) {
-    throw new Error(`${item.name} 的告警上限和告警下限需要同时填写`)
+  if ((periodSecond === undefined) !== (changeValue === undefined)) {
+    throw new Error(`${item.name} 的检查时间和超限范围需要同时填写`)
   }
   }
 
 
-  if (min !== undefined && max !== undefined && min > max) {
-    throw new Error(`${item.name} 的告警下限不能大于告警上限`)
+  if (periodSecond !== undefined && (!Number.isInteger(periodSecond) || periodSecond <= 0)) {
+    throw new Error(`${item.name} 的检查时间必须为大于 0 的整数秒`)
   }
   }
 
 
-  if (min !== undefined && max !== undefined && min === max) {
-    throw new Error(`${item.name} 的告警上限和告警下限不能相等`)
+  if (changeValue !== undefined && changeValue < 0) {
+    throw new Error(`${item.name} 的超限范围不能小于 0`)
   }
   }
 
 
   if ((axisMin === undefined) !== (axisMax === undefined)) {
   if ((axisMin === undefined) !== (axisMax === undefined)) {
@@ -984,8 +1264,10 @@ async function saveRangeSetting(
   }
   }
 
 
   const body = {
   const body = {
-    minValue: min ?? null,
-    maxValue: max ?? null,
+    minValue: null,
+    maxValue: null,
+    periodSecond: periodSecond ?? null,
+    changeValue: changeValue ?? null,
     axisMin: axisMin ?? null,
     axisMin: axisMin ?? null,
     axisMax: axisMax ?? null,
     axisMax: axisMax ?? null,
     deviceId: props.id,
     deviceId: props.id,
@@ -998,8 +1280,10 @@ async function saveRangeSetting(
   const res = await IotDeviceApi.saveMaxMin(body)
   const res = await IotDeviceApi.saveMaxMin(body)
 
 
   if (res.id) item.id = res.id
   if (res.id) item.id = res.id
-  item.minValue = min
-  item.maxValue = max
+  item.minValue = undefined
+  item.maxValue = undefined
+  item.periodSecond = periodSecond
+  item.changeValue = changeValue
   item.axisMin = axisMin
   item.axisMin = axisMin
   item.axisMax = axisMax
   item.axisMax = axisMax
 }
 }
@@ -1013,9 +1297,16 @@ async function handleSettingsSave() {
       const item = dimensions.value.find((dimension) => dimension.identifier === draft.identifier)
       const item = dimensions.value.find((dimension) => dimension.identifier === draft.identifier)
       if (!item) continue
       if (!item) continue
 
 
-      await saveRangeSetting(item, draft.minValue, draft.maxValue, draft.axisMin, draft.axisMax)
+      await saveRangeSetting(
+        item,
+        draft.periodSecond,
+        draft.changeValue,
+        draft.axisMin,
+        draft.axisMax
+      )
     }
     }
 
 
+    clearActiveThresholdAlarms()
     updateSeriesByNames(dimensions.value.map((item) => item.name))
     updateSeriesByNames(dimensions.value.map((item) => item.name))
     settingsDialogVisible.value = false
     settingsDialogVisible.value = false
     message.success('设置成功')
     message.success('设置成功')
@@ -1029,16 +1320,16 @@ async function handleSettingsSave() {
 function handleRangeDialogReset() {
 function handleRangeDialogReset() {
   rangeSettingDrafts.value = rangeSettingDrafts.value.map((item) => ({
   rangeSettingDrafts.value = rangeSettingDrafts.value.map((item) => ({
     ...item,
     ...item,
-    minValue: undefined,
-    maxValue: undefined,
+    periodSecond: undefined,
+    changeValue: undefined,
     axisMin: undefined,
     axisMin: undefined,
     axisMax: undefined
     axisMax: undefined
   }))
   }))
 }
 }
 
 
 function clearRangeDraft(row: RangeSettingDraft) {
 function clearRangeDraft(row: RangeSettingDraft) {
-  row.minValue = undefined
-  row.maxValue = undefined
+  row.periodSecond = undefined
+  row.changeValue = undefined
   row.axisMin = undefined
   row.axisMin = undefined
   row.axisMax = undefined
   row.axisMax = undefined
 }
 }
@@ -1049,9 +1340,9 @@ function clearRangeDraft(row: RangeSettingDraft) {
       <div class="flex items-center">
       <div class="flex items-center">
         <div class="title-icon"></div>
         <div class="title-icon"></div>
         <div>{{ `${props.deviceCode}-${displayDeviceName}` }}</div>
         <div>{{ `${props.deviceCode}-${displayDeviceName}` }}</div>
-        <span v-if="activeThresholdAlarms.length" class="chart-header__alarm-count">
+        <span v-if="activeThresholdAlarmNotices.length" class="chart-header__alarm-count">
           <i class="i-material-symbols:warning-rounded"></i>
           <i class="i-material-symbols:warning-rounded"></i>
-          {{ activeThresholdAlarms.length }} 项超限
+          {{ activeThresholdAlarmNotices.length }} 条告警
         </span>
         </span>
       </div>
       </div>
       <div class="chart-header__actions">
       <div class="chart-header__actions">
@@ -1090,16 +1381,23 @@ function clearRangeDraft(row: RangeSettingDraft) {
 
 
       <div class="chart-stage">
       <div class="chart-stage">
         <div
         <div
-          v-if="activeThresholdAlarms.length"
+          v-if="activeThresholdAlarmNotices.length"
           class="chart-alarm-panel"
           class="chart-alarm-panel"
           role="alert"
           role="alert"
           aria-live="polite">
           aria-live="polite">
           <span
           <span
-            v-for="alarm in activeThresholdAlarms"
+            v-for="alarm in activeThresholdAlarmNotices"
             :key="alarm.key"
             :key="alarm.key"
             class="chart-alarm-panel__item">
             class="chart-alarm-panel__item">
             <i class="i-material-symbols:warning-rounded"></i>
             <i class="i-material-symbols:warning-rounded"></i>
             <span>{{ getThresholdAlarmText(alarm) }}</span>
             <span>{{ getThresholdAlarmText(alarm) }}</span>
+            <button
+              type="button"
+              class="chart-alarm-panel__close"
+              :aria-label="`关闭 ${alarm.name} 告警提示`"
+              @click.stop="closeThresholdAlarmNotice(alarm.key)">
+              <i class="i-material-symbols:close-rounded"></i>
+            </button>
           </span>
           </span>
         </div>
         </div>
         <div ref="chartRef" class="chart-canvas"></div>
         <div ref="chartRef" class="chart-canvas"></div>
@@ -1109,7 +1407,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
     <el-dialog
     <el-dialog
       v-model="settingsDialogVisible"
       v-model="settingsDialogVisible"
       title="设备设置"
       title="设备设置"
-      width="1100px"
+      width="min(1140px, calc(100vw - 32px))"
       class="board-settings-dialog"
       class="board-settings-dialog"
       modal-class="board-settings-overlay"
       modal-class="board-settings-overlay"
       append-to-body
       append-to-body
@@ -1125,37 +1423,40 @@ function clearRangeDraft(row: RangeSettingDraft) {
       </el-form>
       </el-form>
 
 
       <div class="board-settings-section">
       <div class="board-settings-section">
-        <div class="board-settings-section__title">告警阈值与 Y 轴范围</div>
+        <div class="board-settings-section__title">检查时间、超限范围与 Y 轴范围</div>
         <ZmTable :data="rangeSettingDrafts" :loading="false" :max-height="420" :show-border="true">
         <ZmTable :data="rangeSettingDrafts" :loading="false" :max-height="420" :show-border="true">
-          <ZmTableColumn label="曲线" min-width="180">
+          <ZmTableColumn label="曲线" align="left">
             <template #default="{ row }">
             <template #default="{ row }">
               <div class="flex items-center gap-2 min-w-0">
               <div class="flex items-center gap-2 min-w-0">
                 <span
                 <span
                   class="inline-block size-2.5 rounded-full shrink-0"
                   class="inline-block size-2.5 rounded-full shrink-0"
                   :style="{ backgroundColor: row.color }"></span>
                   :style="{ backgroundColor: row.color }"></span>
-                <span class="board-settings-line-name">{{ row.name }}</span>
+                <span class="board-settings-line-name" :title="row.name">{{ row.name }}</span>
               </div>
               </div>
             </template>
             </template>
           </ZmTableColumn>
           </ZmTableColumn>
-          <ZmTableColumn label="告警下限" width="140">
+          <ZmTableColumn label="检查时间(秒)">
             <template #default="{ row }">
             <template #default="{ row }">
               <el-input-number
               <el-input-number
-                v-model="row.minValue"
+                v-model="row.periodSecond"
                 class="!w-full"
                 class="!w-full"
                 :controls="false"
                 :controls="false"
+                :min="1"
+                :precision="0"
                 placeholder="未设置" />
                 placeholder="未设置" />
             </template>
             </template>
           </ZmTableColumn>
           </ZmTableColumn>
-          <ZmTableColumn label="告警上限" width="140">
+          <ZmTableColumn label="超限范围">
             <template #default="{ row }">
             <template #default="{ row }">
               <el-input-number
               <el-input-number
-                v-model="row.maxValue"
+                v-model="row.changeValue"
                 class="!w-full"
                 class="!w-full"
                 :controls="false"
                 :controls="false"
-                placeholder="未设置" />
+                :min="0"
+                placeholder="0" />
             </template>
             </template>
           </ZmTableColumn>
           </ZmTableColumn>
-          <ZmTableColumn label="Y 轴下限" width="140">
+          <ZmTableColumn label="Y 轴下限">
             <template #default="{ row }">
             <template #default="{ row }">
               <el-input-number
               <el-input-number
                 v-model="row.axisMin"
                 v-model="row.axisMin"
@@ -1164,7 +1465,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
                 :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
                 :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
             </template>
             </template>
           </ZmTableColumn>
           </ZmTableColumn>
-          <ZmTableColumn label="Y 轴上限" width="140">
+          <ZmTableColumn label="Y 轴上限">
             <template #default="{ row }">
             <template #default="{ row }">
               <el-input-number
               <el-input-number
                 v-model="row.axisMax"
                 v-model="row.axisMax"
@@ -1173,7 +1474,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
                 :placeholder="formatChartValue(getSeriesRange(row.name).labelMax)" />
                 :placeholder="formatChartValue(getSeriesRange(row.name).labelMax)" />
             </template>
             </template>
           </ZmTableColumn>
           </ZmTableColumn>
-          <ZmTableColumn label="当前 Y 轴范围" width="150">
+          <ZmTableColumn label="当前 Y 轴范围">
             <template #default="{ row }">
             <template #default="{ row }">
               <span class="board-settings-range-text">
               <span class="board-settings-range-text">
                 {{ formatChartValue(getSeriesRange(row.name).labelMin) }}
                 {{ formatChartValue(getSeriesRange(row.name).labelMin) }}
@@ -1182,7 +1483,7 @@ function clearRangeDraft(row: RangeSettingDraft) {
               </span>
               </span>
             </template>
             </template>
           </ZmTableColumn>
           </ZmTableColumn>
-          <ZmTableColumn label="操作" width="90">
+          <ZmTableColumn label="操作">
             <template #default="{ row }">
             <template #default="{ row }">
               <el-button size="small" link type="primary" @click="clearRangeDraft(row)">
               <el-button size="small" link type="primary" @click="clearRangeDraft(row)">
                 清空
                 清空
@@ -1298,6 +1599,14 @@ function clearRangeDraft(row: RangeSettingDraft) {
   color: #334155;
   color: #334155;
 }
 }
 
 
+.board-settings-line-name {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .chart-main {
 .chart-main {
   display: grid;
   display: grid;
   grid-template-columns: auto minmax(0, 1fr);
   grid-template-columns: auto minmax(0, 1fr);
@@ -1461,17 +1770,38 @@ function clearRangeDraft(row: RangeSettingDraft) {
   display: inline-flex;
   display: inline-flex;
   gap: 5px;
   gap: 5px;
   align-items: center;
   align-items: center;
+  max-width: 100%;
   padding: 3px 7px;
   padding: 3px 7px;
   font-size: 12px;
   font-size: 12px;
   font-weight: 700;
   font-weight: 700;
   line-height: 1.4;
   line-height: 1.4;
   color: #fee2e2;
   color: #fee2e2;
-  white-space: nowrap;
+  white-space: normal;
   background: rgb(220 38 38 / 24%);
   background: rgb(220 38 38 / 24%);
   border: 1px solid rgb(248 113 113 / 40%);
   border: 1px solid rgb(248 113 113 / 40%);
   border-radius: 999px;
   border-radius: 999px;
 }
 }
 
 
+.chart-alarm-panel__close {
+  display: inline-flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: center;
+  width: 18px;
+  height: 18px;
+  padding: 0;
+  color: #fecaca;
+  cursor: pointer;
+  background: transparent;
+  border: 0;
+  border-radius: 999px;
+}
+
+.chart-alarm-panel__close:hover {
+  color: #fff;
+  background: rgb(248 113 113 / 28%);
+}
+
 @keyframes alarm-pulse {
 @keyframes alarm-pulse {
   0%,
   0%,
   100% {
   100% {

+ 31 - 0
src/views/oli-connection/monitoring/__tests__/excel-date.spec.ts

@@ -0,0 +1,31 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { toExcelDateValue } from '../excelDate'
+
+const originalTimezone = process.env.TZ
+
+describe('monitoring Excel date export', () => {
+  beforeAll(() => {
+    process.env.TZ = 'Asia/Shanghai'
+  })
+
+  afterAll(() => {
+    if (originalTimezone === undefined) delete process.env.TZ
+    else process.env.TZ = originalTimezone
+  })
+
+  it('keeps the local wall-clock time when SheetJS writes the workbook', async () => {
+    const XLSX = await import('xlsx')
+    const timestamp = new Date(2026, 8, 28, 10, 0, 0).getTime()
+    const worksheet = XLSX.utils.aoa_to_sheet([[toExcelDateValue(timestamp)]], {
+      cellDates: true
+    })
+    worksheet.A1.z = 'yyyy-mm-dd hh:mm:ss'
+
+    const workbook = XLSX.utils.book_new()
+    XLSX.utils.book_append_sheet(workbook, worksheet, '监控数据')
+    const output = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' })
+    const exportedCell = XLSX.read(output, { type: 'buffer' }).Sheets['监控数据'].A1
+
+    expect(exportedCell.w).toBe('2026-09-28 10:00:00')
+  })
+})

+ 342 - 130
src/views/oli-connection/monitoring/detail.vue

@@ -4,6 +4,7 @@ import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
 import { useTableComponents } from '@/components/ZmTable/useTableComponents'
 import { useTableComponents } from '@/components/ZmTable/useTableComponents'
 import { rangeShortcuts } from '@/utils/formatTime'
 import { rangeShortcuts } from '@/utils/formatTime'
 import { useMqtt } from '@/utils/useMqtt'
 import { useMqtt } from '@/utils/useMqtt'
+import { toExcelDateValue } from './excelDate'
 import {
 import {
   Dimensions,
   Dimensions,
   formatChartValue,
   formatChartValue,
@@ -80,6 +81,7 @@ const DEFAULT_CHART_WINDOW_MINUTES = 10
 const MIN_CHART_WINDOW_MINUTES = 1
 const MIN_CHART_WINDOW_MINUTES = 1
 const MAX_CHART_WINDOW_MINUTES = 120
 const MAX_CHART_WINDOW_MINUTES = 120
 const chartWindowMinutes = ref(DEFAULT_CHART_WINDOW_MINUTES)
 const chartWindowMinutes = ref(DEFAULT_CHART_WINDOW_MINUTES)
+const chartWindowMinutesDraft = ref(DEFAULT_CHART_WINDOW_MINUTES)
 const chartWindowMs = computed(() => chartWindowMinutes.value * 60 * 1000)
 const chartWindowMs = computed(() => chartWindowMinutes.value * 60 * 1000)
 
 
 function normalizeChartWindowMinutes(value?: number | null) {
 function normalizeChartWindowMinutes(value?: number | null) {
@@ -114,6 +116,10 @@ function isRangeGreaterThan120Minutes(range: string[]) {
 }
 }
 
 
 const REALTIME_RIGHT_BLANK_MS = 60 * 1000
 const REALTIME_RIGHT_BLANK_MS = 60 * 1000
+const SUSPENSION_WEIGHT_IDENTIFIER = '1001'
+const SUSPENSION_LIGHT_IDENTIFIER = '1008'
+const SUSPENSION_COMBINED_NAME = '悬重/悬轻'
+const suspensionWeightAndLightEnabled = ref(false)
 const selectedDate = ref<string[]>(getInitialDateRange())
 const selectedDate = ref<string[]>(getInitialDateRange())
 const chartRef = ref<HTMLDivElement | null>(null)
 const chartRef = ref<HTMLDivElement | null>(null)
 const targetArea = ref<HTMLElement | null>(null)
 const targetArea = ref<HTMLElement | null>(null)
@@ -138,6 +144,14 @@ interface ChartData {
 }
 }
 
 
 const chartData = shallowRef<ChartData>({})
 const chartData = shallowRef<ChartData>({})
+
+// 【修复前】每条 MQTT 消息只在当前 payload 内查找 1001(悬重)和 1008(悬轻)。
+// 如果设备把两个测点拆成两条消息上报,当前消息缺少的那一侧会被当成 0。
+// 例如:只收到悬轻 0 时,旧逻辑会计算成 0 - 0 = 0,形成错误的“掉零”点。
+//
+// 保存两侧最近一次有效点。尚未收到另一侧时显示单侧值,
+// 悬重为正向、悬轻为负向;两侧齐全后再计算差值。
+const latestSuspensionPoints = new Map<string, ChartPoint>()
 const resizeChart = () => chart?.resize()
 const resizeChart = () => chart?.resize()
 
 
 watch(isConnected, (newVal) => {
 watch(isConnected, (newVal) => {
@@ -150,11 +164,45 @@ async function loadDimensions() {
   if (!query.id) return
   if (!query.id) return
   dimensionLoading.value = true
   dimensionLoading.value = true
   try {
   try {
-    const gateway = [
+    const gatewaySource = [
       ...(((await IotDeviceApi.getIotDeviceTds(Number(query.id))) as IotTdItem[]) ?? [])
       ...(((await IotDeviceApi.getIotDeviceTds(Number(query.id))) as IotTdItem[]) ?? [])
     ]
     ]
+      .map((item) => ({ ...item, identifier: String(item.identifier).trim() }))
       .sort((a, b) => (b.modelOrder ?? 0) - (a.modelOrder ?? 0))
       .sort((a, b) => (b.modelOrder ?? 0) - (a.modelOrder ?? 0))
       .map(mapToDimension)
       .map(mapToDimension)
+    const gatewayIdentifiers = new Set(gatewaySource.map((item) => String(item.identifier)))
+
+    suspensionWeightAndLightEnabled.value =
+      gatewayIdentifiers.has(SUSPENSION_WEIGHT_IDENTIFIER) &&
+      gatewayIdentifiers.has(SUSPENSION_LIGHT_IDENTIFIER)
+
+    // 属性是否存在以 td 的完整列表为准,悬轻值为空也仍启用合并。
+    const weightDimension = gatewaySource.find(
+      (item) => item.identifier === SUSPENSION_WEIGHT_IDENTIFIER
+    )
+    if (weightDimension) {
+      weightDimension.name = suspensionWeightAndLightEnabled.value
+        ? SUSPENSION_COMBINED_NAME
+        : '悬重'
+    }
+
+    if (suspensionWeightAndLightEnabled.value) {
+      const lightDimension = gatewaySource.find(
+        (item) => String(item.identifier) === SUSPENSION_LIGHT_IDENTIFIER
+      )
+      const weightValue = Number(weightDimension?.value ?? 0)
+      const lightValue = Number(lightDimension?.value ?? 0)
+
+      if (weightDimension && Number.isFinite(weightValue) && Number.isFinite(lightValue)) {
+        weightDimension.value = weightValue - lightValue
+      }
+    }
+
+    const gateway = gatewaySource.filter(
+      (item) =>
+        !suspensionWeightAndLightEnabled.value ||
+        String(item.identifier) !== SUSPENSION_LIGHT_IDENTIFIER
+    )
     // const car = [
     // const car = [
     //   ...(((await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))) as IotTdItem[]) ?? [])
     //   ...(((await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))) as IotTdItem[]) ?? [])
     // ]
     // ]
@@ -203,7 +251,10 @@ function getSeriesRange(name: string) {
   const configuredMax = normalizeRangeValue(dimension?.axisMax)
   const configuredMax = normalizeRangeValue(dimension?.axisMax)
   const configured =
   const configured =
     configuredMin !== undefined && configuredMax !== undefined && configuredMin < configuredMax
     configuredMin !== undefined && configuredMax !== undefined && configuredMin < configuredMax
-  const min = configured ? configuredMin : 0
+  const isSuspensionCurve =
+    suspensionWeightAndLightEnabled.value &&
+    String(dimension?.identifier) === SUSPENSION_WEIGHT_IDENTIFIER
+  const min = configured ? configuredMin : isSuspensionCurve ? -100 : 0
   const max = configured ? configuredMax : 100
   const max = configured ? configuredMax : 100
 
 
   return {
   return {
@@ -274,6 +325,38 @@ function getPointTimestamp(value: unknown) {
   return Number.isFinite(parsedValue) ? parsedValue : Date.now()
   return Number.isFinite(parsedValue) ? parsedValue : Date.now()
 }
 }
 
 
+function normalizeChartTimestamp(timestamp: number) {
+  // 所有曲线使用相同的秒级坐标,避免组合曲线与其他曲线因毫秒差拆成两个 tooltip。
+  return Math.floor(timestamp / 1000) * 1000
+}
+
+function getCombinedSuspensionPoint(pointMap: Map<string, ChartPoint>) {
+  if (!pointMap.has(SUSPENSION_WEIGHT_IDENTIFIER) && !pointMap.has(SUSPENSION_LIGHT_IDENTIFIER)) {
+    return null
+  }
+  // 当前 payload 可能只包含 1001 或 1008 的其中一个,因此先更新缓存,
+  // 再从缓存中读取两侧最近一次有效值,而不是只依赖当前 payload。
+  for (const identifier of [SUSPENSION_WEIGHT_IDENTIFIER, SUSPENSION_LIGHT_IDENTIFIER]) {
+    const point = pointMap.get(identifier)
+    if (point) latestSuspensionPoints.set(identifier, point)
+  }
+
+  const weightPoint = latestSuspensionPoints.get(SUSPENSION_WEIGHT_IDENTIFIER)
+  const lightPoint = latestSuspensionPoints.get(SUSPENSION_LIGHT_IDENTIFIER)
+
+  if (!weightPoint && !lightPoint) return null
+
+  return {
+    ts: normalizeChartTimestamp(Math.max(weightPoint?.ts ?? 0, lightPoint?.ts ?? 0)),
+    value:
+      weightPoint && lightPoint
+        ? weightPoint.value - lightPoint.value
+        : weightPoint
+          ? weightPoint.value
+          : -lightPoint!.value
+  }
+}
+
 type SeriesRange = ReturnType<typeof getSeriesRange>
 type SeriesRange = ReturnType<typeof getSeriesRange>
 
 
 function mapChartPoint({ ts, value }: ChartPoint, range: SeriesRange) {
 function mapChartPoint({ ts, value }: ChartPoint, range: SeriesRange) {
@@ -412,6 +495,7 @@ function updateSingleSeries(name: string) {
       {
       {
         id: name,
         id: name,
         name,
         name,
+        showSymbol: chartData.value[name]?.length === 1,
         data: buildSeriesData(name)
         data: buildSeriesData(name)
       }
       }
     ]
     ]
@@ -431,6 +515,7 @@ function updateSeriesByNames(names: string[]) {
     series: names.map((name) => ({
     series: names.map((name) => ({
       id: name,
       id: name,
       name,
       name,
+      showSymbol: chartData.value[name]?.length === 1,
       data: buildSeriesData(name)
       data: buildSeriesData(name)
     }))
     }))
   })
   })
@@ -461,6 +546,7 @@ function flushRealtimeChartUpdate() {
     series: names.map((name) => ({
     series: names.map((name) => ({
       id: name,
       id: name,
       name,
       name,
+      showSymbol: chartData.value[name]?.length === 1,
       data: buildSeriesData(name)
       data: buildSeriesData(name)
     }))
     }))
   })
   })
@@ -586,7 +672,7 @@ function renderChart() {
         type: 'line',
         type: 'line',
         sampling: 'lttb',
         sampling: 'lttb',
         smooth: 0.24,
         smooth: 0.24,
-        showSymbol: false,
+        showSymbol: chartData.value[item.name]?.length === 1,
         connectNulls: true,
         connectNulls: true,
         endLabel: {
         endLabel: {
           show: true,
           show: true,
@@ -623,29 +709,82 @@ function updateDimensionValues(valueMap: Map<string, number>) {
   })
   })
 }
 }
 
 
-function handleMessageUpdate(_topic: string, payload: any) {
+function handleMessageUpdate(topic: string, payload: any) {
+  console.log('[监控详情 MQTT 返回数据]', {
+    deviceCode: data.value.deviceCode,
+    topic,
+    receivedAt: dayjs().format('YYYY-MM-DD HH:mm:ss.SSS'),
+    data: payload
+  })
   const list = Array.isArray(payload) ? payload : Array.isArray(payload?.data) ? payload.data : []
   const list = Array.isArray(payload) ? payload : Array.isArray(payload?.data) ? payload.data : []
+
   const valueMap = new Map<string, number>()
   const valueMap = new Map<string, number>()
+  const pointMap = new Map<string, ChartPoint>()
   const updatedNames = new Set<string>()
   const updatedNames = new Set<string>()
 
 
   list.forEach((item) => {
   list.forEach((item) => {
     const identity = String(item.id ?? item.identity ?? '')
     const identity = String(item.id ?? item.identity ?? '')
-    const numberValue = Number(item.value ?? item.logValue ?? 0)
-
-    if (!identity || !Number.isFinite(numberValue)) return
+    // 【修复前】item.value ?? item.logValue ?? 0 会把缺少数值的 MQTT 项伪装成 0。
+    // 【修复后】缺少 value/logValue 时直接忽略该项;真正上报的数值 0 仍然会被保留。
+    const rawValue = item.value ?? item.logValue
+    const numberValue = Number(rawValue)
+
+    if (
+      !identity ||
+      rawValue === null ||
+      rawValue === undefined ||
+      rawValue === '' ||
+      !Number.isFinite(numberValue)
+    ) {
+      return
+    }
 
 
     valueMap.set(identity, numberValue)
     valueMap.set(identity, numberValue)
+    const point = {
+      // 【修复前】详情页只读取 remark;如果后端返回 readTime/ts/time,就会走 Date.now()。
+      // 【修复后】按设备消息可能使用的字段依次取值,保证点的时间尽量来自设备实际上报时间。
+      ts: normalizeChartTimestamp(
+        getPointTimestamp(item.remark || item.readTime || item.ts || item.time)
+      ),
+      value: numberValue
+    }
+    pointMap.set(identity, point)
+
+    if (
+      suspensionWeightAndLightEnabled.value &&
+      (identity === SUSPENSION_WEIGHT_IDENTIFIER || identity === SUSPENSION_LIGHT_IDENTIFIER)
+    ) {
+      return
+    }
 
 
     const dimension = dimensions.value.find((item) => item.identifier === identity)
     const dimension = dimensions.value.find((item) => item.identifier === identity)
     if (!dimension) return
     if (!dimension) return
 
 
-    appendChartPoint(dimension.name, {
-      ts: getPointTimestamp(item.remark),
-      value: numberValue
-    })
+    appendChartPoint(dimension.name, point)
     updatedNames.add(dimension.name)
     updatedNames.add(dimension.name)
   })
   })
 
 
+  if (suspensionWeightAndLightEnabled.value) {
+    const combinedPoint = getCombinedSuspensionPoint(pointMap)
+
+    if (combinedPoint) {
+      valueMap.set(SUSPENSION_WEIGHT_IDENTIFIER, combinedPoint.value)
+
+      const dimension = dimensions.value.find(
+        (item) => String(item.identifier) === SUSPENSION_WEIGHT_IDENTIFIER
+      )
+
+      if (dimension) {
+        appendChartPoint(dimension.name, combinedPoint)
+        updatedNames.add(dimension.name)
+      }
+    } else {
+      // 当前消息没有有效的悬重或悬轻数据时,不更新组合值。
+      valueMap.delete(SUSPENSION_WEIGHT_IDENTIFIER)
+      valueMap.delete(SUSPENSION_LIGHT_IDENTIFIER)
+    }
+  }
+
   if (!updatedNames.size) return
   if (!updatedNames.size) return
 
 
   updateDimensionValues(valueMap)
   updateDimensionValues(valueMap)
@@ -665,6 +804,65 @@ async function startRealtimeMqtt() {
   connect(`wss://aims.deepoil.cc/mqtt`, { password: token.value }, handleMessageUpdate)
   connect(`wss://aims.deepoil.cc/mqtt`, { password: token.value }, handleMessageUpdate)
 }
 }
 
 
+function normalizeHistoryPoints(data: unknown): ChartPoint[] {
+  if (!Array.isArray(data)) return []
+
+  const points = data
+    .filter(
+      (item) => item.value !== null && item.value !== undefined && String(item.value).trim() !== ''
+    )
+    .map((item) => ({
+      ts: Number(item.ts),
+      value: Number(item.value)
+    }))
+    .filter((item) => Number.isFinite(item.ts) && Number.isFinite(item.value))
+    .sort((a, b) => a.ts - b.ts)
+
+  // 同一秒保留最后采样值,历史和实时均使用同一套坐标精度。
+  const pointsBySecond = new Map<number, ChartPoint>()
+  points.forEach((point) => {
+    const ts = normalizeChartTimestamp(point.ts)
+    pointsBySecond.set(ts, { ts, value: point.value })
+  })
+  return Array.from(pointsBySecond.values())
+}
+
+function mergeSuspensionHistory(weightPoints: ChartPoint[], lightPoints: ChartPoint[]) {
+  const mergedPoints = new Map<number, { weight?: number; light?: number }>()
+
+  weightPoints.forEach((point) => {
+    // 历史接口的两条曲线可能存在毫秒差,例如悬重 26.100、悬轻 26.800;
+    // 先按展示秒归桶,才能把同一采样秒的两侧数据配对起来。
+    const timestamp = normalizeChartTimestamp(point.ts)
+    mergedPoints.set(timestamp, { ...mergedPoints.get(timestamp), weight: point.value })
+  })
+  lightPoints.forEach((point) => {
+    const timestamp = normalizeChartTimestamp(point.ts)
+    mergedPoints.set(timestamp, { ...mergedPoints.get(timestamp), light: point.value })
+  })
+
+  let latestWeight: number | undefined
+  let latestLight: number | undefined
+
+  return Array.from(mergedPoints)
+    .sort(([a], [b]) => a - b)
+    .map(([ts, value]) => {
+      // 按时间保留最近值,允许两侧分开上报;尚未收到另一侧时显示单侧值。
+      if (value.weight !== undefined) latestWeight = value.weight
+      if (value.light !== undefined) latestLight = value.light
+
+      return {
+        ts,
+        value:
+          latestWeight !== undefined && latestLight !== undefined
+            ? latestWeight - latestLight
+            : latestWeight !== undefined
+              ? latestWeight
+              : -latestLight!
+      }
+    })
+}
+
 async function initLoadChartData({
 async function initLoadChartData({
   realtime = true,
   realtime = true,
   resetDate = realtime
   resetDate = realtime
@@ -679,6 +877,10 @@ async function initLoadChartData({
   }
   }
 
 
   chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
   chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
+
+  // 切换时间范围、重置图表或重新加载历史数据时,必须清理上一段数据的最近值缓存,
+  // 否则新时间范围的第一条单侧 MQTT 消息可能会和旧时间范围的数据错误组合。
+  latestSuspensionPoints.clear()
   chartLoading.value = true
   chartLoading.value = true
 
 
   try {
   try {
@@ -696,9 +898,33 @@ async function initLoadChartData({
             selectedDate.value[1]
             selectedDate.value[1]
           )
           )
 
 
-          chartData.value[item.name] = (((res as any[]) ?? []) as any[])
-            .sort((a, b) => a.ts - b.ts)
-            .map((item) => ({ ts: item.ts, value: item.value }))
+          if (
+            suspensionWeightAndLightEnabled.value &&
+            String(item.identifier) === SUSPENSION_WEIGHT_IDENTIFIER
+          ) {
+            const lightRes = await getChartData(
+              data.value.deviceCode,
+              SUSPENSION_LIGHT_IDENTIFIER,
+              selectedDate.value[0],
+              selectedDate.value[1]
+            )
+
+            const weightPoints = normalizeHistoryPoints(res)
+            const lightPoints = normalizeHistoryPoints(lightRes)
+            const latestWeightPoint = weightPoints[weightPoints.length - 1]
+            const latestLightPoint = lightPoints[lightPoints.length - 1]
+
+            if (latestWeightPoint) {
+              latestSuspensionPoints.set(SUSPENSION_WEIGHT_IDENTIFIER, latestWeightPoint)
+            }
+            if (latestLightPoint) {
+              latestSuspensionPoints.set(SUSPENSION_LIGHT_IDENTIFIER, latestLightPoint)
+            }
+
+            chartData.value[item.name] = mergeSuspensionHistory(weightPoints, lightPoints)
+          } else {
+            chartData.value[item.name] = normalizeHistoryPoints(res)
+          }
         } catch (error) {
         } catch (error) {
           console.error(error)
           console.error(error)
         } finally {
         } finally {
@@ -739,6 +965,7 @@ async function initPage(load = true) {
 async function resetChart() {
 async function resetChart() {
   chartLoadVersion += 1
   chartLoadVersion += 1
   chartRealtimeMode.value = true
   chartRealtimeMode.value = true
+  chartWindowMinutesDraft.value = chartWindowMinutes.value
   selectedDate.value = getInitialDateRange()
   selectedDate.value = getInitialDateRange()
   await cancelAllRequests()
   await cancelAllRequests()
   destroy()
   destroy()
@@ -753,10 +980,12 @@ function getSelectedDateEnd() {
   return end.isValid() ? end : dayjs()
   return end.isValid() ? end : dayjs()
 }
 }
 
 
-async function handleChartWindowMinutesChange(value?: number) {
+async function handleChartWindowMinutesConfirm() {
   const realtime = chartRealtimeMode.value
   const realtime = chartRealtimeMode.value
+  const normalizedValue = normalizeChartWindowMinutes(chartWindowMinutesDraft.value)
 
 
-  chartWindowMinutes.value = normalizeChartWindowMinutes(value)
+  chartWindowMinutes.value = normalizedValue
+  chartWindowMinutesDraft.value = normalizedValue
   selectedDate.value = getDateRangeByWindow(realtime ? dayjs() : getSelectedDateEnd())
   selectedDate.value = getDateRangeByWindow(realtime ? dayjs() : getSelectedDateEnd())
 
 
   chartLoadVersion += 1
   chartLoadVersion += 1
@@ -805,7 +1034,7 @@ async function exportChart() {
   }
   }
 }
 }
 
 
-const MAX_XLSX_TIME_COLUMNS = 16_383
+const MAX_XLSX_DATA_COLUMNS = 16_383
 
 
 function getSelectedExportDimensions() {
 function getSelectedExportDimensions() {
   return dimensions.value.filter((item) => selectedDimension.value[item.name])
   return dimensions.value.filter((item) => selectedDimension.value[item.name])
@@ -820,18 +1049,24 @@ function buildExportRows(selectedDimensions: Dimensions[]) {
     )
     )
   ).sort((a, b) => a - b)
   ).sort((a, b) => a - b)
 
 
-  const rows: Array<Array<string | number | Date | null>> = [
-    ['属性名称', ...timestamps.map((timestamp) => new Date(timestamp))]
+  // 导出矩阵转置为“每行一个时间点、每列一个属性”:
+  // 第一列是时间,后续列是选中的属性,便于按时间顺序查看和继续处理数据。
+  const valuesByDimension = new Map(
+    selectedDimensions.map((dimension) => [
+      dimension.name,
+      new Map((chartData.value[dimension.name] || []).map((point) => [point.ts, point.value]))
+    ])
+  )
+  const rows: Array<Array<string | number | null>> = [
+    ['时间', ...selectedDimensions.map((dimension) => dimension.name)]
   ]
   ]
 
 
-  selectedDimensions.forEach((dimension) => {
-    const valuesByTimestamp = new Map(
-      (chartData.value[dimension.name] || []).map((point) => [point.ts, point.value])
-    )
-
+  timestamps.forEach((timestamp) => {
     rows.push([
     rows.push([
-      dimension.name,
-      ...timestamps.map((timestamp) => valuesByTimestamp.get(timestamp) ?? null)
+      toExcelDateValue(timestamp),
+      ...selectedDimensions.map(
+        (dimension) => valuesByDimension.get(dimension.name)?.get(timestamp) ?? null
+      )
     ])
     ])
   })
   })
 
 
@@ -853,18 +1088,20 @@ async function exportChartData() {
     return
     return
   }
   }
 
 
-  if (timestamps.length > MAX_XLSX_TIME_COLUMNS) {
-    message.warning(`时间点共 ${timestamps.length} 个,请缩短时间范围后再导出`)
+  if (selectedDimensions.length > MAX_XLSX_DATA_COLUMNS) {
+    message.warning(
+      `属性共 ${selectedDimensions.length} 个,超过 Excel 列数限制,请减少属性后再导出`
+    )
     return
     return
   }
   }
 
 
   exportDataLoading.value = true
   exportDataLoading.value = true
   try {
   try {
-    const worksheet = XLSX.utils.aoa_to_sheet(rows, { cellDates: true })
-    worksheet['!cols'] = [{ wch: 24 }, ...timestamps.map(() => ({ wch: 20 }))]
+    const worksheet = XLSX.utils.aoa_to_sheet(rows)
+    worksheet['!cols'] = [{ wch: 22 }, ...selectedDimensions.map(() => ({ wch: 20 }))]
 
 
     timestamps.forEach((_, index) => {
     timestamps.forEach((_, index) => {
-      const cell = worksheet[XLSX.utils.encode_cell({ r: 0, c: index + 1 })]
+      const cell = worksheet[XLSX.utils.encode_cell({ r: index + 1, c: 0 })]
       if (cell) cell.z = 'yyyy-mm-dd hh:mm:ss'
       if (cell) cell.z = 'yyyy-mm-dd hh:mm:ss'
     })
     })
 
 
@@ -916,8 +1153,8 @@ function openRangeDialog(targetName?: string) {
     name: item.name,
     name: item.name,
     identifier: item.identifier,
     identifier: item.identifier,
     color: item.color,
     color: item.color,
-    minValue: normalizeRangeValue(item.minValue),
-    maxValue: normalizeRangeValue(item.maxValue),
+    periodSecond: normalizeRangeValue(item.periodSecond),
+    changeValue: normalizeRangeValue(item.changeValue),
     axisMin: normalizeRangeValue(item.axisMin),
     axisMin: normalizeRangeValue(item.axisMin),
     axisMax: normalizeRangeValue(item.axisMax)
     axisMax: normalizeRangeValue(item.axisMax)
   }))
   }))
@@ -926,39 +1163,46 @@ function openRangeDialog(targetName?: string) {
 
 
 async function saveRangeSetting(
 async function saveRangeSetting(
   item: Dimensions,
   item: Dimensions,
-  minValue?: number,
-  maxValue?: number,
+  periodSecondValue?: number,
+  changeValueValue?: number,
   axisMinValue?: number,
   axisMinValue?: number,
   axisMaxValue?: number
   axisMaxValue?: number
 ) {
 ) {
-  const min = normalizeRangeValue(minValue)
-  const max = normalizeRangeValue(maxValue)
+  const periodSecond = normalizeRangeValue(periodSecondValue)
+  const changeValue = normalizeRangeValue(changeValueValue)
   const axisMin = normalizeRangeValue(axisMinValue)
   const axisMin = normalizeRangeValue(axisMinValue)
   const axisMax = normalizeRangeValue(axisMaxValue)
   const axisMax = normalizeRangeValue(axisMaxValue)
 
 
-  if (min === undefined && max === undefined && axisMin === undefined && axisMax === undefined) {
+  if (
+    periodSecond === undefined &&
+    changeValue === undefined &&
+    axisMin === undefined &&
+    axisMax === undefined
+  ) {
     if (item.id) {
     if (item.id) {
       await IotDeviceApi.deleteMaxMin({ id: item.id })
       await IotDeviceApi.deleteMaxMin({ id: item.id })
     }
     }
 
 
     item.minValue = undefined
     item.minValue = undefined
     item.maxValue = undefined
     item.maxValue = undefined
+    item.periodSecond = undefined
+    item.changeValue = undefined
     item.axisMin = undefined
     item.axisMin = undefined
     item.axisMax = undefined
     item.axisMax = undefined
     item.id = undefined
     item.id = undefined
     return
     return
   }
   }
 
 
-  if ((min === undefined) !== (max === undefined)) {
-    throw new Error(`${item.name} 的告警上限和告警下限需要同时填写`)
+  if ((periodSecond === undefined) !== (changeValue === undefined)) {
+    throw new Error(`${item.name} 的检查时间和超限范围需要同时填写`)
   }
   }
 
 
-  if (min !== undefined && max !== undefined && min > max) {
-    throw new Error(`${item.name} 的告警下限不能大于告警上限`)
+  if (periodSecond !== undefined && (!Number.isInteger(periodSecond) || periodSecond <= 0)) {
+    throw new Error(`${item.name} 的检查时间必须为大于 0 的整数秒`)
   }
   }
 
 
-  if (min !== undefined && max !== undefined && min === max) {
-    throw new Error(`${item.name} 的告警上限和告警下限不能相等`)
+  if (changeValue !== undefined && changeValue < 0) {
+    throw new Error(`${item.name} 的超限范围不能小于 0`)
   }
   }
 
 
   if ((axisMin === undefined) !== (axisMax === undefined)) {
   if ((axisMin === undefined) !== (axisMax === undefined)) {
@@ -970,8 +1214,10 @@ async function saveRangeSetting(
   }
   }
 
 
   const body = {
   const body = {
-    minValue: min ?? null,
-    maxValue: max ?? null,
+    minValue: null,
+    maxValue: null,
+    periodSecond: periodSecond ?? null,
+    changeValue: changeValue ?? null,
     axisMin: axisMin ?? null,
     axisMin: axisMin ?? null,
     axisMax: axisMax ?? null,
     axisMax: axisMax ?? null,
     deviceId: query.id,
     deviceId: query.id,
@@ -984,8 +1230,10 @@ async function saveRangeSetting(
   const res = await IotDeviceApi.saveMaxMin(body)
   const res = await IotDeviceApi.saveMaxMin(body)
 
 
   if (res.id) item.id = res.id
   if (res.id) item.id = res.id
-  item.minValue = min
-  item.maxValue = max
+  item.minValue = undefined
+  item.maxValue = undefined
+  item.periodSecond = periodSecond
+  item.changeValue = changeValue
   item.axisMin = axisMin
   item.axisMin = axisMin
   item.axisMax = axisMax
   item.axisMax = axisMax
 }
 }
@@ -996,7 +1244,13 @@ async function handleRangeDialogSave() {
       const item = dimensions.value.find((dimension) => dimension.identifier === draft.identifier)
       const item = dimensions.value.find((dimension) => dimension.identifier === draft.identifier)
       if (!item) continue
       if (!item) continue
 
 
-      await saveRangeSetting(item, draft.minValue, draft.maxValue, draft.axisMin, draft.axisMax)
+      await saveRangeSetting(
+        item,
+        draft.periodSecond,
+        draft.changeValue,
+        draft.axisMin,
+        draft.axisMax
+      )
     }
     }
 
 
     updateSeriesByNames(dimensions.value.map((item) => item.name))
     updateSeriesByNames(dimensions.value.map((item) => item.name))
@@ -1010,16 +1264,16 @@ async function handleRangeDialogSave() {
 function handleRangeDialogReset() {
 function handleRangeDialogReset() {
   rangeSettingDrafts.value = rangeSettingDrafts.value.map((item) => ({
   rangeSettingDrafts.value = rangeSettingDrafts.value.map((item) => ({
     ...item,
     ...item,
-    minValue: undefined,
-    maxValue: undefined,
+    periodSecond: undefined,
+    changeValue: undefined,
     axisMin: undefined,
     axisMin: undefined,
     axisMax: undefined
     axisMax: undefined
   }))
   }))
 }
 }
 
 
 function clearRangeDraft(row: RangeSettingDraft) {
 function clearRangeDraft(row: RangeSettingDraft) {
-  row.minValue = undefined
-  row.maxValue = undefined
+  row.periodSecond = undefined
+  row.changeValue = undefined
   row.axisMin = undefined
   row.axisMin = undefined
   row.axisMax = undefined
   row.axisMax = undefined
 }
 }
@@ -1030,6 +1284,7 @@ onMounted(() => {
 
 
 onUnmounted(() => {
 onUnmounted(() => {
   chartLoadVersion += 1
   chartLoadVersion += 1
+  latestSuspensionPoints.clear()
   destroy()
   destroy()
   cancelRealtimeChartUpdate()
   cancelRealtimeChartUpdate()
   window.removeEventListener('resize', resizeChart)
   window.removeEventListener('resize', resizeChart)
@@ -1119,22 +1374,6 @@ onUnmounted(() => {
                     <span v-else class="dimension-card__value">
                     <span v-else class="dimension-card__value">
                       {{ item.value }}
                       {{ item.value }}
                     </span>
                     </span>
-
-                    <span
-                      v-if="item.minValue !== undefined || item.maxValue !== undefined"
-                      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>告警上限</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>告警下限</span>
-                        <strong>{{ formatChartValue(item.minValue) }}</strong>
-                      </span>
-                    </span>
                   </span>
                   </span>
                 </button>
                 </button>
               </div>
               </div>
@@ -1178,17 +1417,24 @@ onUnmounted(() => {
           <div class="chart-window-control">
           <div class="chart-window-control">
             <span>时间区间</span>
             <span>时间区间</span>
             <el-input-number
             <el-input-number
-              v-model="chartWindowMinutes"
+              v-model="chartWindowMinutesDraft"
               :min="MIN_CHART_WINDOW_MINUTES"
               :min="MIN_CHART_WINDOW_MINUTES"
               :max="MAX_CHART_WINDOW_MINUTES"
               :max="MAX_CHART_WINDOW_MINUTES"
               :precision="0"
               :precision="0"
               :step="1"
               :step="1"
               controls-position="right"
               controls-position="right"
               size="default"
               size="default"
-              class="chart-window-control__input"
-              @change="handleChartWindowMinutesChange" />
+              class="chart-window-control__input" />
             <span>分钟</span>
             <span>分钟</span>
           </div>
           </div>
+          <el-button
+            type="primary"
+            plain
+            size="default"
+            :loading="chartLoading"
+            @click="handleChartWindowMinutesConfirm">
+            确定
+          </el-button>
           <el-button
           <el-button
             size="default"
             size="default"
             :type="isFullscreen ? 'info' : 'primary'"
             :type="isFullscreen ? 'info' : 'primary'"
@@ -1229,40 +1475,50 @@ onUnmounted(() => {
 
 
     <el-dialog
     <el-dialog
       v-model="rangeDialogVisible"
       v-model="rangeDialogVisible"
-      title="告警阈值与 Y 轴范围"
-      width="1100px"
+      title="检查时间、超限范围与 Y 轴范围"
+      width="min(1140px, calc(100vw - 32px))"
       append-to-body
       append-to-body
       destroy-on-close>
       destroy-on-close>
-      <ZmTable :data="rangeSettingDrafts" :loading="false" :max-height="520" :show-border="true">
-        <ZmTableColumn label="曲线" min-width="180">
+      <ZmTable
+        :data="rangeSettingDrafts"
+        :loading="false"
+        :max-height="420"
+        :show-border="true"
+        :fit="false">
+        <ZmTableColumn label="曲线" fixed="left" align="left">
           <template #default="{ row }">
           <template #default="{ row }">
             <div class="flex items-center gap-2 min-w-0">
             <div class="flex items-center gap-2 min-w-0">
               <span
               <span
                 class="inline-block size-2.5 rounded-full shrink-0"
                 class="inline-block size-2.5 rounded-full shrink-0"
                 :style="{ backgroundColor: row.color }"></span>
                 :style="{ backgroundColor: row.color }"></span>
-              <span class="truncate font-medium text-slate-700">{{ row.name }}</span>
+              <span class="min-w-0 flex-1 truncate font-medium text-slate-700" :title="row.name">
+                {{ row.name }}
+              </span>
             </div>
             </div>
           </template>
           </template>
         </ZmTableColumn>
         </ZmTableColumn>
-        <ZmTableColumn label="告警下限" width="140">
+        <ZmTableColumn label="检查时间(秒)">
           <template #default="{ row }">
           <template #default="{ row }">
             <el-input-number
             <el-input-number
-              v-model="row.minValue"
+              v-model="row.periodSecond"
               class="!w-full"
               class="!w-full"
               :controls="false"
               :controls="false"
+              :min="1"
+              :precision="0"
               placeholder="未设置" />
               placeholder="未设置" />
           </template>
           </template>
         </ZmTableColumn>
         </ZmTableColumn>
-        <ZmTableColumn label="告警上限" width="140">
+        <ZmTableColumn label="超限范围">
           <template #default="{ row }">
           <template #default="{ row }">
             <el-input-number
             <el-input-number
-              v-model="row.maxValue"
+              v-model="row.changeValue"
               class="!w-full"
               class="!w-full"
               :controls="false"
               :controls="false"
-              placeholder="未设置" />
+              :min="0"
+              placeholder="0" />
           </template>
           </template>
         </ZmTableColumn>
         </ZmTableColumn>
-        <ZmTableColumn label="Y 轴下限" width="140">
+        <ZmTableColumn label="Y 轴下限">
           <template #default="{ row }">
           <template #default="{ row }">
             <el-input-number
             <el-input-number
               v-model="row.axisMin"
               v-model="row.axisMin"
@@ -1271,7 +1527,7 @@ onUnmounted(() => {
               :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
               :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
           </template>
           </template>
         </ZmTableColumn>
         </ZmTableColumn>
-        <ZmTableColumn label="Y 轴上限" width="140">
+        <ZmTableColumn label="Y 轴上限">
           <template #default="{ row }">
           <template #default="{ row }">
             <el-input-number
             <el-input-number
               v-model="row.axisMax"
               v-model="row.axisMax"
@@ -1280,7 +1536,7 @@ onUnmounted(() => {
               :placeholder="formatChartValue(getSeriesRange(row.name).labelMax)" />
               :placeholder="formatChartValue(getSeriesRange(row.name).labelMax)" />
           </template>
           </template>
         </ZmTableColumn>
         </ZmTableColumn>
-        <ZmTableColumn label="当前 Y 轴范围" width="150">
+        <ZmTableColumn label="当前 Y 轴范围">
           <template #default="{ row }">
           <template #default="{ row }">
             <span class="text-xs text-slate-500">
             <span class="text-xs text-slate-500">
               {{ formatChartValue(getSeriesRange(row.name).labelMin) }}
               {{ formatChartValue(getSeriesRange(row.name).labelMin) }}
@@ -1289,7 +1545,7 @@ onUnmounted(() => {
             </span>
             </span>
           </template>
           </template>
         </ZmTableColumn>
         </ZmTableColumn>
-        <ZmTableColumn label="操作" width="90">
+        <ZmTableColumn label="操作">
           <template #default="{ row }">
           <template #default="{ row }">
             <el-button size="small" link type="primary" @click="clearRangeDraft(row)">
             <el-button size="small" link type="primary" @click="clearRangeDraft(row)">
               清空
               清空
@@ -1495,50 +1751,6 @@ onUnmounted(() => {
   white-space: nowrap;
   white-space: nowrap;
 }
 }
 
 
-.dimension-card__ranges {
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-  align-items: flex-end;
-  justify-content: center;
-  max-width: 100%;
-}
-
-.range-pill {
-  display: inline-flex;
-  align-items: center;
-  gap: 3px;
-  height: 20px;
-  padding: 0 6px;
-  font-size: 10px;
-  font-weight: 800;
-  line-height: 1;
-  border: 1px solid;
-  border-radius: 999px;
-}
-
-.range-pill i {
-  width: 12px;
-  height: 12px;
-}
-
-.range-pill strong {
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-  font-size: 11px;
-}
-
-.range-pill--max {
-  color: #047857;
-  background: rgb(236 253 245 / 78%);
-  border-color: rgb(167 243 208 / 86%);
-}
-
-.range-pill--min {
-  color: #be123c;
-  background: rgb(255 241 242 / 78%);
-  border-color: rgb(254 205 211 / 88%);
-}
-
 .chart-panel {
 .chart-panel {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;

+ 20 - 0
src/views/oli-connection/monitoring/excelDate.ts

@@ -0,0 +1,20 @@
+const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000
+const EXCEL_UNIX_EPOCH_OFFSET_DAYS = 25_569
+
+export function toExcelDateValue(timestamp: number) {
+  const date = new Date(timestamp)
+  const localWallClockTimestamp = Date.UTC(
+    date.getFullYear(),
+    date.getMonth(),
+    date.getDate(),
+    date.getHours(),
+    date.getMinutes(),
+    date.getSeconds(),
+    date.getMilliseconds()
+  )
+
+  // xlsx 0.18.5 converts Date cells using the historical timezone offset at
+  // Excel's epoch. Asia/Shanghai therefore gains 43 seconds. Supplying the
+  // numeric Excel serial preserves the local wall-clock time without that path.
+  return localWallClockTimestamp / MILLISECONDS_PER_DAY + EXCEL_UNIX_EPOCH_OFFSET_DAYS
+}

+ 9 - 1
src/views/pms/device/DeviceBasicInfo.vue

@@ -110,6 +110,14 @@ const displayDate = (value?: number | string | Date) => {
   return value ? formatDate(value as Date, 'YYYY-MM-DD') : '-'
   return value ? formatDate(value as Date, 'YYYY-MM-DD') : '-'
 }
 }
 
 
+// 四舍五入保留两位小数,非数字返回原样(交给 displayValue 兜底显示 '-')
+const formatAmount = (value?: string | number | null) => {
+  if (value === null || value === undefined || value === '') return value
+  const num = Number(value)
+  if (Number.isNaN(num)) return value
+  return num.toFixed(2)
+}
+
 const manufactureFields = computed<DisplayField[]>(() => [
 const manufactureFields = computed<DisplayField[]>(() => [
   {
   {
     label: '制造商',
     label: '制造商',
@@ -174,7 +182,7 @@ const financeFields = computed<DisplayField[]>(() => [
   },
   },
   {
   {
     label: '每月折旧金额',
     label: '每月折旧金额',
-    value: displayValue(props.device.monthAmount)
+    value: displayValue(formatAmount(props.device.monthAmount))
   },
   },
   {
   {
     label: '总折旧月份',
     label: '总折旧月份',

+ 75 - 38
src/views/pms/device/IotDeviceForm.vue

@@ -51,7 +51,7 @@ const formType = computed<'create' | 'update'>(() => {
 })
 })
 
 
 const pageTitle = computed(() => {
 const pageTitle = computed(() => {
-  return formType.value === 'update' ? '编辑设备' : '新增设备'
+  return formType.value === 'update' ? t('action.update') : t('action.create')
 })
 })
 
 
 interface Form {
 interface Form {
@@ -448,7 +448,10 @@ onMounted(() => {
                   @change="changeEncodingType" />
                   @change="changeEncodingType" />
               </el-form-item>
               </el-form-item>
               <el-form-item :label="t('iotDevice.yfCode')" prop="yfDeviceCode">
               <el-form-item :label="t('iotDevice.yfCode')" prop="yfDeviceCode">
-                <el-input :placeholder="t('device.iotdevicef293671-3')" disabled v-model="form.yfDeviceCode" />
+                <el-input
+                  :placeholder="t('device.iotdevicef293671-3')"
+                  disabled
+                  v-model="form.yfDeviceCode" />
               </el-form-item>
               </el-form-item>
               <el-form-item :label="t('iotDevice.code')" prop="deviceCode">
               <el-form-item :label="t('iotDevice.code')" prop="deviceCode">
                 <el-input
                 <el-input
@@ -498,7 +501,10 @@ onMounted(() => {
                 </el-select>
                 </el-select>
               </el-form-item>
               </el-form-item>
               <el-form-item :label="t('iotDevice.assets')" prop="assetProperty">
               <el-form-item :label="t('iotDevice.assets')" prop="assetProperty">
-                <el-select v-model="form.assetProperty" :placeholder="t('device.iotdevicef293671-9')" clearable>
+                <el-select
+                  v-model="form.assetProperty"
+                  :placeholder="t('device.iotdevicef293671-9')"
+                  clearable>
                   <el-option
                   <el-option
                     v-for="dict in getStrDictOptions(DICT_TYPE.PMS_ASSET_PROPERTY)"
                     v-for="dict in getStrDictOptions(DICT_TYPE.PMS_ASSET_PROPERTY)"
                     :key="dict.value"
                     :key="dict.value"
@@ -507,7 +513,10 @@ onMounted(() => {
                 </el-select>
                 </el-select>
               </el-form-item>
               </el-form-item>
               <el-form-item :label="t('iotDevice.brand')" prop="brandName">
               <el-form-item :label="t('iotDevice.brand')" prop="brandName">
-                <el-input v-model="form.brandName" readonly :placeholder="t('device.iotdevicef293671-10')">
+                <el-input
+                  v-model="form.brandName"
+                  readonly
+                  :placeholder="t('device.iotdevicef293671-10')">
                   <template #suffix>
                   <template #suffix>
                     <Icon
                     <Icon
                       v-if="form.brandName"
                       v-if="form.brandName"
@@ -517,36 +526,61 @@ onMounted(() => {
                       @click.stop="clearBrand" />
                       @click.stop="clearBrand" />
                   </template>
                   </template>
                   <template #append>
                   <template #append>
-                    <el-button @click="selectBrand">{{ t('device.iotdevicef293671-11') }}</el-button>
+                    <el-button @click="selectBrand">{{
+                      t('device.iotdevicef293671-11')
+                    }}</el-button>
                   </template>
                   </template>
                 </el-input>
                 </el-input>
               </el-form-item>
               </el-form-item>
               <el-form-item :label="t('device.iotdevicef293671-12')" prop="carNo">
               <el-form-item :label="t('device.iotdevicef293671-12')" prop="carNo">
-                <el-input clearable v-model="form.carNo" :placeholder="t('device.iotdevicef293671-13')" />
+                <el-input
+                  clearable
+                  v-model="form.carNo"
+                  :placeholder="t('device.iotdevicef293671-13')" />
               </el-form-item>
               </el-form-item>
               <el-form-item :label="t('device.iotdevicef293671-14')" prop="deviceNo">
               <el-form-item :label="t('device.iotdevicef293671-14')" prop="deviceNo">
-                <el-input clearable v-model="form.deviceNo" :placeholder="t('device.iotdevicef293671-15')" />
+                <el-input
+                  clearable
+                  v-model="form.deviceNo"
+                  :placeholder="t('device.iotdevicef293671-15')" />
               </el-form-item>
               </el-form-item>
               <el-form-item :label="t('deviceForm.model')" prop="model">
               <el-form-item :label="t('deviceForm.model')" prop="model">
-                <el-input clearable v-model="form.model" :placeholder="t('device.iotdevicef293671-16')">
+                <el-input
+                  clearable
+                  v-model="form.model"
+                  :placeholder="t('device.iotdevicef293671-16')">
                   <template #append>
                   <template #append>
-                    <el-button @click="selectModel">{{ t('device.iotdevicef293671-11') }}</el-button>
+                    <el-button @click="selectModel">{{
+                      t('device.iotdevicef293671-11')
+                    }}</el-button>
                   </template>
                   </template>
                 </el-input>
                 </el-input>
               </el-form-item>
               </el-form-item>
               <el-form-item :label="t('deviceForm.useProject')" prop="useProject">
               <el-form-item :label="t('deviceForm.useProject')" prop="useProject">
-                <el-input clearable v-model="form.useProject" :placeholder="t('device.iotdevicef293671-17')" />
+                <el-input
+                  clearable
+                  v-model="form.useProject"
+                  :placeholder="t('device.iotdevicef293671-17')" />
               </el-form-item>
               </el-form-item>
               <el-form-item :label="t('device.iotdevicef293671-18')" prop="assetOwnership">
               <el-form-item :label="t('device.iotdevicef293671-18')" prop="assetOwnership">
-                <el-input clearable v-model="form.assetOwnership" :placeholder="t('device.iotdevicef293671-19')" />
+                <el-input
+                  clearable
+                  v-model="form.assetOwnership"
+                  :placeholder="t('device.iotdevicef293671-19')" />
               </el-form-item>
               </el-form-item>
               <el-form-item :label="t('device.iotdevicef293671-20')" prop="address">
               <el-form-item :label="t('device.iotdevicef293671-20')" prop="address">
-                <el-input clearable v-model="form.address" :placeholder="t('device.iotdevicef293671-21')" />
+                <el-input
+                  clearable
+                  v-model="form.address"
+                  :placeholder="t('device.iotdevicef293671-21')" />
               </el-form-item>
               </el-form-item>
               <el-form-item :label="t('deviceForm.picture')" prop="picUrl" class="image-field">
               <el-form-item :label="t('deviceForm.picture')" prop="picUrl" class="image-field">
                 <UploadImg v-model="form.picUrl" height="94px" width="100%" class="size-full" />
                 <UploadImg v-model="form.picUrl" height="94px" width="100%" class="size-full" />
               </el-form-item>
               </el-form-item>
-              <el-form-item :label="t('device.iotdevicef293671-22')" prop="remark" class="remark-field">
+              <el-form-item
+                :label="t('device.iotdevicef293671-22')"
+                prop="remark"
+                class="remark-field">
                 <el-input
                 <el-input
                   clearable
                   clearable
                   v-model="form.remark"
                   v-model="form.remark"
@@ -592,7 +626,9 @@ onMounted(() => {
                       @click.stop="clearManufacturer" />
                       @click.stop="clearManufacturer" />
                   </template>
                   </template>
                   <template #append>
                   <template #append>
-                    <el-button @click="selectManufacturer">{{ t('device.iotdevicef293671-11') }}</el-button>
+                    <el-button @click="selectManufacturer">{{
+                      t('device.iotdevicef293671-11')
+                    }}</el-button>
                   </template>
                   </template>
                 </el-input>
                 </el-input>
               </el-form-item>
               </el-form-item>
@@ -617,7 +653,9 @@ onMounted(() => {
                       @click.stop="clearSupplier" />
                       @click.stop="clearSupplier" />
                   </template>
                   </template>
                   <template #append>
                   <template #append>
-                    <el-button @click="selectSupplier">{{ t('device.iotdevicef293671-11') }}</el-button>
+                    <el-button @click="selectSupplier">{{
+                      t('device.iotdevicef293671-11')
+                    }}</el-button>
                   </template>
                   </template>
                 </el-input>
                 </el-input>
               </el-form-item>
               </el-form-item>
@@ -667,9 +705,7 @@ onMounted(() => {
 
 
           <div v-show="sectionExpanded.finance" class="info-card__body">
           <div v-show="sectionExpanded.finance" class="info-card__body">
             <div class="form-grid">
             <div class="form-grid">
-              <el-form-item
-                ::label="t('device.iotdevicef293671-26')"
-                prop="plPrice">
+              <el-form-item ::label="t('device.iotdevicef293671-26')" prop="plPrice">
                 <el-input-number
                 <el-input-number
                   clearable
                   clearable
                   :controls="false"
                   :controls="false"
@@ -680,18 +716,14 @@ onMounted(() => {
                   v-model="form.plPrice"
                   v-model="form.plPrice"
                   ::placeholder="t('device.iotdevicef293671-27')" />
                   ::placeholder="t('device.iotdevicef293671-27')" />
               </el-form-item>
               </el-form-item>
-              <el-form-item
-                ::label="t('device.iotdevicef293671-28')"
-                prop="plDate">
+              <el-form-item ::label="t('device.iotdevicef293671-28')" prop="plDate">
                 <el-date-picker
                 <el-date-picker
                   v-model="form.plDate"
                   v-model="form.plDate"
                   type="date"
                   type="date"
                   value-format="x"
                   value-format="x"
                   ::placeholder="t('device.iotdevicef293671-29')" />
                   ::placeholder="t('device.iotdevicef293671-29')" />
               </el-form-item>
               </el-form-item>
-              <el-form-item
-                ::label="t('device.iotdevicef293671-30')"
-                prop="plYear">
+              <el-form-item ::label="t('device.iotdevicef293671-30')" prop="plYear">
                 <el-input-number
                 <el-input-number
                   clearable
                   clearable
                   :controls="false"
                   :controls="false"
@@ -702,18 +734,14 @@ onMounted(() => {
                   v-model="form.plYear"
                   v-model="form.plYear"
                   ::placeholder="t('device.iotdevicef293671-31')" />
                   ::placeholder="t('device.iotdevicef293671-31')" />
               </el-form-item>
               </el-form-item>
-              <el-form-item
-                ::label="t('device.iotdevicef293671-32')"
-                prop="plStartDate">
+              <el-form-item ::label="t('device.iotdevicef293671-32')" prop="plStartDate">
                 <el-date-picker
                 <el-date-picker
                   v-model="form.plStartDate"
                   v-model="form.plStartDate"
                   type="date"
                   type="date"
                   value-format="x"
                   value-format="x"
                   ::placeholder="t('device.iotdevicef293671-33')" />
                   ::placeholder="t('device.iotdevicef293671-33')" />
               </el-form-item>
               </el-form-item>
-              <el-form-item
-                ::label="t('device.iotdevicef293671-34')"
-                prop="plMonthed">
+              <el-form-item ::label="t('device.iotdevicef293671-34')" prop="plMonthed">
                 <el-input
                 <el-input
                   clearable
                   clearable
                   :controls="false"
                   :controls="false"
@@ -724,9 +752,7 @@ onMounted(() => {
                   v-model="form.plMonthed"
                   v-model="form.plMonthed"
                   ::placeholder="t('device.iotdevicef293671-35')" />
                   ::placeholder="t('device.iotdevicef293671-35')" />
               </el-form-item>
               </el-form-item>
-              <el-form-item
-                ::label="t('device.iotdevicef293671-36')"
-                prop="plAmounted">
+              <el-form-item ::label="t('device.iotdevicef293671-36')" prop="plAmounted">
                 <el-input-number
                 <el-input-number
                   clearable
                   clearable
                   :controls="false"
                   :controls="false"
@@ -771,7 +797,10 @@ onMounted(() => {
                   :placeholder="t('device.iotdevicef293671-43')" />
                   :placeholder="t('device.iotdevicef293671-43')" />
               </el-form-item>
               </el-form-item>
               <el-form-item :label="t('device.iotdevicef293671-44')" prop="currency">
               <el-form-item :label="t('device.iotdevicef293671-44')" prop="currency">
-                <el-input clearable v-model="form.currency" :placeholder="t('device.iotdevicef293671-45')" />
+                <el-input
+                  clearable
+                  v-model="form.currency"
+                  :placeholder="t('device.iotdevicef293671-45')" />
               </el-form-item>
               </el-form-item>
             </div>
             </div>
           </div>
           </div>
@@ -848,10 +877,18 @@ onMounted(() => {
     </main>
     </main>
 
 
     <div class="form-action-bar">
     <div class="form-action-bar">
-      <el-button size="default" @click="expandAllSections">{{ t('device.iotdevicef293671-49') }}</el-button>
-      <el-button size="default" @click="collapseAllSections">{{ t('device.iotdevicef293671-50') }}</el-button>
-      <el-button size="default" @click="handleCancel">{{ t('device.iotdevicef293671-51') }}</el-button>
-      <el-button size="default" type="primary" @click="handleSave">{{ t('device.iotdevicef293671-52') }}</el-button>
+      <el-button size="default" @click="expandAllSections">{{
+        t('device.iotdevicef293671-49')
+      }}</el-button>
+      <el-button size="default" @click="collapseAllSections">{{
+        t('device.iotdevicef293671-50')
+      }}</el-button>
+      <el-button size="default" @click="handleCancel">{{
+        t('device.iotdevicef293671-51')
+      }}</el-button>
+      <el-button size="default" type="primary" @click="handleSave">{{
+        t('device.iotdevicef293671-52')
+      }}</el-button>
     </div>
     </div>
   </div>
   </div>
   <brand-list ref="brandRef" @choose="chooseBrand" />
   <brand-list ref="brandRef" @choose="chooseBrand" />

+ 1 - 1
src/views/pms/iotrddailyreport/FillDailyReportForm.vue

@@ -1174,7 +1174,7 @@ const inContent = async (attachment) => {
                     type="textarea"
                     type="textarea"
                     :autosize="{ minRows: 1 }"
                     :autosize="{ minRows: 1 }"
                     show-word-limit
                     show-word-limit
-                    :maxlength="2000"
+                    :maxlength="3000"
                     class="w-full!" />
                     class="w-full!" />
                 </el-form-item>
                 </el-form-item>
               </template>
               </template>

+ 1 - 3
src/views/pms/iotrddailyreport/summary.vue

@@ -4,8 +4,6 @@ import { useI18n } from 'vue-i18n'
 import { IotRdDailyReportApi } from '@/api/pms/iotrddailyreport'
 import { IotRdDailyReportApi } from '@/api/pms/iotrddailyreport'
 import download from '@/utils/download'
 import download from '@/utils/download'
 import { rangeShortcuts } from '@/utils/formatTime'
 import { rangeShortcuts } from '@/utils/formatTime'
-import { resolveDailyReportDeptId } from '@/utils/dailyReportDept'
-import { useUserStore } from '@/store/modules/user'
 import DailyStatistics from './components/DailyStatistics.vue'
 import DailyStatistics from './components/DailyStatistics.vue'
 import NonProductionEfficiency from './components/NonProductionEfficiency.vue'
 import NonProductionEfficiency from './components/NonProductionEfficiency.vue'
 const { t } = useI18n()
 const { t } = useI18n()
@@ -18,7 +16,7 @@ const route = useRoute()
 const pageRouteName = route.name
 const pageRouteName = route.name
 const message = useMessage()
 const message = useMessage()
 const TOP_DEPT_ID = 163
 const TOP_DEPT_ID = 163
-const deptId = resolveDailyReportDeptId(useUserStore().getUser.deptId, TOP_DEPT_ID)
+const deptId = TOP_DEPT_ID
 
 
 interface Query {
 interface Query {
   pageNo: number
   pageNo: number

+ 120 - 28
src/views/pms/iotrhdailyreport/components/DailyStatistics.vue

@@ -262,21 +262,29 @@ const getList = useDebounceFn(async () => {
   }
   }
 }, 500)
 }, 500)
 
 
-const tab = ref<'表格' | '看板'>('表格')
+type ViewTab = '表格' | '日看板' | '月看板'
+type ChartTab = Exclude<ViewTab, '表格'>
 
 
-const currentTab = ref<'表格' | '看板'>('表格')
+const tab = ref<ViewTab>('表格')
+
+const currentTab = ref<ViewTab>('表格')
 
 
 const direction = ref<'left' | 'right'>('right')
 const direction = ref<'left' | 'right'>('right')
 
 
-const handleSelectTab = (val: '表格' | '看板') => {
+const isChartTab = (val: ViewTab): val is ChartTab => val !== '表格'
+
+const handleSelectTab = (val: ViewTab) => {
   if (tab.value === val && currentTab.value === val) return
   if (tab.value === val && currentTab.value === val) return
 
 
   tab.value = val
   tab.value = val
-  direction.value = val === '看板' ? 'right' : 'left'
+  direction.value = val === '表格' ? 'left' : 'right'
   nextTick(() => {
   nextTick(() => {
     currentTab.value = val
     currentTab.value = val
-    setTimeout(() => {
-      if (val === '看板') scheduleChartRender()
+    if (!isChartTab(val)) return
+
+    selectedLegends.value = { ...legendSelections[val] }
+    getChart(val).then(() => {
+      scheduleChartRender()
     })
     })
   })
   })
 }
 }
@@ -284,7 +292,7 @@ const handleSelectTab = (val: '表格' | '看板') => {
 watch(
 watch(
   () => props.defaultView,
   () => props.defaultView,
   (val) => {
   (val) => {
-    handleSelectTab(val || '表格')
+    handleSelectTab(val === '看板' ? '日看板' : '表格')
   },
   },
   { immediate: true }
   { immediate: true }
 )
 )
@@ -301,7 +309,7 @@ type ChartKey =
   | 'cumulativeGasInjection'
   | 'cumulativeGasInjection'
   | 'cumulativePowerConsumption'
   | 'cumulativePowerConsumption'
   | 'cumulativeWaterInjection'
   | 'cumulativeWaterInjection'
-  | 'transitTime'
+  | 'hourUtilizationRate'
   | 'utilizationRate'
   | 'utilizationRate'
 
 
 interface LegendItem {
 interface LegendItem {
@@ -316,10 +324,25 @@ const legendItems: LegendItem[] = [
   { name: '累计注气量 (万方)', key: 'cumulativeGasInjection', unit: '万方', decimals: 2 },
   { name: '累计注气量 (万方)', key: 'cumulativeGasInjection', unit: '万方', decimals: 2 },
   { name: '累计用电量 (KWh)', key: 'cumulativePowerConsumption', unit: 'KWh', decimals: 2 },
   { name: '累计用电量 (KWh)', key: 'cumulativePowerConsumption', unit: 'KWh', decimals: 2 },
   { name: '累计注水量 (方)', key: 'cumulativeWaterInjection', unit: '方', decimals: 2 },
   { name: '累计注水量 (方)', key: 'cumulativeWaterInjection', unit: '方', decimals: 2 },
-  { name: '平均时效 (%)', key: 'transitTime', unit: '%', decimals: 2 },
+  { name: '平均时效 (%)', key: 'hourUtilizationRate', unit: '%', decimals: 2 },
   { name: '设备利用率 (%)', key: 'utilizationRate', unit: '%', decimals: 2 }
   { name: '设备利用率 (%)', key: 'utilizationRate', unit: '%', decimals: 2 }
 ]
 ]
 
 
+const monthDefaultLegendNames = new Set(['累计注气量 (万方)', '平均时效 (%)', '设备利用率 (%)'])
+
+const createLegendSelection = (chartTab: ChartTab) =>
+  Object.fromEntries(
+    legendItems.map(({ name }) => [
+      name,
+      chartTab === '日看板' || monthDefaultLegendNames.has(name)
+    ])
+  )
+
+const legendSelections: Record<ChartTab, Record<string, boolean>> = {
+  日看板: createLegendSelection('日看板'),
+  月看板: createLegendSelection('月看板')
+}
+
 const legendItemMap = legendItems.reduce<Record<string, LegendItem>>((map, item) => {
 const legendItemMap = legendItems.reduce<Record<string, LegendItem>>((map, item) => {
   map[item.name] = item
   map[item.name] = item
   return map
   return map
@@ -330,17 +353,25 @@ const chartData = ref<Record<ChartKey, number[]>>({
   cumulativeGasInjection: [],
   cumulativeGasInjection: [],
   cumulativePowerConsumption: [],
   cumulativePowerConsumption: [],
   cumulativeWaterInjection: [],
   cumulativeWaterInjection: [],
-  transitTime: [],
+  hourUtilizationRate: [],
   utilizationRate: []
   utilizationRate: []
 })
 })
 
 
-let chartLoading = ref(false)
+const chartLoading = ref(false)
+let chartRequestId = 0
 
 
-const getChart = useDebounceFn(async () => {
+const getChart = useDebounceFn(async (chartTab: ChartTab = '日看板') => {
+  const requestId = ++chartRequestId
   chartLoading.value = true
   chartLoading.value = true
 
 
   try {
   try {
-    const res = await IotRhDailyReportApi.getIotRhDailyReportSummaryPolyline(props.query)
+    const getPolyline =
+      chartTab === '月看板'
+        ? IotRhDailyReportApi.getIotRhDailyReportSummaryMonthPolyline
+        : IotRhDailyReportApi.getIotRhDailyReportSummaryPolyline
+    const res = await getPolyline(props.query)
+
+    if (requestId !== chartRequestId) return
 
 
     chartData.value = {
     chartData.value = {
       cumulativeFuelConsumption: res.map((item) => item.cumulativeFuelConsumption || 0),
       cumulativeFuelConsumption: res.map((item) => item.cumulativeFuelConsumption || 0),
@@ -350,14 +381,16 @@ const getChart = useDebounceFn(async () => {
       // cumulativePowerConsumption: res.map((item) => (item.cumulativePowerConsumption || 0) / 1000),
       // cumulativePowerConsumption: res.map((item) => (item.cumulativePowerConsumption || 0) / 1000),
       cumulativeWaterInjection: res.map((item) => item.cumulativeWaterInjection || 0),
       cumulativeWaterInjection: res.map((item) => item.cumulativeWaterInjection || 0),
       // cumulativeWaterInjection: res.map((item) => (item.cumulativeWaterInjection || 0) / 10000),
       // cumulativeWaterInjection: res.map((item) => (item.cumulativeWaterInjection || 0) / 10000),
-      transitTime: res.map((item) => (item.transitTime || 0) * 100),
+      hourUtilizationRate: res.map((item) => (item.hourUtilizationRate || 0) * 100),
       utilizationRate: res.map((item) => Number(((item.utilizationRate || 0) * 100).toFixed(2)))
       utilizationRate: res.map((item) => Number(((item.utilizationRate || 0) * 100).toFixed(2)))
     }
     }
 
 
     xAxisData.value = res.map((item) => item.reportDate || '')
     xAxisData.value = res.map((item) => item.reportDate || '')
     resetVisibleZoomRange()
     resetVisibleZoomRange()
   } finally {
   } finally {
-    chartLoading.value = false
+    if (requestId === chartRequestId) {
+      chartLoading.value = false
+    }
   }
   }
 }, 500)
 }, 500)
 
 
@@ -365,7 +398,7 @@ const resizer = useDebounceFn(() => {
   chart?.resize()
   chart?.resize()
 }, 100)
 }, 100)
 
 
-const selectedLegends = ref<Record<string, boolean>>({})
+const selectedLegends = ref<Record<string, boolean>>({ ...legendSelections['日看板'] })
 const visibleZoomRange = ref({
 const visibleZoomRange = ref({
   startIndex: 0,
   startIndex: 0,
   endIndex: 0
   endIndex: 0
@@ -377,9 +410,11 @@ const NORMALIZED_AXIS_CENTER = 50
 const NORMALIZED_AXIS_PADDING = 4
 const NORMALIZED_AXIS_PADDING = 4
 
 
 const ensureLegendSelection = () => {
 const ensureLegendSelection = () => {
+  const chartTab = isChartTab(currentTab.value) ? currentTab.value : '日看板'
+
   legendItems.forEach(({ name }) => {
   legendItems.forEach(({ name }) => {
     if (selectedLegends.value[name] === undefined) {
     if (selectedLegends.value[name] === undefined) {
-      selectedLegends.value[name] = true
+      selectedLegends.value[name] = legendSelections[chartTab][name]
     }
     }
   })
   })
 }
 }
@@ -485,8 +520,27 @@ const formatSeriesValue = (name: string, value: number) => {
   return `${safeValue.toFixed(item.decimals)} ${item.unit}`
   return `${safeValue.toFixed(item.decimals)} ${item.unit}`
 }
 }
 
 
+const MONTH_LABEL_GAP = 18
+
+const getMonthLabelOffset = (name: string): [number, number] => {
+  const visibleLegendNames = legendItems
+    .filter((item) => selectedLegends.value[item.name] !== false)
+    .map((item) => item.name)
+  const visibleIndex = visibleLegendNames.indexOf(name)
+
+  if (visibleIndex < 0) return [0, 0]
+
+  return [0, (visibleIndex - (visibleLegendNames.length - 1) / 2) * MONTH_LABEL_GAP]
+}
+
+const formatPointLabelValue = (item: LegendItem, dataIndex: number) => {
+  const value = chartData.value[item.key][dataIndex] ?? 0
+  return `${value.toFixed(item.decimals)}${item.unit}`
+}
+
 const getSeries = () => {
 const getSeries = () => {
   const enableSampling = xAxisData.value.length > 120
   const enableSampling = xAxisData.value.length > 120
+  const showPointLabel = currentTab.value === '月看板'
 
 
   return legendItems.map((item) => ({
   return legendItems.map((item) => ({
     name: item.name,
     name: item.name,
@@ -502,6 +556,23 @@ const getSeries = () => {
     emphasis: {
     emphasis: {
       focus: 'series'
       focus: 'series'
     },
     },
+    label: {
+      show: showPointLabel,
+      position: 'top',
+      distance: 8,
+      formatter: (params: any) => formatPointLabelValue(item, params.dataIndex),
+      fontSize: 13,
+      backgroundColor: 'rgba(255, 255, 255, 0.82)',
+      borderRadius: 2,
+      padding: [2, 4]
+    },
+    labelLayout: showPointLabel
+      ? {
+          dy: getMonthLabelOffset(item.name)[1],
+          moveOverlap: 'shiftY',
+          hideOverlap: false
+        }
+      : undefined,
     sampling: enableSampling ? 'lttb' : undefined,
     sampling: enableSampling ? 'lttb' : undefined,
     progressive: 300,
     progressive: 300,
     progressiveThreshold: 1500,
     progressiveThreshold: 1500,
@@ -614,6 +685,17 @@ const initChart = () => {
   chart.off('legendselectchanged')
   chart.off('legendselectchanged')
   chart.on('legendselectchanged', (params: any) => {
   chart.on('legendselectchanged', (params: any) => {
     selectedLegends.value = { ...params.selected }
     selectedLegends.value = { ...params.selected }
+
+    if (isChartTab(currentTab.value)) {
+      legendSelections[currentTab.value] = { ...params.selected }
+    }
+
+    chart?.setOption(
+      {
+        series: getSeries()
+      },
+      { lazyUpdate: true }
+    )
   })
   })
 
 
   chart.off('datazoom')
   chart.off('datazoom')
@@ -644,7 +726,7 @@ const scheduleChartRender = (attempt = 0) => {
   chartRenderRaf = requestAnimationFrame(() => {
   chartRenderRaf = requestAnimationFrame(() => {
     chartRenderRaf = 0
     chartRenderRaf = 0
 
 
-    if (currentTab.value !== '看板') return
+    if (!isChartTab(currentTab.value)) return
 
 
     const chartEl = chartRef.value
     const chartEl = chartRef.value
 
 
@@ -669,8 +751,10 @@ onUnmounted(() => {
 })
 })
 
 
 const handleQuery = () => {
 const handleQuery = () => {
-  getChart().then(() => {
-    if (currentTab.value === '看板') {
+  const chartTab = currentTab.value === '月看板' ? '月看板' : '日看板'
+
+  getChart(chartTab).then(() => {
+    if (isChartTab(currentTab.value)) {
       scheduleChartRender()
       scheduleChartRender()
     }
     }
   })
   })
@@ -720,7 +804,7 @@ const exportChart = () => {
     let event = new MouseEvent('click')
     let event = new MouseEvent('click')
 
 
     a.href = dataURL
     a.href = dataURL
-    a.download = `瑞恒日报统计数据.png`
+    a.download = `瑞恒${currentTab.value}统计数据.png`
     a.dispatchEvent(event)
     a.dispatchEvent(event)
   }
   }
 }
 }
@@ -732,7 +816,7 @@ const exportData = async () => {
 }
 }
 
 
 const exportAll = async () => {
 const exportAll = async () => {
-  if (tab.value === '看板') exportChart()
+  if (isChartTab(tab.value)) exportChart()
   else exportData()
   else exportData()
 }
 }
 
 
@@ -770,8 +854,10 @@ const { ZmTable, ZmTableColumn } = useTableComponents()
 </script>
 </script>
 
 
 <template>
 <template>
-  <div class="grid grid-rows-[96px_1fr] gap-2 h-full min-h-0">
-    <div class="grid grid-cols-8 gap-4">
+  <div
+    class="grid gap-2 h-full min-h-0"
+    :class="tab === '月看板' ? 'grid-rows-[1fr]' : 'grid-rows-[96px_1fr]'">
+    <div v-if="tab !== '月看板'" class="grid grid-cols-8 gap-4">
       <template v-for="info in totalWorkKeys" :key="info[0]">
       <template v-for="info in totalWorkKeys" :key="info[0]">
         <el-tooltip :disabled="info[0] !== 'totalGasInjection'" placement="top">
         <el-tooltip :disabled="info[0] !== 'totalGasInjection'" placement="top">
           <template #content>
           <template #content>
@@ -824,10 +910,16 @@ const { ZmTable, ZmTableColumn } = useTableComponents()
           >
           >
           <el-button
           <el-button
             size="default"
             size="default"
-            :type="tab === '看板' ? 'primary' : 'default'"
-            @click="handleSelectTab('看板')"
-            >{{ t('iotrhdailyreport.dailystati238689-2') }}</el-button
-          >
+            :type="tab === '日看板' ? 'primary' : 'default'"
+            @click="handleSelectTab('日看板')"
+            >日看板
+          </el-button>
+          <el-button
+            size="default"
+            :type="tab === '月看板' ? 'primary' : 'default'"
+            @click="handleSelectTab('月看板')"
+            >月看板
+          </el-button>
         </el-button-group>
         </el-button-group>
         <h3 class="text-xl font-medium">{{ `${props.deptName}-${tab}` }}</h3>
         <h3 class="text-xl font-medium">{{ `${props.deptName}-${tab}` }}</h3>
         <el-button size="default" type="primary" @click="exportAll">{{
         <el-button size="default" type="primary" @click="exportAll">{{

+ 1 - 1
src/views/pms/qhse/case/index.vue

@@ -415,7 +415,7 @@ const resetQuery = () => {
 
 
 const openForm = async (type: 'create' | 'update', id?: number) => {
 const openForm = async (type: 'create' | 'update', id?: number) => {
   formType.value = type
   formType.value = type
-  dialogTitle.value = type === 'create' ? '新增事故案例' : '编辑事故案例'
+  dialogTitle.value = type === 'create' ? t('action.add') : t('action.update')
   if (type === 'create') {
   if (type === 'create') {
     formData.value = createDefaultForm()
     formData.value = createDefaultForm()
     dialogVisible.value = true
     dialogVisible.value = true

+ 71 - 21
src/views/pms/qhse/certificate.vue

@@ -88,7 +88,10 @@
       ref="queryFormRef"
       ref="queryFormRef"
       :inline="true">
       :inline="true">
       <el-form-item :label="t('qhse.certificat865417-3')" prop="type">
       <el-form-item :label="t('qhse.certificat865417-3')" prop="type">
-        <el-select v-model="queryParams.type" :placeholder="t('qhse.certificat865417-4')" style="width: 150px">
+        <el-select
+          v-model="queryParams.type"
+          :placeholder="t('qhse.certificat865417-4')"
+          style="width: 150px">
           <el-option :label="t('qhse.certificat865417-2')" value="personal" />
           <el-option :label="t('qhse.certificat865417-2')" value="personal" />
           <!-- <el-option label="组织证书" value="organization" /> -->
           <!-- <el-option label="组织证书" value="organization" /> -->
           <el-option :label="t('qhse.certificat865417-5')" value="other" />
           <el-option :label="t('qhse.certificat865417-5')" value="other" />
@@ -96,7 +99,11 @@
       </el-form-item>
       </el-form-item>
 
 
       <el-form-item :label="t('qhse.certificat865417-6')" prop="classify">
       <el-form-item :label="t('qhse.certificat865417-6')" prop="classify">
-        <el-select v-model="queryParams.classify" :placeholder="t('qhse.certificat865417-6')" clearable class="!w-150px">
+        <el-select
+          v-model="queryParams.classify"
+          :placeholder="t('qhse.certificat865417-6')"
+          clearable
+          class="!w-150px">
           <el-option
           <el-option
             v-for="dict in getStrDictOptions(DICT_TYPE.PERSON_CERT)"
             v-for="dict in getStrDictOptions(DICT_TYPE.PERSON_CERT)"
             :key="dict.value"
             :key="dict.value"
@@ -178,7 +185,11 @@
                 </template>
                 </template>
               </zm-table-column>
               </zm-table-column>
 
 
-              <zm-table-column :label="t('qhse.certificat865417-6')" align="center" width="150" prop="classify">
+              <zm-table-column
+                :label="t('qhse.certificat865417-6')"
+                align="center"
+                width="150"
+                prop="classify">
                 <template #default="scope">
                 <template #default="scope">
                   <dict-tag
                   <dict-tag
                     v-if="scope.row.type === 'organization'"
                     v-if="scope.row.type === 'organization'"
@@ -200,14 +211,29 @@
                 </template>
                 </template>
               </zm-table-column>
               </zm-table-column>
 
 
-              <zm-table-column :label="t('qhse.certificat865417-7')" align="center" prop="userName" />
-              <zm-table-column :label="t('qhse.certificat865417-17')" align="center" prop="deptName" />
+              <zm-table-column
+                :label="t('qhse.certificat865417-7')"
+                align="center"
+                prop="userName" />
+              <zm-table-column
+                :label="t('qhse.certificat865417-17')"
+                align="center"
+                prop="deptName" />
 
 
-              <zm-table-column :label="t('qhse.certificat865417-18')" align="center" prop="certOrg" />
+              <zm-table-column
+                :label="t('qhse.certificat865417-18')"
+                align="center"
+                prop="certOrg" />
 
 
-              <zm-table-column :label="t('qhse.certificat865417-19')" align="center" prop="certStandard" />
+              <zm-table-column
+                :label="t('qhse.certificat865417-19')"
+                align="center"
+                prop="certStandard" />
 
 
-              <zm-table-column :label="t('qhse.certificat865417-20')" align="center" prop="certIssue">
+              <zm-table-column
+                :label="t('qhse.certificat865417-20')"
+                align="center"
+                prop="certIssue">
                 <template #default="scope">
                 <template #default="scope">
                   {{ formatDateCorrectly(scope.row.certIssue) }}
                   {{ formatDateCorrectly(scope.row.certIssue) }}
                 </template>
                 </template>
@@ -223,7 +249,10 @@
                 <template #default="scope"> {{ scope.row.noticeBefore }}天前提醒 </template>
                 <template #default="scope"> {{ scope.row.noticeBefore }}天前提醒 </template>
               </zm-table-column> -->
               </zm-table-column> -->
 
 
-              <zm-table-column :label="t('qhse.certificat865417-22')" align="center" prop="remark" />
+              <zm-table-column
+                :label="t('qhse.certificat865417-22')"
+                align="center"
+                prop="remark" />
               <zm-table-column
               <zm-table-column
                 :label="t('devicePerson.operation')"
                 :label="t('devicePerson.operation')"
                 align="center"
                 align="center"
@@ -231,13 +260,19 @@
                 min-width="180px"
                 min-width="180px"
                 action>
                 action>
                 <template #default="scope">
                 <template #default="scope">
-                  <el-button link type="primary" @click="handleEdit(scope.row)">{{ t('qhse.certificat865417-23') }}</el-button>
-                  <el-button link type="danger" @click="handleDelete(scope.row.id)">{{ t('qhse.certificat865417-24') }}</el-button>
+                  <el-button link type="primary" @click="handleEdit(scope.row)">{{
+                    t('qhse.certificat865417-23')
+                  }}</el-button>
+                  <el-button link type="danger" @click="handleDelete(scope.row.id)">{{
+                    t('qhse.certificat865417-24')
+                  }}</el-button>
                   <el-button
                   <el-button
                     link
                     link
                     type="success"
                     type="success"
                     v-if="scope.row.certPic"
                     v-if="scope.row.certPic"
-                    @click="viewFile(scope.row.certPic)">{{ t('qhse.certificat865417-25') }}</el-button>
+                    @click="viewFile(scope.row.certPic)"
+                    >{{ t('qhse.certificat865417-25') }}</el-button
+                  >
                 </template>
                 </template>
               </zm-table-column>
               </zm-table-column>
             </zm-table>
             </zm-table>
@@ -294,8 +329,14 @@
       </el-form-item>
       </el-form-item>
 
 
       <span class="absolute left-8 text-red" v-if="formData.type !== 'other'">*</span>
       <span class="absolute left-8 text-red" v-if="formData.type !== 'other'">*</span>
-      <el-form-item :label="t('qhse.certificat865417-6')" prop="classify" v-show="formData.type !== 'other'">
-        <el-select v-model="formData.classify" :placeholder="t('qhse.certificat865417-6')" clearable>
+      <el-form-item
+        :label="t('qhse.certificat865417-6')"
+        prop="classify"
+        v-show="formData.type !== 'other'">
+        <el-select
+          v-model="formData.classify"
+          :placeholder="t('qhse.certificat865417-6')"
+          clearable>
           <el-option
           <el-option
             v-for="dict in getStrDictOptions(DICT_TYPE.PERSON_CERT)"
             v-for="dict in getStrDictOptions(DICT_TYPE.PERSON_CERT)"
             :key="dict.value"
             :key="dict.value"
@@ -309,7 +350,10 @@
       </el-form-item>
       </el-form-item>
 
 
       <el-form-item :label="t('qhse.certificat865417-16')" prop="trainCycle">
       <el-form-item :label="t('qhse.certificat865417-16')" prop="trainCycle">
-        <el-select v-model="formData.trainCycle" :placeholder="t('qhse.certificat865417-16')" clearable>
+        <el-select
+          v-model="formData.trainCycle"
+          :placeholder="t('qhse.certificat865417-16')"
+          clearable>
           <el-option
           <el-option
             v-for="dict in getStrDictOptions(DICT_TYPE.QHSE_TRAIN_CYCLE)"
             v-for="dict in getStrDictOptions(DICT_TYPE.QHSE_TRAIN_CYCLE)"
             :key="dict.value"
             :key="dict.value"
@@ -391,7 +435,9 @@
 
 
     <template #footer>
     <template #footer>
       <el-button @click="closeDialog">{{ t('qhse.certificat865417-36') }}</el-button>
       <el-button @click="closeDialog">{{ t('qhse.certificat865417-36') }}</el-button>
-      <el-button type="primary" @click="submitForm" :loading="submitLoading">{{ t('qhse.certificat865417-37') }}</el-button>
+      <el-button type="primary" @click="submitForm" :loading="submitLoading">{{
+        t('qhse.certificat865417-37')
+      }}</el-button>
     </template>
     </template>
   </Dialog>
   </Dialog>
 
 
@@ -403,15 +449,19 @@
       <span class="file-name-text flex-1 min-w-0">{{ extractFileName(file) }}</span>
       <span class="file-name-text flex-1 min-w-0">{{ extractFileName(file) }}</span>
       <div class="flex shrink-0 items-center gap-5 flex-nowrap">
       <div class="flex shrink-0 items-center gap-5 flex-nowrap">
         <el-button link type="primary" @click="viewFileInfo(file)">
         <el-button link type="primary" @click="viewFileInfo(file)">
-          <Icon icon="ep:view" class="mr-2px" />{{ t('qhse.certificat865417-39') }}</el-button>
+          <Icon icon="ep:view" class="mr-2px" />{{ t('qhse.certificat865417-39') }}</el-button
+        >
         <el-button link type="primary" @click="handleDownload(file)">
         <el-button link type="primary" @click="handleDownload(file)">
-          <Icon icon="ep:download" class="mr-2px" />{{ t('qhse.certificat865417-40') }}</el-button>
+          <Icon icon="ep:download" class="mr-2px" />{{ t('qhse.certificat865417-40') }}</el-button
+        >
       </div>
       </div>
     </div>
     </div>
 
 
     <template #footer>
     <template #footer>
       <div class="dialog-footer mt-10">
       <div class="dialog-footer mt-10">
-        <el-button type="primary" @click="dialogFileView = false">{{ t('qhse.certificat865417-41') }}</el-button>
+        <el-button type="primary" @click="dialogFileView = false">{{
+          t('qhse.certificat865417-41')
+        }}</el-button>
       </div>
       </div>
     </template>
     </template>
   </Dialog>
   </Dialog>
@@ -618,7 +668,7 @@ const resetQuery = () => {
 // 显示新增对话框
 // 显示新增对话框
 const handleAdd = () => {
 const handleAdd = () => {
   isEdit.value = false
   isEdit.value = false
-  dialogTitle.value = '新增证书'
+  dialogTitle.value = t('action.add')
   resetForm()
   resetForm()
   dialogVisible.value = true
   dialogVisible.value = true
 }
 }
@@ -626,7 +676,7 @@ const handleAdd = () => {
 // 显示编辑对话框
 // 显示编辑对话框
 const handleEdit = (row) => {
 const handleEdit = (row) => {
   isEdit.value = true
   isEdit.value = true
-  dialogTitle.value = '编辑证书'
+  dialogTitle.value = t('action.edit')
 
 
   formData.value = {
   formData.value = {
     ...row,
     ...row,

+ 39 - 13
src/views/pms/qhse/credential/index.vue

@@ -68,7 +68,11 @@
       ref="queryFormRef"
       ref="queryFormRef"
       :inline="true">
       :inline="true">
       <el-form-item :label="t('qhse.indexvue451736-2')" prop="classify">
       <el-form-item :label="t('qhse.indexvue451736-2')" prop="classify">
-        <el-select v-model="queryParams.classify" :placeholder="t('qhse.indexvue451736-2')" clearable class="!w-150px">
+        <el-select
+          v-model="queryParams.classify"
+          :placeholder="t('qhse.indexvue451736-2')"
+          clearable
+          class="!w-150px">
           <el-option
           <el-option
             v-for="dict in getStrDictOptions(DICT_TYPE.ORG_CERT)"
             v-for="dict in getStrDictOptions(DICT_TYPE.ORG_CERT)"
             :key="dict.value"
             :key="dict.value"
@@ -145,7 +149,11 @@
                 </template>
                 </template>
               </zm-table-column> -->
               </zm-table-column> -->
 
 
-              <zm-table-column :label="t('qhse.indexvue451736-2')" align="center" width="150" prop="classify">
+              <zm-table-column
+                :label="t('qhse.indexvue451736-2')"
+                align="center"
+                width="150"
+                prop="classify">
                 <template #default="scope">
                 <template #default="scope">
                   <dict-tag :type="DICT_TYPE.ORG_CERT" :value="scope.row.classify" />
                   <dict-tag :type="DICT_TYPE.ORG_CERT" :value="scope.row.classify" />
                 </template>
                 </template>
@@ -156,11 +164,17 @@
                 prop="certName"
                 prop="certName"
                 show-overflow-tooltip />
                 show-overflow-tooltip />
 
 
-              <zm-table-column :label="t('qhse.indexvue451736-10')" align="center" prop="deptName" />
+              <zm-table-column
+                :label="t('qhse.indexvue451736-10')"
+                align="center"
+                prop="deptName" />
 
 
               <zm-table-column :label="t('qhse.indexvue451736-11')" align="center" prop="certOrg" />
               <zm-table-column :label="t('qhse.indexvue451736-11')" align="center" prop="certOrg" />
 
 
-              <zm-table-column :label="t('qhse.indexvue451736-12')" align="center" prop="certStandard" />
+              <zm-table-column
+                :label="t('qhse.indexvue451736-12')"
+                align="center"
+                prop="certStandard" />
 
 
               <zm-table-column :label="t('qhse.indexvue451736-13')" align="center" prop="certIssue">
               <zm-table-column :label="t('qhse.indexvue451736-13')" align="center" prop="certIssue">
                 <template #default="scope">
                 <template #default="scope">
@@ -186,13 +200,19 @@
                 min-width="180px"
                 min-width="180px"
                 action>
                 action>
                 <template #default="scope">
                 <template #default="scope">
-                  <el-button link type="primary" @click="handleEdit(scope.row)">{{ t('qhse.indexvue451736-16') }}</el-button>
-                  <el-button link type="danger" @click="handleDelete(scope.row.id)">{{ t('qhse.indexvue451736-17') }}</el-button>
+                  <el-button link type="primary" @click="handleEdit(scope.row)">{{
+                    t('qhse.indexvue451736-16')
+                  }}</el-button>
+                  <el-button link type="danger" @click="handleDelete(scope.row.id)">{{
+                    t('qhse.indexvue451736-17')
+                  }}</el-button>
                   <el-button
                   <el-button
                     link
                     link
                     type="success"
                     type="success"
                     v-if="scope.row.certPic"
                     v-if="scope.row.certPic"
-                    @click="viewFile(scope.row.certPic)">{{ t('qhse.indexvue451736-18') }}</el-button>
+                    @click="viewFile(scope.row.certPic)"
+                    >{{ t('qhse.indexvue451736-18') }}</el-button
+                  >
                 </template>
                 </template>
               </zm-table-column>
               </zm-table-column>
             </zm-table>
             </zm-table>
@@ -320,7 +340,9 @@
 
 
     <template #footer>
     <template #footer>
       <el-button @click="closeDialog">{{ t('qhse.indexvue451736-28') }}</el-button>
       <el-button @click="closeDialog">{{ t('qhse.indexvue451736-28') }}</el-button>
-      <el-button type="primary" @click="submitForm" :loading="submitLoading">{{ t('qhse.indexvue451736-29') }}</el-button>
+      <el-button type="primary" @click="submitForm" :loading="submitLoading">{{
+        t('qhse.indexvue451736-29')
+      }}</el-button>
     </template>
     </template>
   </Dialog>
   </Dialog>
 
 
@@ -332,15 +354,19 @@
       <span class="file-name-text flex-1 min-w-0">{{ extractFileName(file) }}</span>
       <span class="file-name-text flex-1 min-w-0">{{ extractFileName(file) }}</span>
       <div class="flex shrink-0 items-center gap-5 flex-nowrap">
       <div class="flex shrink-0 items-center gap-5 flex-nowrap">
         <el-button link type="primary" @click="viewFileInfo(file)">
         <el-button link type="primary" @click="viewFileInfo(file)">
-          <Icon icon="ep:view" class="mr-2px" />{{ t('qhse.indexvue451736-31') }}</el-button>
+          <Icon icon="ep:view" class="mr-2px" />{{ t('qhse.indexvue451736-31') }}</el-button
+        >
         <el-button link type="primary" @click="handleDownload(file)">
         <el-button link type="primary" @click="handleDownload(file)">
-          <Icon icon="ep:download" class="mr-2px" />{{ t('qhse.indexvue451736-32') }}</el-button>
+          <Icon icon="ep:download" class="mr-2px" />{{ t('qhse.indexvue451736-32') }}</el-button
+        >
       </div>
       </div>
     </div>
     </div>
 
 
     <template #footer>
     <template #footer>
       <div class="dialog-footer mt-10">
       <div class="dialog-footer mt-10">
-        <el-button type="primary" @click="dialogFileView = false">{{ t('qhse.indexvue451736-33') }}</el-button>
+        <el-button type="primary" @click="dialogFileView = false">{{
+          t('qhse.indexvue451736-33')
+        }}</el-button>
       </div>
       </div>
     </template>
     </template>
   </Dialog>
   </Dialog>
@@ -516,7 +542,7 @@ const resetQuery = () => {
 // 显示新增对话框
 // 显示新增对话框
 const handleAdd = () => {
 const handleAdd = () => {
   isEdit.value = false
   isEdit.value = false
-  dialogTitle.value = '新增证书'
+  dialogTitle.value = t('action.add')
   resetForm()
   resetForm()
   dialogVisible.value = true
   dialogVisible.value = true
 }
 }
@@ -524,7 +550,7 @@ const handleAdd = () => {
 // 显示编辑对话框
 // 显示编辑对话框
 const handleEdit = (row) => {
 const handleEdit = (row) => {
   isEdit.value = true
   isEdit.value = true
-  dialogTitle.value = '编辑证书'
+  dialogTitle.value = t('action.edit')
 
 
   formData.value = {
   formData.value = {
     ...row,
     ...row,

+ 109 - 27
src/views/pms/qhse/factor/index.vue

@@ -31,14 +31,17 @@
           </el-form-item>
           </el-form-item>
           <el-form-item>
           <el-form-item>
             <el-button @click="handleQuery">
             <el-button @click="handleQuery">
-              <Icon icon="ep:search" class="mr-5px" />{{ t('qhse.indexvue239376-6') }}</el-button>
+              <Icon icon="ep:search" class="mr-5px" />{{ t('qhse.indexvue239376-6') }}</el-button
+            >
             <el-button @click="resetQuery">
             <el-button @click="resetQuery">
-              <Icon icon="ep:refresh" class="mr-5px" />{{ t('qhse.indexvue239376-7') }}</el-button>
+              <Icon icon="ep:refresh" class="mr-5px" />{{ t('qhse.indexvue239376-7') }}</el-button
+            >
             <el-button type="primary" @click="openAddDialog" color="#626aef"
             <el-button type="primary" @click="openAddDialog" color="#626aef"
               ><Icon icon="ep:plus" class="mr-5px" />{{ t('qhse.indexvue239376-8') }}</el-button
               ><Icon icon="ep:plus" class="mr-5px" />{{ t('qhse.indexvue239376-8') }}</el-button
             >
             >
             <el-button type="success" plain @click="handleExport" :loading="exportLoading">
             <el-button type="success" plain @click="handleExport" :loading="exportLoading">
-              <Icon icon="ep:download" class="mr-5px" />{{ t('qhse.indexvue239376-9') }}</el-button>
+              <Icon icon="ep:download" class="mr-5px" />{{ t('qhse.indexvue239376-9') }}</el-button
+            >
           </el-form-item>
           </el-form-item>
         </el-form>
         </el-form>
       </ContentWrap>
       </ContentWrap>
@@ -46,7 +49,11 @@
       <!-- 表格 -->
       <!-- 表格 -->
       <ContentWrap style="border: 0">
       <ContentWrap style="border: 0">
         <zm-table :data="tableData" height="66vh">
         <zm-table :data="tableData" height="66vh">
-          <zm-table-column :label="t('qhse.indexvue239376-10')" width="70" align="center" fixed="left">
+          <zm-table-column
+            :label="t('qhse.indexvue239376-10')"
+            width="70"
+            align="center"
+            fixed="left">
             <template #default="scope">
             <template #default="scope">
               {{ scope.$index + 1 }}
               {{ scope.$index + 1 }}
             </template>
             </template>
@@ -57,11 +64,22 @@
             align="center"
             align="center"
             fixed="left"
             fixed="left"
             min-width="150" />
             min-width="150" />
-          <zm-table-column :label="t('qhse.indexvue239376-2')" min-width="150" prop="stepBreak" align="center" />
-          <zm-table-column :label="t('qhse.indexvue239376-4')" prop="environmentElement" align="center" />
+          <zm-table-column
+            :label="t('qhse.indexvue239376-2')"
+            min-width="150"
+            prop="stepBreak"
+            align="center" />
+          <zm-table-column
+            :label="t('qhse.indexvue239376-4')"
+            prop="environmentElement"
+            align="center" />
 
 
           <zm-table-column :label="t('qhse.indexvue239376-11')" align="center">
           <zm-table-column :label="t('qhse.indexvue239376-11')" align="center">
-            <zm-table-column :label="t('qhse.indexvue239376-12')" prop="timeBefore" width="60" align="center">
+            <zm-table-column
+              :label="t('qhse.indexvue239376-12')"
+              prop="timeBefore"
+              width="60"
+              align="center">
               <template #default="{ row }">
               <template #default="{ row }">
                 <el-button circle type="success" style="border: none" plain v-if="row.timeBefore">
                 <el-button circle type="success" style="border: none" plain v-if="row.timeBefore">
                   <span class="text-[#259644]">
                   <span class="text-[#259644]">
@@ -71,7 +89,11 @@
                 <span v-else></span>
                 <span v-else></span>
               </template>
               </template>
             </zm-table-column>
             </zm-table-column>
-            <zm-table-column :label="t('qhse.indexvue239376-13')" prop="timeNow" width="60" align="center">
+            <zm-table-column
+              :label="t('qhse.indexvue239376-13')"
+              prop="timeNow"
+              width="60"
+              align="center">
               <template #default="{ row }">
               <template #default="{ row }">
                 <el-button circle type="success" style="border: none" plain v-if="row.timeNow">
                 <el-button circle type="success" style="border: none" plain v-if="row.timeNow">
                   <span class="text-[#259644]">
                   <span class="text-[#259644]">
@@ -81,7 +103,11 @@
                 <span v-else></span>
                 <span v-else></span>
               </template>
               </template>
             </zm-table-column>
             </zm-table-column>
-            <zm-table-column :label="t('qhse.indexvue239376-14')" prop="timeFuture" width="60" align="center">
+            <zm-table-column
+              :label="t('qhse.indexvue239376-14')"
+              prop="timeFuture"
+              width="60"
+              align="center">
               <template #default="{ row }">
               <template #default="{ row }">
                 <el-button circle type="success" style="border: none" plain v-if="row.timeFuture">
                 <el-button circle type="success" style="border: none" plain v-if="row.timeFuture">
                   <span class="text-[#259644]">
                   <span class="text-[#259644]">
@@ -94,7 +120,11 @@
           </zm-table-column>
           </zm-table-column>
 
 
           <zm-table-column :label="t('qhse.indexvue239376-15')" align="center">
           <zm-table-column :label="t('qhse.indexvue239376-15')" align="center">
-            <zm-table-column :label="t('qhse.indexvue239376-16')" prop="statusNormal" width="60" align="center">
+            <zm-table-column
+              :label="t('qhse.indexvue239376-16')"
+              prop="statusNormal"
+              width="60"
+              align="center">
               <template #default="{ row }">
               <template #default="{ row }">
                 <el-button circle type="success" style="border: none" plain v-if="row.statusNormal">
                 <el-button circle type="success" style="border: none" plain v-if="row.statusNormal">
                   <span class="text-[#259644]">
                   <span class="text-[#259644]">
@@ -104,7 +134,11 @@
                 <span v-else></span>
                 <span v-else></span>
               </template>
               </template>
             </zm-table-column>
             </zm-table-column>
-            <zm-table-column :label="t('qhse.indexvue239376-17')" prop="statusException" width="60" align="center">
+            <zm-table-column
+              :label="t('qhse.indexvue239376-17')"
+              prop="statusException"
+              width="60"
+              align="center">
               <template #default="{ row }">
               <template #default="{ row }">
                 <el-button
                 <el-button
                   circle
                   circle
@@ -119,7 +153,11 @@
                 <span v-else></span>
                 <span v-else></span>
               </template>
               </template>
             </zm-table-column>
             </zm-table-column>
-            <zm-table-column :label="t('qhse.indexvue239376-18')" prop="statusDanger" width="60" align="center">
+            <zm-table-column
+              :label="t('qhse.indexvue239376-18')"
+              prop="statusDanger"
+              width="60"
+              align="center">
               <template #default="{ row }">
               <template #default="{ row }">
                 <el-button circle type="success" style="border: none" plain v-if="row.statusDanger">
                 <el-button circle type="success" style="border: none" plain v-if="row.statusDanger">
                   <span class="text-[#259644]">
                   <span class="text-[#259644]">
@@ -146,7 +184,11 @@
                 <span v-else></span>
                 <span v-else></span>
               </template>
               </template>
             </zm-table-column>
             </zm-table-column>
-            <zm-table-column :label="t('qhse.indexvue239376-21')" prop="typeWater" width="60" align="center">
+            <zm-table-column
+              :label="t('qhse.indexvue239376-21')"
+              prop="typeWater"
+              width="60"
+              align="center">
               <template #default="{ row }">
               <template #default="{ row }">
                 <!-- <span>
                 <!-- <span>
                 {{ row.typeWater ? '✔' : '' }}
                 {{ row.typeWater ? '✔' : '' }}
@@ -159,7 +201,11 @@
                 <span v-else></span>
                 <span v-else></span>
               </template>
               </template>
             </zm-table-column>
             </zm-table-column>
-            <zm-table-column :label="t('qhse.indexvue239376-22')" prop="typeGas" width="60" align="center">
+            <zm-table-column
+              :label="t('qhse.indexvue239376-22')"
+              prop="typeGas"
+              width="60"
+              align="center">
               <template #default="{ row }">
               <template #default="{ row }">
                 <!-- <span>
                 <!-- <span>
                 {{ row.typeGas ? '✔' : '' }}
                 {{ row.typeGas ? '✔' : '' }}
@@ -173,7 +219,11 @@
                 <span v-else></span>
                 <span v-else></span>
               </template>
               </template>
             </zm-table-column>
             </zm-table-column>
-            <zm-table-column :label="t('qhse.indexvue239376-23')" prop="typeNoise" width="60" align="center">
+            <zm-table-column
+              :label="t('qhse.indexvue239376-23')"
+              prop="typeNoise"
+              width="60"
+              align="center">
               <template #default="{ row }">
               <template #default="{ row }">
                 <el-button circle type="success" style="border: none" plain v-if="row.typeNoise">
                 <el-button circle type="success" style="border: none" plain v-if="row.typeNoise">
                   <span class="text-[#259644]">
                   <span class="text-[#259644]">
@@ -183,7 +233,11 @@
                 <span v-else></span>
                 <span v-else></span>
               </template>
               </template>
             </zm-table-column>
             </zm-table-column>
-            <zm-table-column :label="t('qhse.indexvue239376-24')" prop="typeWaste" width="60" align="center">
+            <zm-table-column
+              :label="t('qhse.indexvue239376-24')"
+              prop="typeWaste"
+              width="60"
+              align="center">
               <template #default="{ row }">
               <template #default="{ row }">
                 <el-button circle type="success" style="border: none" plain v-if="row.typeWaste">
                 <el-button circle type="success" style="border: none" plain v-if="row.typeWaste">
                   <span class="text-[#259644]">
                   <span class="text-[#259644]">
@@ -193,7 +247,11 @@
                 <span v-else></span>
                 <span v-else></span>
               </template>
               </template>
             </zm-table-column>
             </zm-table-column>
-            <zm-table-column :label="t('qhse.indexvue239376-25')" prop="typeSoil" width="60" align="center">
+            <zm-table-column
+              :label="t('qhse.indexvue239376-25')"
+              prop="typeSoil"
+              width="60"
+              align="center">
               <template #default="{ row }">
               <template #default="{ row }">
                 <el-button circle type="success" style="border: none" plain v-if="row.typeSoil">
                 <el-button circle type="success" style="border: none" plain v-if="row.typeSoil">
                   <span class="text-[#259644]">
                   <span class="text-[#259644]">
@@ -203,7 +261,11 @@
                 <span v-else></span>
                 <span v-else></span>
               </template>
               </template>
             </zm-table-column>
             </zm-table-column>
-            <zm-table-column :label="t('qhse.indexvue239376-26')" prop="typeOther" width="60" align="center">
+            <zm-table-column
+              :label="t('qhse.indexvue239376-26')"
+              prop="typeOther"
+              width="60"
+              align="center">
               <template #default="{ row }">
               <template #default="{ row }">
                 <el-button circle type="success" style="border: none" plain v-if="row.typeOther">
                 <el-button circle type="success" style="border: none" plain v-if="row.typeOther">
                   <span class="text-[#259644]">
                   <span class="text-[#259644]">
@@ -215,18 +277,34 @@
             </zm-table-column>
             </zm-table-column>
           </zm-table-column>
           </zm-table-column>
 
 
-          <zm-table-column :label="t('qhse.indexvue239376-27')" prop="controlMethod" align="center" />
-          <zm-table-column :label="t('qhse.indexvue239376-28')" prop="createTime" width="160" align="center">
+          <zm-table-column
+            :label="t('qhse.indexvue239376-27')"
+            prop="controlMethod"
+            align="center" />
+          <zm-table-column
+            :label="t('qhse.indexvue239376-28')"
+            prop="createTime"
+            width="160"
+            align="center">
             <template #default="{ row }">
             <template #default="{ row }">
               {{ formatDate(row.createTime) }}
               {{ formatDate(row.createTime) }}
             </template>
             </template>
           </zm-table-column>
           </zm-table-column>
           <zm-table-column :label="t('qhse.indexvue239376-29')" prop="remark" align="center" />
           <zm-table-column :label="t('qhse.indexvue239376-29')" prop="remark" align="center" />
 
 
-          <zm-table-column :label="t('qhse.indexvue239376-30')" width="120" fixed="right" align="center" action>
+          <zm-table-column
+            :label="t('qhse.indexvue239376-30')"
+            width="120"
+            fixed="right"
+            align="center"
+            action>
             <template #default="{ row }">
             <template #default="{ row }">
-              <el-button type="primary" link @click="editRow(row)">{{ t('qhse.indexvue239376-31') }}</el-button>
-              <el-button type="danger" link @click="deleteRow(row)">{{ t('qhse.indexvue239376-32') }}</el-button>
+              <el-button type="primary" link @click="editRow(row)">{{
+                t('qhse.indexvue239376-31')
+              }}</el-button>
+              <el-button type="danger" link @click="deleteRow(row)">{{
+                t('qhse.indexvue239376-32')
+              }}</el-button>
             </template>
             </template>
           </zm-table-column>
           </zm-table-column>
         </zm-table>
         </zm-table>
@@ -269,7 +347,9 @@
         <el-row :gutter="20">
         <el-row :gutter="20">
           <el-col :span="12">
           <el-col :span="12">
             <el-form-item :label="t('qhse.indexvue239376-4')" prop="environmentElement">
             <el-form-item :label="t('qhse.indexvue239376-4')" prop="environmentElement">
-              <el-input v-model="formData.environmentElement" :placeholder="t('qhse.indexvue239376-5')" />
+              <el-input
+                v-model="formData.environmentElement"
+                :placeholder="t('qhse.indexvue239376-5')" />
             </el-form-item>
             </el-form-item>
           </el-col>
           </el-col>
           <!-- <el-col :span="12">
           <!-- <el-col :span="12">
@@ -355,7 +435,9 @@
 
 
       <template #footer>
       <template #footer>
         <el-button @click="cancelForm">{{ t('qhse.indexvue239376-41') }}</el-button>
         <el-button @click="cancelForm">{{ t('qhse.indexvue239376-41') }}</el-button>
-        <el-button type="primary" @click="submitForm" :loading="submitLoading">{{ t('qhse.indexvue239376-42') }}</el-button>
+        <el-button type="primary" @click="submitForm" :loading="submitLoading">{{
+          t('qhse.indexvue239376-42')
+        }}</el-button>
       </template>
       </template>
     </el-dialog>
     </el-dialog>
   </el-row>
   </el-row>
@@ -517,7 +599,7 @@ const formRef = ref()
 // 打开新增对话框
 // 打开新增对话框
 const openAddDialog = () => {
 const openAddDialog = () => {
   isEdit.value = false
   isEdit.value = false
-  dialogTitle.value = '新增环境因素'
+  dialogTitle.value = t('action.add')
   resetFormData()
   resetFormData()
   dialogVisible.value = true
   dialogVisible.value = true
 }
 }
@@ -525,7 +607,7 @@ const openAddDialog = () => {
 // 编辑行
 // 编辑行
 const editRow = (row) => {
 const editRow = (row) => {
   isEdit.value = true
   isEdit.value = true
-  dialogTitle.value = '编辑环境因素'
+  dialogTitle.value = t('action.update')
 
 
   // 构造表单数据
   // 构造表单数据
   formData.value = {
   formData.value = {

+ 2 - 2
src/views/pms/qhse/index.vue

@@ -819,7 +819,7 @@ const resetQuery = () => {
 // 显示新增对话框
 // 显示新增对话框
 const handleAdd = () => {
 const handleAdd = () => {
   isEdit.value = false
   isEdit.value = false
-  dialogTitle.value = '新增台账'
+  dialogTitle.value = t('action.add')
   resetForm()
   resetForm()
   formData.value.deptId = queryParams.deptId || userStore.getUser.deptId
   formData.value.deptId = queryParams.deptId || userStore.getUser.deptId
   dialogVisible.value = true
   dialogVisible.value = true
@@ -828,7 +828,7 @@ const handleAdd = () => {
 // 显示编辑对话框
 // 显示编辑对话框
 const handleEdit = (row) => {
 const handleEdit = (row) => {
   isEdit.value = true
   isEdit.value = true
-  dialogTitle.value = '编辑台账'
+  dialogTitle.value = t('action.update')
 
 
   formData.value = {
   formData.value = {
     ...row,
     ...row,

+ 61 - 16
src/views/pms/qhse/industry/index.vue

@@ -40,21 +40,27 @@
         </el-form-item>
         </el-form-item>
 
 
         <el-form-item>
         <el-form-item>
-          <el-button @click="handleQuery"> <Icon icon="ep:search" class="mr-5px" />{{ t('qhse.indexvue598751-4') }}</el-button>
-          <el-button @click="resetQuery"> <Icon icon="ep:refresh" class="mr-5px" />{{ t('qhse.indexvue598751-5') }}</el-button>
+          <el-button @click="handleQuery">
+            <Icon icon="ep:search" class="mr-5px" />{{ t('qhse.indexvue598751-4') }}</el-button
+          >
+          <el-button @click="resetQuery">
+            <Icon icon="ep:refresh" class="mr-5px" />{{ t('qhse.indexvue598751-5') }}</el-button
+          >
           <el-button
           <el-button
             type="primary"
             type="primary"
             plain
             plain
             @click="openForm('create')"
             @click="openForm('create')"
             v-hasPermi="['rq:qhse-law:create']">
             v-hasPermi="['rq:qhse-law:create']">
-            <Icon icon="ep:plus" class="mr-5px" />{{ t('qhse.indexvue598751-6') }}</el-button>
+            <Icon icon="ep:plus" class="mr-5px" />{{ t('qhse.indexvue598751-6') }}</el-button
+          >
           <el-button
           <el-button
             type="success"
             type="success"
             plain
             plain
             :loading="exportLoading"
             :loading="exportLoading"
             @click="handleExport"
             @click="handleExport"
             v-hasPermi="['rq:qhse-law:export']">
             v-hasPermi="['rq:qhse-law:export']">
-            <Icon icon="ep:download" class="mr-5px" />{{ t('qhse.indexvue598751-7') }}</el-button>
+            <Icon icon="ep:download" class="mr-5px" />{{ t('qhse.indexvue598751-7') }}</el-button
+          >
         </el-form-item>
         </el-form-item>
       </el-form>
       </el-form>
     </div>
     </div>
@@ -78,30 +84,54 @@
                 min-width="180"
                 min-width="180"
                 align="center"
                 align="center"
                 show-overflow-tooltip />
                 show-overflow-tooltip />
-              <zm-table-column :label="t('qhse.indexvue598751-0')" prop="type" min-width="110" align="center">
+              <zm-table-column
+                :label="t('qhse.indexvue598751-0')"
+                prop="type"
+                min-width="110"
+                align="center">
                 <template #default="scope">
                 <template #default="scope">
                   <dict-tag :type="DICT_TYPE.QHSE_LAW_TYPE" :value="scope.row.type" />
                   <dict-tag :type="DICT_TYPE.QHSE_LAW_TYPE" :value="scope.row.type" />
                 </template>
                 </template>
               </zm-table-column>
               </zm-table-column>
 
 
-              <zm-table-column :label="t('qhse.indexvue598751-8')" prop="publishDate" min-width="160" align="center">
+              <zm-table-column
+                :label="t('qhse.indexvue598751-8')"
+                prop="publishDate"
+                min-width="160"
+                align="center">
                 <template #default="{ row }">{{
                 <template #default="{ row }">{{
                   formatDateTime(row.publishDate).substring(0, 10)
                   formatDateTime(row.publishDate).substring(0, 10)
                 }}</template>
                 }}</template>
               </zm-table-column>
               </zm-table-column>
-              <zm-table-column :label="t('qhse.indexvue598751-9')" prop="effectiveDate" min-width="160" align="center">
+              <zm-table-column
+                :label="t('qhse.indexvue598751-9')"
+                prop="effectiveDate"
+                min-width="160"
+                align="center">
                 <template #default="{ row }">{{
                 <template #default="{ row }">{{
                   formatDateTime(row.effectiveDate).substring(0, 10)
                   formatDateTime(row.effectiveDate).substring(0, 10)
                 }}</template>
                 }}</template>
               </zm-table-column>
               </zm-table-column>
 
 
-              <zm-table-column :label="t('qhse.indexvue598751-10')" prop="newDate" min-width="160" align="center">
+              <zm-table-column
+                :label="t('qhse.indexvue598751-10')"
+                prop="newDate"
+                min-width="160"
+                align="center">
                 <template #default="{ row }">{{
                 <template #default="{ row }">{{
                   formatDateTime(row.newDate).substring(0, 10)
                   formatDateTime(row.newDate).substring(0, 10)
                 }}</template>
                 }}</template>
               </zm-table-column>
               </zm-table-column>
-              <zm-table-column :label="t('qhse.indexvue598751-11')" prop="publishDept" min-width="140" align="center" />
-              <zm-table-column :label="t('qhse.indexvue598751-12')" prop="fileNo" min-width="140" align="center" />
+              <zm-table-column
+                :label="t('qhse.indexvue598751-11')"
+                prop="publishDept"
+                min-width="140"
+                align="center" />
+              <zm-table-column
+                :label="t('qhse.indexvue598751-12')"
+                prop="fileNo"
+                min-width="140"
+                align="center" />
               <!-- <zm-table-column label="部门" prop="deptName" min-width="120" align="center">
               <!-- <zm-table-column label="部门" prop="deptName" min-width="120" align="center">
                 <template #default="{ row }">{{ row.deptName || row.deptId || '-' }}</template>
                 <template #default="{ row }">{{ row.deptName || row.deptId || '-' }}</template>
               </zm-table-column> -->
               </zm-table-column> -->
@@ -118,18 +148,27 @@
                 min-width="160"
                 min-width="160"
                 align="center"
                 align="center"
                 show-overflow-tooltip />
                 show-overflow-tooltip />
-              <zm-table-column :label="t('qhse.indexvue598751-15')" align="center" fixed="right" min-width="140" action>
+              <zm-table-column
+                :label="t('qhse.indexvue598751-15')"
+                align="center"
+                fixed="right"
+                min-width="140"
+                action>
                 <template #default="scope">
                 <template #default="scope">
                   <el-button
                   <el-button
                     link
                     link
                     type="primary"
                     type="primary"
                     @click="openForm('update', scope.row.id)"
                     @click="openForm('update', scope.row.id)"
-                    v-hasPermi="['rq:qhse-law:update']">{{ t('qhse.indexvue598751-16') }}</el-button>
+                    v-hasPermi="['rq:qhse-law:update']"
+                    >{{ t('qhse.indexvue598751-16') }}</el-button
+                  >
                   <el-button
                   <el-button
                     link
                     link
                     type="danger"
                     type="danger"
                     @click="handleDelete(scope.row.id)"
                     @click="handleDelete(scope.row.id)"
-                    v-hasPermi="['rq:qhse-law:delete']">{{ t('qhse.indexvue598751-17') }}</el-button>
+                    v-hasPermi="['rq:qhse-law:delete']"
+                    >{{ t('qhse.indexvue598751-17') }}</el-button
+                  >
                 </template>
                 </template>
               </zm-table-column>
               </zm-table-column>
             </zm-table>
             </zm-table>
@@ -249,12 +288,18 @@
           :placeholder="t('qhse.indexvue598751-26')" />
           :placeholder="t('qhse.indexvue598751-26')" />
       </el-form-item>
       </el-form-item>
       <el-form-item :label="t('qhse.indexvue598751-14')" prop="remark">
       <el-form-item :label="t('qhse.indexvue598751-14')" prop="remark">
-        <el-input v-model="formData.remark" type="textarea" :rows="3" :placeholder="t('qhse.indexvue598751-27')" />
+        <el-input
+          v-model="formData.remark"
+          type="textarea"
+          :rows="3"
+          :placeholder="t('qhse.indexvue598751-27')" />
       </el-form-item>
       </el-form-item>
     </el-form>
     </el-form>
 
 
     <template #footer>
     <template #footer>
-      <el-button type="primary" @click="submitForm" :disabled="formLoading">{{ t('qhse.indexvue598751-28') }}</el-button>
+      <el-button type="primary" @click="submitForm" :disabled="formLoading">{{
+        t('qhse.indexvue598751-28')
+      }}</el-button>
       <el-button @click="dialogVisible = false">{{ t('qhse.indexvue598751-29') }}</el-button>
       <el-button @click="dialogVisible = false">{{ t('qhse.indexvue598751-29') }}</el-button>
     </template>
     </template>
   </Dialog>
   </Dialog>
@@ -361,7 +406,7 @@ const resetQuery = () => {
 
 
 const openForm = async (type: 'create' | 'update', id?: number) => {
 const openForm = async (type: 'create' | 'update', id?: number) => {
   formType.value = type
   formType.value = type
-  dialogTitle.value = type === 'create' ? '新增行业标准' : '编辑行业标准'
+  dialogTitle.value = type === 'create' ? t('action.add') : t('action.update')
   if (type === 'create') {
   if (type === 'create') {
     formData.value = createDefaultForm()
     formData.value = createDefaultForm()
     dialogVisible.value = true
     dialogVisible.value = true

+ 11 - 0
src/views/pms/stat/rykb/ryProductionBriefs.vue

@@ -236,11 +236,22 @@ onMounted(() => {
             <div class="production-brief-table__summary">{{ row.deptName || '--' }}</div>
             <div class="production-brief-table__summary">{{ row.deptName || '--' }}</div>
           </template>
           </template>
         </el-table-column>
         </el-table-column>
+        <el-table-column
+          prop="deviceAttributes"
+          :label="t('iotrydailyreport.ryformvue846251-69')"
+          min-width="100"
+          align="center" />
         <el-table-column
         <el-table-column
           prop="taskName"
           prop="taskName"
           :label="t('stat.ryproducti168743-5')"
           :label="t('stat.ryproducti168743-5')"
           min-width="130"
           min-width="130"
           align="center" />
           align="center" />
+
+        <el-table-column
+          prop="location"
+          :label="t('iotrydailyreport.ryformvue846251-9')"
+          min-width="130"
+          align="center" />
         <el-table-column
         <el-table-column
           prop="constructionStatusName"
           prop="constructionStatusName"
           :label="t('stat.ryproducti168743-6')"
           :label="t('stat.ryproducti168743-6')"

+ 37 - 9
src/views/pms/stat/rykb/rydeviceList.vue

@@ -101,8 +101,9 @@ onMounted(() => {
       <div class="kb-panel-title-text flex items-center">
       <div class="kb-panel-title-text flex items-center">
         <div class="icon-decorator">
         <div class="icon-decorator">
           <span></span>
           <span></span>
-          <span></span>
-        </div>{{ t('stat.rydeviceli064265-0') }}</div>
+          <span></span> </div
+        >{{ t('stat.rydeviceli064265-0') }}</div
+      >
       <div class="ry-device-list-panel__picker">
       <div class="ry-device-list-panel__picker">
         <el-date-picker
         <el-date-picker
           v-model="createTime"
           v-model="createTime"
@@ -123,10 +124,25 @@ onMounted(() => {
         :height="tableHeight"
         :height="tableHeight"
         class="device-list-table"
         class="device-list-table"
         @row-click="handleRowClick">
         @row-click="handleRowClick">
-        <el-table-column prop="projectDeptName" :label="t('stat.rydeviceli064265-3')" align="center" />
-        <el-table-column prop="teamCount" :label="t('stat.rydeviceli064265-4')" width="72" align="center" />
-        <el-table-column prop="cumulativeDays" :label="t('stat.rydeviceli064265-5')" width="72" align="center" />
-        <el-table-column prop="constructionDays" :label="t('stat.rydeviceli064265-6')" width="72" align="center" />
+        <el-table-column
+          prop="projectDeptName"
+          :label="t('stat.rydeviceli064265-3')"
+          align="center" />
+        <el-table-column
+          prop="teamCount"
+          :label="t('stat.rydeviceli064265-4')"
+          width="72"
+          align="center" />
+        <el-table-column
+          prop="cumulativeDays"
+          :label="t('stat.rydeviceli064265-5')"
+          width="72"
+          align="center" />
+        <el-table-column
+          prop="constructionDays"
+          :label="t('stat.rydeviceli064265-6')"
+          width="72"
+          align="center" />
         <!-- <el-table-column label="注气量(万方)" min-width="150" align="center">
         <!-- <el-table-column label="注气量(万方)" min-width="150" align="center">
           <template #default="{ row }">
           <template #default="{ row }">
             {{ formatGasInjection(row.gasInjection) }}
             {{ formatGasInjection(row.gasInjection) }}
@@ -158,9 +174,21 @@ onMounted(() => {
         :data="teamList"
         :data="teamList"
         :height="TEAM_TABLE_HEIGHT"
         :height="TEAM_TABLE_HEIGHT"
         class="team-list-table">
         class="team-list-table">
-        <el-table-column prop="teamName" :label="t('stat.rydeviceli064265-8')" min-width="220" align="center" />
-        <el-table-column prop="cumulativeDays" :label="t('stat.rydeviceli064265-5')" min-width="140" align="center" />
-        <el-table-column prop="constructionDays" :label="t('stat.rydeviceli064265-6')" min-width="140" align="center" />
+        <el-table-column
+          prop="teamName"
+          :label="t('stat.rydeviceli064265-8')"
+          min-width="220"
+          align="center" />
+        <el-table-column
+          prop="cumulativeDays"
+          :label="t('stat.rydeviceli064265-5')"
+          min-width="140"
+          align="center" />
+        <el-table-column
+          prop="constructionDays"
+          :label="t('stat.rydeviceli064265-6')"
+          min-width="140"
+          align="center" />
         <el-table-column :label="t('stat.rydeviceli064265-0')" min-width="160" align="center">
         <el-table-column :label="t('stat.rydeviceli064265-0')" min-width="160" align="center">
           <template #default="{ row }">
           <template #default="{ row }">
             {{ formatRate(row.utilizationRate) }}
             {{ formatRate(row.utilizationRate) }}

+ 6 - 1
src/views/pms/video_center/haikang/index.vue

@@ -89,6 +89,11 @@ const splitOptions: Array<{ count: SplitCount; columns: HikvisionSplitColumns; i
   { count: 16, columns: 4, icon: grid16Icon }
   { count: 16, columns: 4, icon: grid16Icon }
 ]
 ]
 
 
+// 生产构建会把小 SVG 内联为带引号的 data: URL;CSS url() 不加引号会让整个 mask 声明无效。
+function toSplitIconUrl(icon: string) {
+  return `url("${icon}")`
+}
+
 /**
 /**
  * 八方向控制盘的展示配置与接口命令映射。
  * 八方向控制盘的展示配置与接口命令映射。
  * gridArea 将八个按钮固定在 3×3 网格四周,中间单元格留给不可点击的云台标识。
  * gridArea 将八个按钮固定在 3×3 网格四周,中间单元格留给不可点击的云台标识。
@@ -1998,7 +2003,7 @@ onBeforeUnmount(() => {
                   @click="handleSplitChange(option.count, option.columns)">
                   @click="handleSplitChange(option.count, option.columns)">
                   <span
                   <span
                     class="split-menu-icon"
                     class="split-menu-icon"
-                    :style="{ '--split-icon': `url(${option.icon})` }"
+                    :style="{ '--split-icon': toSplitIconUrl(option.icon) }"
                     aria-hidden="true"></span>
                     aria-hidden="true"></span>
                 </button>
                 </button>
               </div>
               </div>

+ 1 - 1
src/views/report-statistics/device_book/index2.vue

@@ -108,7 +108,7 @@
           </zm-table-column>
           </zm-table-column>
 
 
           <zm-table-column
           <zm-table-column
-            label="设备编码"
+            :label="t('monitor.deviceCode')"
             sortable
             sortable
             align="center"
             align="center"
             prop="deviceCode"
             prop="deviceCode"

+ 1 - 1
src/views/report-statistics/inspection_order/index.vue

@@ -156,7 +156,7 @@
         </el-col>
         </el-col>
         <el-col :span="2">
         <el-col :span="2">
           <el-button type="success" plain @click="handleExport" :loading="exportLoading"
           <el-button type="success" plain @click="handleExport" :loading="exportLoading"
-            ><Icon icon="ep:download" class="mr-3px" /> 导出</el-button
+            ><Icon icon="ep:download" class="mr-3px" />{{ t('action.export') }}</el-button
           >
           >
         </el-col>
         </el-col>
       </el-row>
       </el-row>

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

@@ -8,7 +8,7 @@ import { IotReportApi } from '@/api/pms/report'
 import { rangeShortcuts } from '@/utils/formatTime'
 import { rangeShortcuts } from '@/utils/formatTime'
 import download from '@/utils/download'
 import download from '@/utils/download'
 import { useTableComponents } from '@/components/ZmTable/useTableComponents'
 import { useTableComponents } from '@/components/ZmTable/useTableComponents'
-
+const { t } = useI18n()
 defineOptions({ name: 'WorkOrderCompletion' })
 defineOptions({ name: 'WorkOrderCompletion' })
 
 
 // 定义时间类型
 // 定义时间类型
@@ -392,7 +392,7 @@ async function handleExport() {
           </el-button-group>
           </el-button-group>
         </div>
         </div>
         <div class="flex gap-2">
         <div class="flex gap-2">
-          <el-button size="default" @click="handleReset">重置</el-button>
+          <el-button size="default" @click="handleReset">{{ t('action.reset') }}</el-button>
           <!-- @click="handleExport"
           <!-- @click="handleExport"
             :loading="exportLoading" -->
             :loading="exportLoading" -->
           <el-button
           <el-button
@@ -401,7 +401,7 @@ async function handleExport() {
             size="default"
             size="default"
             plain
             plain
             type="success">
             type="success">
-            <Icon icon="ep:download" class="mr-5px" /> 导出
+            <Icon icon="ep:download" class="mr-5px" /> {{ t('action.export') }}
           </el-button>
           </el-button>
         </div>
         </div>
       </div>
       </div>

+ 34 - 54
src/views/supplier/certificate/index.vue

@@ -6,16 +6,14 @@
       :model="queryParams"
       :model="queryParams"
       ref="queryFormRef"
       ref="queryFormRef"
       :inline="true"
       :inline="true"
-      label-width="68px"
-    >
-      <el-form-item label="供应商" prop="supplierId" v-if="ifAlone==null">
+      label-width="68px">
+      <el-form-item label="供应商" prop="supplierId" v-if="ifAlone == null">
         <el-input
         <el-input
           v-model="queryParams.supplierId"
           v-model="queryParams.supplierId"
           placeholder="请输入供应商"
           placeholder="请输入供应商"
           clearable
           clearable
           @keyup.enter="handleQuery"
           @keyup.enter="handleQuery"
-          class="!w-240px"
-        />
+          class="!w-240px" />
       </el-form-item>
       </el-form-item>
       <el-form-item label="证件类型" label-width="85px" prop="type">
       <el-form-item label="证件类型" label-width="85px" prop="type">
         <el-select v-model="queryParams.type" placeholder="证件类型" clearable class="!w-240px">
         <el-select v-model="queryParams.type" placeholder="证件类型" clearable class="!w-240px">
@@ -23,8 +21,7 @@
             v-for="dict in getIntDictOptions(DICT_TYPE.SUPPLIER_CERT)"
             v-for="dict in getIntDictOptions(DICT_TYPE.SUPPLIER_CERT)"
             :key="dict.value"
             :key="dict.value"
             :label="dict.label"
             :label="dict.label"
-            :value="dict.value"
-          />
+            :value="dict.value" />
         </el-select>
         </el-select>
       </el-form-item>
       </el-form-item>
       <el-form-item label="证件名称" prop="name">
       <el-form-item label="证件名称" prop="name">
@@ -33,8 +30,7 @@
           placeholder="请输入证件名称"
           placeholder="请输入证件名称"
           clearable
           clearable
           @keyup.enter="handleQuery"
           @keyup.enter="handleQuery"
-          class="!w-240px"
-        />
+          class="!w-240px" />
       </el-form-item>
       </el-form-item>
       <!--      <el-form-item label="创建时间" prop="createTime">-->
       <!--      <el-form-item label="创建时间" prop="createTime">-->
       <!--        <el-date-picker-->
       <!--        <el-date-picker-->
@@ -51,16 +47,15 @@
         <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
         <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
         <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
         <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
         <el-button v-if="!isDetail" type="primary" plain @click="openForm('create')">
         <el-button v-if="!isDetail" type="primary" plain @click="openForm('create')">
-          <Icon icon="ep:plus" class="mr-5px" /> 新增
+          <Icon icon="ep:plus" class="mr-5px" /> {{ t('action.add') }}
         </el-button>
         </el-button>
         <el-button
         <el-button
           v-if="!isDetail"
           v-if="!isDetail"
           type="success"
           type="success"
           plain
           plain
           @click="handleExport"
           @click="handleExport"
-          :loading="exportLoading"
-        >
-          <Icon icon="ep:download" class="mr-5px" /> 导出
+          :loading="exportLoading">
+          <Icon icon="ep:download" class="mr-5px" /> {{ t('action.export') }}
         </el-button>
         </el-button>
       </el-form-item>
       </el-form-item>
     </el-form>
     </el-form>
@@ -86,8 +81,7 @@
         align="center"
         align="center"
         prop="createTime"
         prop="createTime"
         :formatter="dateFormatter"
         :formatter="dateFormatter"
-        width="180px"
-      />
+        width="180px" />
       <el-table-column label="附件" align="center" prop="urls">
       <el-table-column label="附件" align="center" prop="urls">
         <!--          <template #default="scope">-->
         <!--          <template #default="scope">-->
 
 
@@ -102,9 +96,11 @@
       <el-table-column v-if="!isDetail" label="操作" align="center" min-width="120px">
       <el-table-column v-if="!isDetail" label="操作" align="center" min-width="120px">
         <template #default="scope">
         <template #default="scope">
           <el-button link type="primary" @click="openForm('update', scope.row.id)">
           <el-button link type="primary" @click="openForm('update', scope.row.id)">
-            编辑
+            {{ t('action.update') }}
+          </el-button>
+          <el-button link type="danger" @click="handleDelete(scope.row.id)">
+            {{ t('action.delete') }}
           </el-button>
           </el-button>
-          <el-button link type="danger" @click="handleDelete(scope.row.id)"> 删除 </el-button>
         </template>
         </template>
       </el-table-column>
       </el-table-column>
     </el-table>
     </el-table>
@@ -113,8 +109,7 @@
       :total="total"
       :total="total"
       v-model:page="queryParams.pageNo"
       v-model:page="queryParams.pageNo"
       v-model:limit="queryParams.pageSize"
       v-model:limit="queryParams.pageSize"
-      @pagination="getList"
-    />
+      @pagination="getList" />
   </ContentWrap>
   </ContentWrap>
 
 
   <!-- 表单弹窗:添加/修改 -->
   <!-- 表单弹窗:添加/修改 -->
@@ -122,8 +117,7 @@
     ref="formRef"
     ref="formRef"
     :supplierId="{ receivedParam }"
     :supplierId="{ receivedParam }"
     :ifAlone="ifAlone"
     :ifAlone="ifAlone"
-    @success="getList"
-  />
+    @success="getList" />
   <el-form v-if="ifAlone != null">
   <el-form v-if="ifAlone != null">
     <el-form-item style="float: right">
     <el-form-item style="float: right">
       <el-button @click="close" type="primary">返回</el-button>
       <el-button @click="close" type="primary">返回</el-button>
@@ -136,32 +130,16 @@
     width="900px"
     width="900px"
     height="700px"
     height="700px"
     style="min-height: 200px"
     style="min-height: 200px"
-    destroy-on-close
-  >
+    destroy-on-close>
     <el-row>
     <el-row>
-      <el-table
-        :data="objectInfo"
-        size="small"
-        border
-        header-cell-class-name="table-header-gray"
-      >
-        <el-table-column
-          label="文件名称"
-          prop="name"
-          min-width="100"
-          align="center"
-        />
-        <el-table-column
-          label="预览"
-          prop="url"
-          min-width="100"
-          align="center"
-        >
-                    <template #default="scope">
-                      <el-button @click="openWeb(scope.row.url)" style="border: none">
-                        <Icon style="color: #14c4ca" icon="ep:view"/>
-                      </el-button>
-                    </template>
+      <el-table :data="objectInfo" size="small" border header-cell-class-name="table-header-gray">
+        <el-table-column label="文件名称" prop="name" min-width="100" align="center" />
+        <el-table-column label="预览" prop="url" min-width="100" align="center">
+          <template #default="scope">
+            <el-button @click="openWeb(scope.row.url)" style="border: none">
+              <Icon style="color: #14c4ca" icon="ep:view" />
+            </el-button>
+          </template>
         </el-table-column>
         </el-table-column>
       </el-table>
       </el-table>
     </el-row>
     </el-row>
@@ -210,19 +188,21 @@ const exportLoading = ref(false) // 导出的加载中
 const objectInfo = ref([])
 const objectInfo = ref([])
 const openView = (row: CertificateVO) => {
 const openView = (row: CertificateVO) => {
   if (row.urls) {
   if (row.urls) {
-    objectInfo.value=[]
-    row.urls.split(',').forEach(item => {
-      let object = {url:'',name:''};
-      object.url = item;
-      const lastIndex = item.lastIndexOf('/');
-      object.name = lastIndex !== -1 ? item.substring(lastIndex+1) : item;
-      objectInfo.value.push(object);
+    objectInfo.value = []
+    row.urls.split(',').forEach((item) => {
+      let object = { url: '', name: '' }
+      object.url = item
+      const lastIndex = item.lastIndexOf('/')
+      object.name = lastIndex !== -1 ? item.substring(lastIndex + 1) : item
+      objectInfo.value.push(object)
     })
     })
   }
   }
   showDialog.value = true
   showDialog.value = true
 }
 }
 const openWeb = (url) => {
 const openWeb = (url) => {
-  window.open('http://1.94.244.160:8012/onlinePreview?url='+encodeURIComponent(Base64.encode(url)));
+  window.open(
+    'http://1.94.244.160:8012/onlinePreview?url=' + encodeURIComponent(Base64.encode(url))
+  )
 }
 }
 
 
 const getList = async () => {
 const getList = async () => {

+ 9 - 0
src/views/video-center/index.vue

@@ -0,0 +1,9 @@
+<template>
+  <HaiKang />
+</template>
+
+<script setup lang="ts">
+import HaiKang from '@/views/pms/video_center/haikang/index.vue'
+
+defineOptions({ name: 'VideoCenter' })
+</script>

+ 42 - 0
tests/hikvisionSplitIcons.test.mjs

@@ -0,0 +1,42 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import test from 'node:test'
+
+const icons = [
+  ['grid-one', 1],
+  ['grid-four', 2],
+  ['grid-nine', 3],
+  ['grid-16', 4]
+]
+
+const page = readFileSync(
+  new URL('../src/views/pms/video_center/haikang/index.vue', import.meta.url),
+  'utf8'
+)
+assert.match(page, /mask:\s*var\(--split-icon\)/, 'the split icons are rendered as CSS alpha masks')
+test('the SVG data URL is quoted before becoming a CSS mask URL', () => {
+  const splitStyle = page.split('\n').find((line) => line.includes("'--split-icon':")) ?? ''
+  assert.match(splitStyle, /'--split-icon':\s*toSplitIconUrl\(option\.icon\)/)
+  assert.match(
+    page,
+    /function toSplitIconUrl\(icon: string\)\s*\{\s*return `url\("\$\{icon\}"\)`/,
+    'unquoted Vite SVG data URLs contain quotes and invalidate the mask declaration'
+  )
+})
+
+for (const [iconName, columns] of icons) {
+  test(`${iconName} draws ${columns}×${columns} hollow cells instead of solid blocks`, () => {
+    const iconPath = new URL(`../src/assets/icons/${iconName}.svg`, import.meta.url)
+    const svg = readFileSync(fileURLToPath(iconPath), 'utf8')
+    const frame = svg.match(/<rect\b[^>]*>/g) ?? []
+    const dividers = svg.match(/<line\b[^>]*>/g) ?? []
+
+    assert.equal(frame.length, 1, 'the grid needs one outlined outer frame')
+    assert.match(frame[0], /\bfill="none"/, 'the panel interiors must remain transparent')
+    assert.match(frame[0], /\bstroke="currentColor"/, 'the frame must be visible in the mask')
+    assert.equal(dividers.length, 2 * (columns - 1), 'each row and column needs a divider')
+    for (const divider of dividers) assert.match(divider, /\bstroke="currentColor"/)
+    assert.doesNotMatch(svg, /<path\b/, 'filled paths turn the mask back into solid tiles')
+  })
+}

+ 59 - 0
tests/thresholdAlarmNoticeTracker.test.mjs

@@ -0,0 +1,59 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import { createThresholdAlarmNoticeTracker } from '../src/utils/thresholdAlarmNoticeTracker.ts'
+
+test('关闭当前活动告警提示后,本轮持续超限不再重复告警', () => {
+  const tracker = createThresholdAlarmNoticeTracker()
+
+  tracker.record('oil-speed', 'notice-1', 'min-1|max-1')
+  assert.equal(tracker.shouldCreate('oil-speed', 'min-1|max-1'), false)
+
+  assert.equal(tracker.close('oil-speed', 'notice-1'), true)
+  assert.equal(tracker.isAcknowledged('oil-speed', 'min-1|max-1'), true)
+  assert.equal(tracker.shouldCreate('oil-speed', 'min-1|max-1'), false)
+  assert.equal(tracker.shouldCreate('oil-speed', 'min-2|max-2'), true)
+})
+
+test('关闭历史提示不会让当前活动告警重复提示', () => {
+  const tracker = createThresholdAlarmNoticeTracker()
+
+  tracker.record('oil-speed', 'notice-current', 'min-1|max-1')
+  assert.equal(tracker.close('oil-speed', 'notice-history'), false)
+
+  assert.equal(tracker.isAcknowledged('oil-speed', 'min-1|max-1'), false)
+  assert.equal(tracker.shouldCreate('oil-speed', 'min-1|max-1'), false)
+})
+
+test('检查通过后允许下一轮告警创建新提示', () => {
+  const tracker = createThresholdAlarmNoticeTracker()
+
+  tracker.record('oil-speed', 'notice-1', 'min-1|max-1')
+  tracker.close('oil-speed', 'notice-1')
+  assert.equal(tracker.shouldCreate('oil-speed', 'min-1|max-1'), false)
+
+  tracker.pass('oil-speed')
+
+  assert.equal(tracker.isAcknowledged('oil-speed', 'min-1|max-1'), false)
+  assert.equal(tracker.shouldCreate('oil-speed', 'min-1|max-1'), true)
+})
+
+test('不同指标的当前提示状态互不影响', () => {
+  const tracker = createThresholdAlarmNoticeTracker()
+
+  tracker.record('oil-speed', 'oil-notice', 'oil-1')
+  tracker.record('well-pressure', 'pressure-notice', 'pressure-1')
+  tracker.close('oil-speed', 'oil-notice')
+
+  assert.equal(tracker.isAcknowledged('oil-speed', 'oil-1'), true)
+  assert.equal(tracker.shouldCreate('oil-speed', 'oil-1'), false)
+  assert.equal(tracker.shouldCreate('well-pressure', 'pressure-1'), false)
+})
+
+test('同一指标记录新提示时返回旧提示键并替换当前状态', () => {
+  const tracker = createThresholdAlarmNoticeTracker()
+
+  assert.equal(tracker.record('oil-speed', 'notice-1', 'oil-1'), undefined)
+  assert.equal(tracker.record('oil-speed', 'notice-2', 'oil-2'), 'notice-1')
+  assert.equal(tracker.close('oil-speed', 'notice-1'), false)
+  assert.equal(tracker.close('oil-speed', 'notice-2'), true)
+})

+ 133 - 0
tests/thresholdAlarmWindow.test.mjs

@@ -0,0 +1,133 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import { getWindowChangeAlarm } from '../src/utils/thresholdAlarmWindow.ts'
+
+const second = (value) => value * 1000
+
+test('最近 10 秒内相邻数据上涨幅度大于超限范围时告警', () => {
+  const points = [
+    { ts: second(47), value: 800 },
+    { ts: second(48), value: 901 },
+    { ts: second(55), value: 901 }
+  ]
+
+  assert.deepEqual(getWindowChangeAlarm(points, second(55), 10, 100), {
+    change: 101,
+    direction: 'increase',
+    fromValue: 800,
+    fromTs: second(47),
+    toTs: second(48),
+    toValue: 901
+  })
+})
+
+test('最近 10 秒内相邻数据下降幅度大于超限范围时同样告警', () => {
+  const points = [
+    { ts: second(47), value: 901 },
+    { ts: second(48), value: 800 },
+    { ts: second(55), value: 800 }
+  ]
+
+  assert.deepEqual(getWindowChangeAlarm(points, second(55), 10, 100), {
+    change: 101,
+    direction: 'decrease',
+    fromValue: 901,
+    fromTs: second(47),
+    toTs: second(48),
+    toValue: 800
+  })
+})
+
+test('相邻数据间隔不固定时仍按相邻变化量判断', () => {
+  const points = [
+    { ts: second(46), value: 800 },
+    { ts: second(47), value: 850 },
+    { ts: second(49), value: 951 },
+    { ts: second(53), value: 970 },
+    { ts: second(55), value: 970 }
+  ]
+
+  assert.deepEqual(getWindowChangeAlarm(points, second(55), 10, 100), {
+    change: 101,
+    direction: 'increase',
+    fromValue: 850,
+    fromTs: second(47),
+    toTs: second(49),
+    toValue: 951
+  })
+})
+
+test('相邻数据未超限时,即使窗口累计变化超限也不告警', () => {
+  const points = [
+    { ts: second(46), value: 850 },
+    { ts: second(47), value: 900 },
+    { ts: second(48), value: 950 },
+    { ts: second(55), value: 950 }
+  ]
+
+  assert.equal(getWindowChangeAlarm(points, second(55), 10, 100), undefined)
+})
+
+test('窗口内有多次超限时返回最新一组相邻变化', () => {
+  const points = [
+    { ts: second(47), value: 800 },
+    { ts: second(48), value: 901 },
+    { ts: second(50), value: 800 },
+    { ts: second(51), value: 650 },
+    { ts: second(55), value: 650 }
+  ]
+
+  assert.deepEqual(getWindowChangeAlarm(points, second(55), 10, 100), {
+    change: 150,
+    direction: 'decrease',
+    fromValue: 800,
+    fromTs: second(50),
+    toTs: second(51),
+    toValue: 650
+  })
+})
+
+test('窗口外的数据不参与幅度计算', () => {
+  const points = [
+    { ts: second(45), value: 1000 },
+    { ts: second(46), value: 800 },
+    { ts: second(55), value: 800 }
+  ]
+
+  assert.equal(getWindowChangeAlarm(points, second(55), 10, 100), undefined)
+})
+
+test('窗口随最新数据右移,窗口外的相邻超限退出后告警解除', () => {
+  const points = [
+    { ts: second(46), value: 800 },
+    { ts: second(47), value: 901 },
+    { ts: second(56), value: 900 }
+  ]
+
+  assert.equal(getWindowChangeAlarm(points, second(56), 10, 100), undefined)
+})
+
+test('范围边界相等时不触发', () => {
+  const points = [
+    { ts: second(54), value: 800 },
+    { ts: second(55), value: 900 }
+  ]
+
+  assert.equal(getWindowChangeAlarm(points, second(55), 10, 100), undefined)
+})
+
+test('乱序数据中晚于当前时间的点不参与检查', () => {
+  const points = [
+    { ts: second(54), value: 800 },
+    { ts: second(55), value: 900 },
+    { ts: second(56), value: 1000 }
+  ]
+
+  assert.equal(getWindowChangeAlarm(points, second(55), 10, 100), undefined)
+})
+
+test('窗口内只有一个数据点时不触发', () => {
+  const points = [{ ts: second(55), value: 800 }]
+
+  assert.equal(getWindowChangeAlarm(points, second(55), 10, 0), undefined)
+})

+ 17 - 0
vitest.config.mts

@@ -0,0 +1,17 @@
+import { fileURLToPath, URL } from 'node:url'
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+  resolve: {
+    alias: {
+      '@': fileURLToPath(new URL('./src', import.meta.url))
+    }
+  },
+  test: {
+    environment: 'node',
+    include: [
+      'src/components/mt-edit/**/*.spec.ts',
+      'src/views/oli-connection/monitoring/**/*.spec.ts'
+    ]
+  }
+})