فهرست منبع

Merge branch 'feature/maotu'

Zimo 4 روز پیش
والد
کامیت
38186c3aa6
50فایلهای تغییر یافته به همراه8236 افزوده شده و 1139 حذف شده
  1. 2 0
      package.json
  2. 254 6
      pnpm-lock.yaml
  3. 1214 0
      src/components/custom-components/hikvision-video-vue/index.vue
  4. 66 0
      src/components/custom-components/status-vue/index.vue
  5. 94 41
      src/components/custom-components/text-vue/index.vue
  6. 29 12
      src/components/mt-dzr/index.vue
  7. 16 0
      src/components/mt-dzr/utils/drag-position.ts
  8. 8 0
      src/components/mt-dzr/utils/move-completion.ts
  9. 399 62
      src/components/mt-edit/components/done-tree/index.vue
  10. 32 16
      src/components/mt-edit/components/draw-line-render/index.vue
  11. 5 1
      src/components/mt-edit/components/group-render/index.vue
  12. 687 283
      src/components/mt-edit/components/layout/header-panel/index.vue
  13. 892 139
      src/components/mt-edit/components/layout/left-aside/index.vue
  14. 295 46
      src/components/mt-edit/components/layout/main-panel/index.vue
  15. 268 0
      src/components/mt-edit/components/layout/right-aside/hikvision-camera-select.vue
  16. 224 23
      src/components/mt-edit/components/layout/right-aside/index.vue
  17. 183 22
      src/components/mt-edit/components/layout/right-aside/page-setting.vue
  18. 687 136
      src/components/mt-edit/components/layout/right-aside/select-item-event-setting/index.vue
  19. 192 46
      src/components/mt-edit/components/layout/right-aside/select-item-props-setting.vue
  20. 241 49
      src/components/mt-edit/components/layout/right-aside/select-item-setting.vue
  21. 85 0
      src/components/mt-edit/components/layout/right-aside/status-color-rules-editor.vue
  22. 51 23
      src/components/mt-edit/components/line-render/index.vue
  23. 14 5
      src/components/mt-edit/components/render-core/index.vue
  24. 15 4
      src/components/mt-edit/components/render-item/index.vue
  25. 5 1
      src/components/mt-edit/composables/index.ts
  26. 43 36
      src/components/mt-edit/composables/sys-line.ts
  27. 140 37
      src/components/mt-edit/index.vue
  28. 202 0
      src/components/mt-edit/store/__tests__/cache.spec.ts
  29. 74 0
      src/components/mt-edit/store/__tests__/hikvision-video.spec.ts
  30. 127 14
      src/components/mt-edit/store/cache.ts
  31. 182 8
      src/components/mt-edit/store/config.ts
  32. 2 2
      src/components/mt-edit/store/context-menu.ts
  33. 39 2
      src/components/mt-edit/store/types.ts
  34. 39 0
      src/components/mt-edit/utils/__tests__/canvas-pan.spec.ts
  35. 60 0
      src/components/mt-edit/utils/__tests__/canvas-scale.spec.ts
  36. 30 0
      src/components/mt-edit/utils/__tests__/editor-document.spec.ts
  37. 12 0
      src/components/mt-edit/utils/__tests__/keyboard-shortcut.spec.ts
  38. 49 0
      src/components/mt-edit/utils/__tests__/line-interaction.spec.ts
  39. 36 0
      src/components/mt-edit/utils/__tests__/move-completion.spec.ts
  40. 21 0
      src/components/mt-edit/utils/__tests__/text-format.spec.ts
  41. 14 0
      src/components/mt-edit/utils/canvas-pan.ts
  42. 58 0
      src/components/mt-edit/utils/canvas-scale.ts
  43. 20 0
      src/components/mt-edit/utils/editor-document.ts
  44. 23 3
      src/components/mt-edit/utils/index.ts
  45. 46 0
      src/components/mt-edit/utils/line-interaction.ts
  46. 40 0
      src/components/mt-edit/utils/text-format.ts
  47. 827 106
      src/views/maotu/components/DeviceBindPanel.vue
  48. 151 6
      src/views/maotu/edit.vue
  49. 29 10
      src/views/maotu/preview.vue
  50. 14 0
      vitest.config.mts

+ 2 - 0
package.json

@@ -8,6 +8,7 @@
     "i": "pnpm install",
     "dev": "vite --mode env.local",
     "dev-server": "vite --mode dev",
+    "test:unit": "vitest run --config vitest.config.mts",
     "ts:check": "vue-tsc --noEmit",
     "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",
@@ -174,6 +175,7 @@
     "vite-plugin-svg-icons": "^2.0.1",
     "vite-plugin-svg-icons-ng": "^1.3.1",
     "vite-plugin-top-level-await": "^1.4.4",
+    "vitest": "1.6.1",
     "vue-eslint-parser": "^9.3.2",
     "vue-tsc": "^1.8.27"
   },

+ 254 - 6
pnpm-lock.yaml

@@ -450,6 +450,9 @@ importers:
       vite-plugin-top-level-await:
         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))
+      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:
         specifier: ^9.3.2
         version: 9.4.3(eslint@8.57.1)
@@ -2366,6 +2369,21 @@ packages:
       vite: ^5.0.0 || ^6.0.0
       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':
     resolution: {integrity: sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==}
 
@@ -2589,6 +2607,10 @@ packages:
     peerDependencies:
       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:
     resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==}
     engines: {node: '>=0.4.0'}
@@ -2687,6 +2709,9 @@ packages:
     resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
     engines: {node: '>= 0.4'}
 
+  assertion-error@1.1.0:
+    resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==}
+
   assign-symbols@1.0.0:
     resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==}
     engines: {node: '>=0.10.0'}
@@ -2899,6 +2924,10 @@ packages:
     resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
     engines: {node: '>=0.8'}
 
+  chai@4.5.0:
+    resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==}
+    engines: {node: '>=4'}
+
   chalk@1.1.3:
     resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==}
     engines: {node: '>=0.10.0'}
@@ -2911,6 +2940,9 @@ packages:
     resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==}
     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:
     resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
 
@@ -3387,6 +3419,10 @@ packages:
     resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
     engines: {node: '>=0.10'}
 
+  deep-eql@4.1.4:
+    resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==}
+    engines: {node: '>=6'}
+
   deep-is@0.1.4:
     resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
 
@@ -3452,6 +3488,10 @@ packages:
   didi@9.0.2:
     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:
     resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
 
@@ -4002,6 +4042,9 @@ packages:
     resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==}
     engines: {node: '>=18'}
 
+  get-func-name@2.0.2:
+    resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==}
+
   get-intrinsic@1.2.4:
     resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==}
     engines: {node: '>= 0.4'}
@@ -4772,6 +4815,9 @@ packages:
     resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
     engines: {node: '>= 0.6.0'}
 
+  loupe@2.3.7:
+    resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==}
+
   lru-cache@10.4.3:
     resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
 
@@ -5163,6 +5209,10 @@ packages:
     resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==}
     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:
     resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
     engines: {node: '>=8'}
@@ -5260,6 +5310,9 @@ packages:
   pathe@2.0.3:
     resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
 
+  pathval@1.1.1:
+    resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==}
+
   perfect-debounce@1.0.0:
     resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
 
@@ -5785,6 +5838,9 @@ packages:
     resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
     engines: {node: '>= 0.4'}
 
+  siginfo@2.0.0:
+    resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
   signal-exit@4.1.0:
     resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
     engines: {node: '>=14'}
@@ -5895,6 +5951,9 @@ packages:
     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'
 
+  stackback@0.0.2:
+    resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
   stackblur-canvas@2.7.0:
     resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
     engines: {node: '>=0.1.14'}
@@ -5903,6 +5962,9 @@ packages:
     resolution: {integrity: sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==}
     engines: {node: '>=0.10.0'}
 
+  std-env@3.10.0:
+    resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+
   steady-xml@0.1.0:
     resolution: {integrity: sha512-5sk17qO2wWRtonTNoBhoKAB35OSsGJOa3+NEa6D+1GS+de+ujDWxnflMkXBrviOfkNrPTUqduAdXhrMJs89nAw==}
     engines: {node: '>=12.0.0'}
@@ -6095,6 +6157,9 @@ packages:
   tiny-warning@1.0.3:
     resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
 
+  tinybench@2.9.0:
+    resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
   tinyexec@0.3.1:
     resolution: {integrity: sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==}
 
@@ -6102,6 +6167,14 @@ packages:
     resolution: {integrity: sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==}
     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:
     resolution: {integrity: sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==}
     engines: {node: '>=0.10.0'}
@@ -6151,6 +6224,10 @@ packages:
     resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
     engines: {node: '>= 0.8.0'}
 
+  type-detect@4.1.0:
+    resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==}
+    engines: {node: '>=4'}
+
   type-fest@0.20.2:
     resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
     engines: {node: '>=10'}
@@ -6343,6 +6420,11 @@ packages:
   videojs-vtt.js@0.15.5:
     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:
     resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==}
     peerDependencies:
@@ -6416,6 +6498,31 @@ packages:
       terser:
         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:
     resolution: {integrity: sha512-BREuTgTYlUr0zw0EZn3hnhC3I6gPWv+Kwh4MCih6QcAeaTlaIX0DwOVN0wHej7hSvDPecz4jygy/idsgKfW58Q==}
     engines: {node: '>=12'}
@@ -6606,6 +6713,11 @@ packages:
     engines: {node: '>= 8'}
     hasBin: true
 
+  why-is-node-running@2.3.0:
+    resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+    engines: {node: '>=8'}
+    hasBin: true
+
   wildcard@1.1.2:
     resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==}
 
@@ -6836,7 +6948,7 @@ snapshots:
       '@babel/core': 7.26.0
       '@babel/helper-compilation-targets': 7.25.9
       '@babel/helper-plugin-utils': 7.25.9
-      debug: 4.3.7
+      debug: 4.4.3
       lodash.debounce: 4.0.8
       resolve: 1.22.8
     transitivePeerDependencies:
@@ -7940,7 +8052,7 @@ snapshots:
       '@antfu/install-pkg': 0.4.1
       '@antfu/utils': 0.7.10
       '@iconify/types': 2.0.0
-      debug: 4.3.7
+      debug: 4.4.3
       kolorist: 1.8.0
       local-pkg: 0.5.1
       mlly: 1.7.3
@@ -8613,7 +8725,7 @@ snapshots:
     dependencies:
       '@typescript-eslint/types': 6.21.0
       '@typescript-eslint/visitor-keys': 6.21.0
-      debug: 4.3.7
+      debug: 4.4.3
       globby: 11.1.0
       is-glob: 4.0.3
       minimatch: 9.0.3
@@ -8628,7 +8740,7 @@ snapshots:
     dependencies:
       '@typescript-eslint/types': 7.18.0
       '@typescript-eslint/visitor-keys': 7.18.0
-      debug: 4.3.7
+      debug: 4.4.3
       globby: 11.1.0
       is-glob: 4.0.3
       minimatch: 9.0.5
@@ -8643,7 +8755,7 @@ snapshots:
     dependencies:
       '@typescript-eslint/types': 8.26.1
       '@typescript-eslint/visitor-keys': 8.26.1
-      debug: 4.3.7
+      debug: 4.4.3
       fast-glob: 3.3.2
       is-glob: 4.0.3
       minimatch: 9.0.5
@@ -8859,7 +8971,7 @@ snapshots:
   '@unocss/rule-utils@0.58.9':
     dependencies:
       '@unocss/core': 0.58.9
-      magic-string: 0.30.14
+      magic-string: 0.30.17
 
   '@unocss/rule-utils@66.1.0-beta.5':
     dependencies:
@@ -8999,6 +9111,35 @@ snapshots:
       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)
 
+  '@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':
     dependencies:
       '@volar/source-map': 1.11.1
@@ -9317,6 +9458,10 @@ snapshots:
     dependencies:
       acorn: 8.14.0
 
+  acorn-walk@8.3.5:
+    dependencies:
+      acorn: 8.14.0
+
   acorn@8.14.0: {}
 
   adler-32@1.3.1: {}
@@ -9404,6 +9549,8 @@ snapshots:
       get-intrinsic: 1.3.0
       is-array-buffer: 3.0.5
 
+  assertion-error@1.1.0: {}
+
   assign-symbols@1.0.0: {}
 
   astral-regex@2.0.0: {}
@@ -9675,6 +9822,16 @@ snapshots:
       adler-32: 1.3.1
       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:
     dependencies:
       ansi-styles: 2.2.1
@@ -9690,6 +9847,10 @@ snapshots:
 
   chalk@5.3.0: {}
 
+  check-error@1.0.3:
+    dependencies:
+      get-func-name: 2.0.2
+
   cheerio-select@2.1.0:
     dependencies:
       boolbase: 1.0.0
@@ -10181,6 +10342,10 @@ snapshots:
 
   decode-uri-component@0.2.2: {}
 
+  deep-eql@4.1.4:
+    dependencies:
+      type-detect: 4.1.0
+
   deep-is@0.1.4: {}
 
   default-passive-events@2.0.0: {}
@@ -10260,6 +10425,8 @@ snapshots:
 
   didi@9.0.2: {}
 
+  diff-sequences@29.6.3: {}
+
   dijkstrajs@1.0.3: {}
 
   dingtalk-jsapi@3.2.2:
@@ -10960,6 +11127,8 @@ snapshots:
 
   get-east-asian-width@1.3.0: {}
 
+  get-func-name@2.0.2: {}
+
   get-intrinsic@1.2.4:
     dependencies:
       es-errors: 1.3.0
@@ -11700,6 +11869,10 @@ snapshots:
 
   loglevel@1.9.2: {}
 
+  loupe@2.3.7:
+    dependencies:
+      get-func-name: 2.0.2
+
   lru-cache@10.4.3: {}
 
   lru-cache@5.1.1:
@@ -12150,6 +12323,10 @@ snapshots:
     dependencies:
       yocto-queue: 1.1.1
 
+  p-limit@5.0.0:
+    dependencies:
+      yocto-queue: 1.1.1
+
   p-locate@4.1.0:
     dependencies:
       p-limit: 2.3.0
@@ -12225,6 +12402,8 @@ snapshots:
 
   pathe@2.0.3: {}
 
+  pathval@1.1.1: {}
+
   perfect-debounce@1.0.0: {}
 
   performance-now@2.1.0: {}
@@ -12758,6 +12937,8 @@ snapshots:
       side-channel-map: 1.0.1
       side-channel-weakmap: 1.0.2
 
+  siginfo@2.0.0: {}
+
   signal-exit@4.1.0: {}
 
   signature_pad@3.0.0-beta.4: {}
@@ -12870,6 +13051,8 @@ snapshots:
 
   stable@0.1.8: {}
 
+  stackback@0.0.2: {}
+
   stackblur-canvas@2.7.0: {}
 
   static-extend@0.1.2:
@@ -12877,6 +13060,8 @@ snapshots:
       define-property: 0.2.5
       object-copy: 0.1.0
 
+  std-env@3.10.0: {}
+
   steady-xml@0.1.0: {}
 
   stop-iteration-iterator@1.1.0:
@@ -13131,6 +13316,8 @@ snapshots:
 
   tiny-warning@1.0.3: {}
 
+  tinybench@2.9.0: {}
+
   tinyexec@0.3.1: {}
 
   tinyglobby@0.2.10:
@@ -13138,6 +13325,10 @@ snapshots:
       fdir: 6.4.2(picomatch@4.0.2)
       picomatch: 4.0.2
 
+  tinypool@0.8.4: {}
+
+  tinyspy@2.2.1: {}
+
   to-object-path@0.3.0:
     dependencies:
       kind-of: 3.2.2
@@ -13184,6 +13375,8 @@ snapshots:
     dependencies:
       prelude-ls: 1.2.1
 
+  type-detect@4.1.0: {}
+
   type-fest@0.20.2: {}
 
   type@2.7.3: {}
@@ -13448,6 +13641,23 @@ snapshots:
     dependencies:
       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)):
     dependencies:
       chalk: 4.1.2
@@ -13532,6 +13742,39 @@ snapshots:
       sass: 1.81.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)):
     dependencies:
       vue: 3.5.12(typescript@5.3.3)
@@ -13729,6 +13972,11 @@ snapshots:
     dependencies:
       isexe: 2.0.0
 
+  why-is-node-running@2.3.0:
+    dependencies:
+      siginfo: 2.0.0
+      stackback: 0.0.2
+
   wildcard@1.1.2: {}
 
   wmf@1.0.2: {}

+ 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>
-  <p
-    class="break-words w-1/1 h-1/1"
-    :class="props.vertical ? 'text-vertical' : ''"
+  <div
+    class="mt-text"
     :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>
 <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>
 <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>

+ 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 { dzrStore } from './store/index'
 import type { MouseTouchEvent } from './utils/types'
+import { completeModelMove } from './utils/move-completion'
+import { hasExceededDragThreshold, normalizeCoordinate } from './utils/drag-position'
 const MtDzrProps = withDefaults(defineProps<IDzrProps>(), {
   id: randomString(16),
   modelValue: () => {
@@ -164,14 +166,22 @@ const onMouseDown = (de: MouseTouchEvent) => {
   const max_top = clientHeight - MtDzrProps.modelValue.height
   let set_new_left = init_x
   let set_new_top = init_y
+  let drag_started = false
   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 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_top = new_top < min_top ? min_top : new_top > max_top ? max_top : new_top
     drag_data_info.value = {
@@ -205,8 +215,8 @@ const onMouseDown = (de: MouseTouchEvent) => {
       if (adsorp_diff_x == 0 && adsorp_diff_y == 0) {
         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,
         left: set_new_left,
@@ -232,12 +242,19 @@ const onMouseDown = (de: MouseTouchEvent) => {
   autoDestroyMouseMove(onMouseMove, () => {
     nextTick(() => {
       dzrStore.hideDzrCopy()
-      MtDzrEmits('moveMouseUp')
-      MtDzrEmits('update:modelValue', {
+      if (!drag_started) {
+        return
+      }
+      const finalPosition = {
         ...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>
-  <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>
-    </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>
 
 <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 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 = {
   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(() => {
-  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) => {
   emits('updateSelectedItemsId', data.id)
 }
 const changeHide = (data: IDoneJson) => {
   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 index = getLayerIndex(data)
@@ -79,13 +174,255 @@ const canMoveDown = (data: IDoneJson) => {
   return getLayerIndex(data) > 0
 }
 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>
+
+<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 { IDoneJson, IGlobalStoreCanvasCfg, IGlobalStoreGridCfg } from '../../store/types'
 import { alignToGrid, isSystemShortcutKey, positionArrarToPath } from '@/components/mt-edit/utils'
+import {
+  hasLineDragStarted,
+  normalizeLineGeometry
+} from '@/components/mt-edit/utils/line-interaction'
 import { computed } from 'vue'
 import { configStore } from '../../store/config'
 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
   let new_x = 0
   let new_y = 0
+  let has_dragged = false
   const onMouseMove = (e: MouseTouchEvent) => {
     // 记录鼠标移动的位置
     const m_x = e instanceof MouseEvent ? e.clientX : e.touches[0].pageX
     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)
       ? 0
@@ -174,37 +183,44 @@ const onMouseDown = (de: MouseTouchEvent, point_index: number, item: { x: number
     document.removeEventListener('mouseup', onMouseUp)
     document.removeEventListener('touchmove', onMouseMove)
     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 =
       (itemRect?.left - canvas_area_bounding_info?.left) / lineRenderProps.canvasCfg.scale
     const new_top =
       (itemRect?.top - canvas_area_bounding_info?.top) / lineRenderProps.canvasCfg.scale
     const move_x = new_left - lineRenderProps.itemJson.binfo.left
     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 = {
       ...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: {
         ...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"
         :canvas-cfg="groupRender.canvasCfg"
         :canvas-dom="groupRender.canvasDom"
+        :preview-mode="groupRender.previewMode"
         :lock-state="false" />
     </div>
   </div>
@@ -33,8 +34,11 @@ type GroupRender = {
   grid: IGlobalStoreGridCfg
   canvasCfg: IGlobalStoreCanvasCfg
   canvasDom: HTMLElement | null
+  previewMode?: boolean
 }
-const groupRender = withDefaults(defineProps<GroupRender>(), {})
+const groupRender = withDefaults(defineProps<GroupRender>(), {
+  previewMode: false
+})
 </script>
 <style scoped>
 .mt-group {

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

@@ -1,277 +1,243 @@
 <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>
-      <el-button
-        text
-        circle
-        size="small"
+      <tool-button
+        :label="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"
+          danger
           @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
           placement="bottom"
-          :width="240"
-          trigger="hover"
+          :width="460"
+          trigger="click"
           :disabled="!headerPanelProps.alignEnabled">
           <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>
-          <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>
         </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 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 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 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
-        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>
-      <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-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'" />
           </el-icon>
         </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'" />
           </el-icon>
         </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>
       </div>
-    </div>
+    </section>
   </div>
 </template>
+
 <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 type { IRealTimeData } from '@/components/mt-edit/store/types'
-import deepOilLogo from '@/assets/imgs/logo.png'
 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 = {
+  title?: string
   leftAside: boolean
   rightAside: boolean
-  selectedItemsId: string[] //已选中组件的id
+  selectedItemsId: string[]
   groupEnabled: boolean
   unGroupEnabled: boolean
   alignEnabled: boolean
+  layerEnabled: boolean
+  canMoveLayerUp: boolean
+  canMoveLayerDown: boolean
   deleteEnabled: boolean
   lockState: boolean
   undoEnabled: boolean
@@ -279,12 +245,57 @@ type HeaderPanelProps = {
   realTimeData: IRealTimeData
   useThumbnail?: 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>(), {
+  title: '未命名项目',
   leftAside: true,
   rightAside: true,
   useThumbnail: false,
   saveLoading: false,
+  unsaved: false,
   selectedItemsId: () => []
 })
 const emits = defineEmits([
@@ -295,9 +306,9 @@ const emits = defineEmits([
   'onDeleteClick',
   'onExportClick',
   'onTreeClick',
+  'changeLayer',
   'alignSelected',
   'update:lockState',
-  'onHelpClick',
   'onRedoClick',
   'onUndoClick',
   'onImportClick',
@@ -307,28 +318,21 @@ const emits = defineEmits([
   'onDrawLineClick',
   'onThumbnailClick'
 ])
-const isDark = useDark({
-  selector: '#mt-edit'
-})
+const isDark = useDark({ selector: '#mt-edit' })
 const { isFullscreen, toggle } = useFullscreen()
 const toggleDark = useToggle(isDark)
 const drawline_selected = ref(false)
-const is_npm_env = ref(import.meta.env.MODE === 'npm')
+const layerPopoverVisible = ref(false)
 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 = (
   type:
@@ -340,52 +344,452 @@ const alignSelected = (
     | 'bottom'
     | 'horizontal-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 = () => {
   drawline_selected.value = !drawline_selected.value
   emits('onDrawLineClick', drawline_selected.value)
 }
 </script>
+
 <style scoped>
-.editor-brand {
+.editor-header {
   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;
-  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 {
-  width: 28px;
-  height: 28px;
-  flex: 0 0 28px;
-  border-radius: 6px;
+  width: 32px;
+  height: 32px;
   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-weight: 700;
-  color: #1f2937;
-  letter-spacing: 0;
   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>

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

@@ -1,157 +1,241 @@
 <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
             v-for="config_item_key in checked_keys"
             :key="config_item_key"
-            :title="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"
+                class="component-card"
                 @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
                     draggable="false"
-                    class="w-30px h-30px select-none"
+                    fit="contain"
+                    class="component-card__image"
                     :class="isDark ? 'bg-amber-50' : ''"
                     :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>
-              </div>
+              </article>
             </div>
+            <el-empty v-else :image-size="44" description="暂无匹配组件" />
           </el-collapse-item>
         </el-collapse>
+        <el-empty v-else :image-size="64" description="暂无可用分类" />
       </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 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>
+      </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"
-                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>
-              </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>
+            <el-empty v-else :image-size="84" :description="manage_empty_description" />
           </el-scrollbar>
-        </div>
+        </main>
       </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>
   </div>
 </template>
 <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 {
-  ElInput,
+  ElButton,
+  ElCheckbox,
   ElCollapse,
   ElCollapseItem,
-  ElButton,
-  ElScrollbar,
-  ElImage,
-  ElTooltip,
-  ElText,
   ElDialog,
-  ElCheckbox,
-  ElDivider,
+  ElEmpty,
+  ElIcon,
+  ElImage,
+  ElInput,
+  ElMessage,
+  ElPopconfirm,
+  ElScrollbar,
   ElTree,
+  ElTooltip,
   ElUpload,
-  type UploadUserFile,
-  ElMessage,
-  type UploadFile
+  type UploadFile,
+  type UploadUserFile
 } 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 = {
   leftAsideConfig: ILeftAsideConfig
 }
@@ -168,12 +252,11 @@ const disable_classify = useLocalStorage<string[]>('mt-disable-classify', [])
 const local_file = useLocalStorage<ILeftAsideConfigItem[]>('mt-local-file', [])
 leftAsideProps.leftAsideConfig.set('本地文件', local_file.value)
 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 up_img_list = ref<UploadUserFile[]>([])
-const show_del_local_file = ref<null | string>(null)
+const manage_search_str = ref('')
 const classify_list = computed(() =>
   [...leftAsideProps.leftAsideConfig.keys()].map((m) => {
     return { label: m }
@@ -182,34 +265,56 @@ const classify_list = computed(() =>
 const checked_keys = ref<string[]>(
   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({
   get: () => {
     return classify_list.value.length == checked_keys.value.length
   },
   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(() => {
   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 = (
   arr: ILeftAsideConfigItemPublic[] | undefined
 ): ILeftAsideConfigItemPublic[] => {
   if (!arr) {
     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
 }
@@ -218,7 +323,6 @@ const onDragStart = (config_item_key: string, item_id: string) => {
     console.error('拖拽初始化失败', config_item_key, item_id)
     return
   }
-  is_show_tooltip[`${config_item_key}${item_id}`] = false
   globalStore.setIntention('create')
   globalStore.setCreateItemInfo({
     config_key: config_item_key,
@@ -226,9 +330,31 @@ const onDragStart = (config_item_key: string, item_id: string) => {
   })
 }
 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
 }
-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)) {
     checked_keys.value.push(data.label)
   } else if (!checked) {
@@ -240,6 +366,7 @@ const handleCheckChange = (data: { label: string }, checked: boolean, indetermin
 }
 const onNodeClick = ({ label }: { label: string }) => {
   selected_node_key.value = label
+  manage_search_str.value = ''
 }
 const onUpLoadChange = (e: UploadFile) => {
   if (!e.raw!.type.includes('image/')) {
@@ -279,6 +406,7 @@ const onUpLoadChange = (e: UploadFile) => {
     up_img_list.value = []
     selected_node_key.value = '本地文件'
     treeRef.value?.setCurrentKey('本地文件')
+    ElMessage.success('素材上传成功')
   })
 }
 const onDelLocalFile = ({ id }: ILeftAsideConfigItem) => {
@@ -287,11 +415,636 @@ const onDelLocalFile = ({ id }: ILeftAsideConfigItem) => {
     local_file.value.splice(find_index, 1)
   }
   leftAsideProps.leftAsideConfig.set('本地文件', local_file.value)
+  ElMessage.success('本地素材已删除')
 }
 </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>
-#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>

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

@@ -1,5 +1,12 @@
 <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
       id="mtCanvasArea"
       ref="canvasAreaRef"
@@ -69,12 +76,73 @@
       :menu-info="contextMenuStore.menuInfo"
       :show="globalStore.intention == 'showContextMenu'"
       @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>
 </template>
 <script setup lang="ts">
 import { globalStore } from '@/components/mt-edit/store/global'
 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 PatternGrid from '@/components/mt-edit/components/pattern-grid/index.vue'
 import { computed, ref, reactive, onMounted, onUnmounted } from 'vue'
@@ -91,6 +159,7 @@ import {
   getRectCoordinate,
   getRectCenterCoordinate,
   handleAlign,
+  isDeleteShortcutKey,
   isSystemShortcutKey
 } from '@/components/mt-edit/utils'
 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 DrawLineRender from '@/components/mt-edit/components/draw-line-render/index.vue'
 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 = {
   groupEnabled: boolean
   unGroupEnabled: boolean
@@ -120,10 +199,12 @@ const mainPanelProps = withDefaults(defineProps<MainPanelProps>(), {
   lineAppendEnable: false
 })
 const canvasAreaRef = ref()
+const viewportRef = ref<HTMLElement>()
 const selectedAreaRef = ref<InstanceType<typeof SelectedArea>>()
 const dragCanvasRef = ref<InstanceType<typeof DragCanvas>>()
 const dragLineRenderRef = ref<InstanceType<typeof DrawLineRender>>()
 const shortcut_drag_select_item_id = ref('')
+const panModeEnabled = ref(false)
 // 是否需要重新计算画布缩放中心点
 const is_need_recal_center = ref(true)
 let is_listen_keydown = false // 是否已经监听了键盘事件
@@ -141,6 +222,9 @@ const done_json = computed({
     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 activeElement = document.activeElement
   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_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 create_item: IDoneJson = {
@@ -256,8 +343,26 @@ const onDrop = (e: DragEvent | TouchEvent, isTouch?: boolean) => {
     binfo: {
       left: alignToGrid(realityX / 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
     },
     resize: is_line ? false : true,
@@ -281,7 +386,7 @@ const onDrop = (e: DragEvent | TouchEvent, isTouch?: boolean) => {
   globalStore.setSingleSelect(create_item.id)
   globalStore.setIntention('none')
   globalStore.setCreateItemInfo(null)
-  cacheStore.addHistory(done_json_temp)
+  cacheStore.addHistory()
 }
 const onDragOver = (e: DragEvent) => {
   e.preventDefault()
@@ -289,8 +394,19 @@ const onDragOver = (e: DragEvent) => {
 const onRenderCoreMouseDown = (item: IDoneJson, e: MouseEvent) => {
   commitActiveRightAsideEditor()
   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)
     return
   }
@@ -337,17 +453,19 @@ const onRenderCoreMouseDown = (item: IDoneJson, e: MouseEvent) => {
 const onMouseDown = (e: MouseEvent) => {
   commitActiveRightAsideEditor()
   beginListenerKeyDown()
-  if (isSystemShortcutKey(e)) {
+  if (
+    shouldStartCanvasPan({
+      button: e.button,
+      panModeEnabled: panModeEnabled.value,
+      systemShortcutPressed: isSystemShortcutKey(e),
+      canvasLocked: globalStore.lock
+    })
+  ) {
     shortcut_drag_select_item_id.value = ''
     beginDragCanvas(e)
     return
   }
   globalStore.cancelAllSelect()
-  // 锁定状态或者右键点击进行画布拖动
-  if (globalStore.lock || e.button == 2) {
-    beginDragCanvas(e)
-    return
-  }
   if (mainPanelProps.lineAppendEnable) {
     globalStore.setIntention('drawSysLineStart')
     const { realityX, realityY } = getRealityXY(e, canvasAreaRef.value?.getBoundingClientRect())
@@ -430,7 +548,7 @@ const createGroupItem = () => {
   done_json_temp.push(create_group_item)
   globalStore.setGlobalStoreDoneJson(done_json_temp)
   globalStore.setSingleSelect(create_group_item.id)
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 const onUngroup = () => {
   // 根据选中的组件id,找到对应的需要解除组合的组件
@@ -461,7 +579,7 @@ const onUngroup = () => {
   )
   globalStore.setGlobalStoreDoneJson([...done_json_temp, ...split_group_items])
   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) => {
   // 拿到画布的边界信息
@@ -565,7 +683,9 @@ const onMoveMouseUp = () => {
   //移动完毕之后隐藏参考线
   globalStore.guideCfg.x.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,
     globalStore.canvasCfg.scale
   )
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 const onItemRotateDone = (item: IDoneJson) => {
   const update_lines = globalStore.done_json.filter(
@@ -727,7 +847,7 @@ const onItemRotateDone = (item: IDoneJson) => {
     canvasAreaRef.value,
     globalStore.canvasCfg.scale
   )
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 const onAlignSelected = (
   type:
@@ -751,7 +871,7 @@ const onAlignSelected = (
     globalStore.canvasCfg.scale,
     globalStore.done_json
   )
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 const dragCanvasMouseDown = () => {
   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.setSelectItems(new_items.map((m) => m.id))
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 /**
  * 删除
@@ -917,7 +1037,7 @@ const onContextMenuDelete = () => {
     return
   }
   globalStore.deleteSelectedItems()
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 /**
  * 组合
@@ -946,7 +1066,7 @@ const onContextMoveTop = () => {
     ...globalStore.done_json.filter((f) => f.id !== item.id),
     item
   ])
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 const onContextMoveBottom = () => {
   if (globalStore.selected_items_id.length !== 1) {
@@ -957,7 +1077,7 @@ const onContextMoveBottom = () => {
     item,
     ...globalStore.done_json.filter((f) => f.id !== item.id)
   ])
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 const onContextMoveUp = () => {
   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 + 1] = temp
   globalStore.setGlobalStoreDoneJson(globalStore.done_json)
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 const onContextMoveDown = () => {
   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 - 1] = temp
   globalStore.setGlobalStoreDoneJson(globalStore.done_json)
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 const onUndo = () => {
-  if (cacheStore.historyIndex == 0) {
-    return
-  }
-  cacheStore.historyIndex -= 1
-  globalStore.setGlobalStoreDoneJson(objectDeepClone(cacheStore.history[cacheStore.historyIndex]))
-  globalStore.setSelectItems([])
+  cacheStore.undo()
 }
 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)
   }
   //删除
-  else if (e.key.toLocaleLowerCase() === 'delete') {
+  else if (isDeleteShortcutKey(e)) {
     e.preventDefault()
     onContextMenuDelete()
   }
@@ -1190,17 +1300,58 @@ const onKeydown = (e: KeyboardEvent) => {
       canvasAreaRef.value,
       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)) {
     e.preventDefault()
     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) {
       return
@@ -1209,7 +1360,7 @@ const onMouseWheel = (e: any) => {
     const new_transform_origin_x = parseInt(realityX / globalStore.canvasCfg.scale + '')
     const new_transform_origin_y = parseInt(realityY / globalStore.canvasCfg.scale + '')
     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 = {
@@ -1249,7 +1400,7 @@ const onDrawLineEnd = (line_item: IDoneJson) => {
   globalStore.setGlobalStoreDoneJson(done_json_temp)
   globalStore.setSingleSelect(new_line_item.id)
   globalStore.setIntention('none')
-  cacheStore.addHistory(done_json_temp)
+  cacheStore.addHistory()
 }
 onMounted(() => {
   beginListenerKeyDown()
@@ -1268,6 +1419,20 @@ defineExpose({
 })
 </script>
 <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 {
   position: relative;
   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 {
   position: absolute;
   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>
-  <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>
 </template>
 <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 PageSetting from './page-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 selected_item_id = computed(() => globalStore.selected_items_id[0] || '')
 const selected_item_json = computed(() => {
@@ -39,17 +62,195 @@ const onUpdateItemJson = (item: IDoneJson) => {
   const done_json_temp = [...globalStore.done_json]
   done_json_temp[find_index_item_json] = item
   globalStore.setGlobalStoreDoneJson(done_json_temp)
+  cacheStore.addHistoryDebounced(`item-setting:${item.id}`)
 }
 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(() => {
   return !!slots.deviceBind
 })
 </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>

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

@@ -1,7 +1,13 @@
 <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-select v-model="canvas_size" placeholder="请设置画布尺寸">
             <el-option-group label="自定义">
@@ -45,17 +51,21 @@
                 size="small"
                 :step="0.1"
                 :min="0.1"
+                :max="4"
                 class="mx-5px" />
             </div>
           </el-select>
         </el-form-item>
         <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 label="背景图片" size="small">
           <el-upload
             ref="canvasBgImgUploadRef"
-            class="w-24px h-24px"
+            class="canvas-background-upload"
             v-model:file-list="bg_img_list"
             :auto-upload="false"
             :limit="1"
@@ -64,27 +74,36 @@
             accept="image/*"
             @mouseenter="show_clear_bg_img = true"
             @mouseleave="show_clear_bg_img = false">
-            <div class="flex justify-center items-center relative">
+            <div class="canvas-background-upload__trigger">
               <img
-                class="w-40px h-40px absolute left-0"
                 v-if="rightAsideProps.canvasCfg.img"
+                class="canvas-background-upload__preview"
                 :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
                 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">
-                <el-icon title="删除" :size="25">
+                <el-icon title="删除背景图片" :size="16">
                   <svg-analysis name="delete" />
                 </el-icon>
               </div>
             </div>
           </el-upload>
         </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-switch v-model="canvas_guide" />
         </el-form-item>
@@ -101,14 +120,12 @@
           <el-input-number v-model="grid_size" :min="1" />
         </el-form-item>
       </el-form>
-    </el-tab-pane>
-  </el-tabs>
+    </section>
+  </div>
 </template>
 <script setup lang="ts">
 import type { IGlobalStoreCanvasCfg, IGlobalStoreGridCfg } from '@/components/mt-edit/store/types'
 import {
-  ElTabs,
-  ElTabPane,
   ElForm,
   ElFormItem,
   ElInputNumber,
@@ -121,20 +138,19 @@ import {
   ElUpload,
   ElIcon,
   type UploadUserFile,
-  ElButton,
   type UploadFile,
   ElMessage
 } from 'element-plus'
 import { computed, ref } from 'vue'
 import SvgAnalysis from '@/components/mt-edit/components/svg-analysis/index.vue'
 import { blobToBase64 } from '@/components/mt-edit/utils'
+import { normalizeCanvasScale } from '@/components/mt-edit/utils/canvas-scale'
 type RightAsideProps = {
   canvasCfg: IGlobalStoreCanvasCfg
   gridCfg: IGlobalStoreGridCfg
 }
 const rightAsideProps = withDefaults(defineProps<RightAsideProps>(), {})
 const emits = defineEmits(['update:canvasCfg', 'update:gridCfg'])
-const activeName = ref('page')
 const canvasBgImgUploadRef = ref()
 const canvas_size = computed({
   get: () => {
@@ -175,6 +191,14 @@ const canvas_size_options = [
   {
     label: 'pc端',
     options: [
+      {
+        value: '3840*2160',
+        label: '4K(3840*2160)'
+      },
+      {
+        value: '2560*1440',
+        label: '2K(2560*1440)'
+      },
       {
         value: '1920*1080',
         label: '1920*1080'
@@ -218,7 +242,7 @@ const canvas_size_scale_input = computed({
   set: (value) => {
     emits('update:canvasCfg', {
       ...rightAsideProps.canvasCfg,
-      scale: value
+      scale: normalizeCanvasScale(value)
     })
   }
 })
@@ -229,7 +253,7 @@ const canvas_size_scale = computed({
   set: (value) => {
     emits('update:canvasCfg', {
       ...rightAsideProps.canvasCfg,
-      scale: value
+      scale: normalizeCanvasScale(value)
     })
   }
 })
@@ -348,3 +372,140 @@ const canvas_guide = computed({
   }
 })
 </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>
-  <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
           v-for="(event_list_item, event_list_index) in event_list"
           :key="event_list_item.id"
           :name="event_list_item.id">
           <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>
-                  <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>
                 </template>
               </el-popconfirm>
             </div>
           </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"
+                class="event-action-options"
+                size="small"
                 @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>
-            </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"
-                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>
+            </section>
           </div>
         </el-collapse-item>
       </el-collapse>
@@ -224,19 +334,23 @@
 <script setup lang="ts">
 import {
   ElButton,
-  ElForm,
-  ElFormItem,
-  ElSelect,
-  ElOption,
-  ElText,
   ElCollapse,
   ElCollapseItem,
+  ElDialog,
+  ElDrawer,
+  ElEmpty,
+  ElForm,
+  ElFormItem,
   ElIcon,
+  ElOption,
   ElPopconfirm,
-  ElDrawer,
-  ElDialog
+  ElRadioButton,
+  ElRadioGroup,
+  ElSelect,
+  ElTag
 } from 'element-plus'
 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 { randomString } from '@/components/mt-edit/utils'
 import type {
@@ -314,8 +428,9 @@ const all_component_options = computed(() => {
     }
   })
 })
-const activeNames = ref([])
+const activeNames = ref('')
 const event_list = computed(() => selectItemEventSettingProps.itemEvents)
+const trigger_editor_event_ids = ref<string[]>([])
 const select_event_index = ref(0)
 const drawer_visiable = ref(false)
 const drawer_title = ref('属性更改配置')
@@ -325,8 +440,9 @@ const dialog_title = ref('自定义代码编写')
 const dialog_code = ref('')
 const webtopo_project_dialog_visible = ref(false)
 const onAddEvent = () => {
+  const id = randomString()
   event_list.value.push({
-    id: randomString(),
+    id,
     type: 'click',
     action: 'changeAttr',
     change_attr: [],
@@ -339,9 +455,30 @@ const onAddEvent = () => {
       value: null
     }
   })
+  activeNames.value = id
 }
 const onDelEvent = (event_list_index: number) => {
+  const eventId = event_list.value[event_list_index]?.id
   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) => {
   drawer_visiable.value = true
@@ -385,6 +522,52 @@ const onWebtopoProjectClick = (index: number) => {
 const onWebtopoProjectSelected = (project: IDoneJsonEventWebtopoProject) => {
   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获取属性
  * @param id
@@ -517,16 +700,384 @@ watch(event_list.value, (val) => {
 })
 </script>
 <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;
+  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%;
+  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;
+  margin-right: auto;
   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;
 }
 
-.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>

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

@@ -1,55 +1,87 @@
 <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>
-    </el-form-item>
+    </section>
   </div>
 </template>
 <script setup lang="ts">
 import type { ILeftAsideConfigItemPublicProps } from '@/components/mt-edit/store/types'
+import { computed } from 'vue'
 import {
   ElFormItem,
   ElInput,
@@ -60,16 +92,130 @@ import {
   ElColorPicker
 } from 'element-plus'
 import JsonEdit from './json-edit.vue'
+import StatusColorRulesEditor from './status-color-rules-editor.vue'
+import HikvisionCameraSelect from './hikvision-camera-select.vue'
 type SelectItemPropsSettingProps = {
   itemId?: string
   propsInfo: ILeftAsideConfigItemPublicProps | undefined
 }
 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>
 <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) {
+  height: 20px;
+  padding: 0 0 5px;
   overflow: hidden;
+  line-height: 20px;
   text-overflow: ellipsis;
   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>

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

@@ -1,69 +1,112 @@
 <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-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 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
-                size="small"
+                :precision="2"
                 v-model="item_binfo_left"
                 @change="emits('addHistory')" />
             </el-form-item>
-            <el-form-item label="y轴坐标" size="small">
+            <el-form-item label="Y 坐标" size="small">
               <el-input-number
-                size="small"
+                :precision="2"
                 v-model="item_binfo_top"
                 @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_width"
-                @change="emits('addHistory')" />
+              <el-input-number v-model="item_binfo_width" @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_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 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>
+        </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
               :item-id="selectItemSettingProps.itemJson.id"
-              v-model:propsInfo="item_props" />
+              v-model:propsInfo="item_props"
+              @history-change="emits('addHistoryDebounced')" />
           </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 label="事件" name="event">
       <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 SelectItemEventSetting from './select-item-event-setting/index.vue'
 const activeName = ref('config')
-const activeNames = ref(['1'])
+const activeNames = ref<string[]>([])
 type SelectItemSettingProps = {
   itemJson: IDoneJson | undefined
   doneJson: IDoneJson[]
 }
 const selectItemSettingProps = withDefaults(defineProps<SelectItemSettingProps>(), {})
-const emits = defineEmits(['update:itemJson', 'addHistory'])
+const emits = defineEmits(['update:itemJson', 'addHistory', 'addHistoryDebounced'])
 const slots = useSlots()
 // 自由连线 直角连线都有自定义宽高以及禁止缩放和旋转
 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({
   get: () => {
     return selectItemSettingProps.itemJson?.title
@@ -281,3 +330,146 @@ const hasDeviceBindSlot = computed(() => {
   return !!slots.deviceBind
 })
 </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,
   positionArrarToPath
 } from '@/components/mt-edit/utils'
+import {
+  completeLineEdit,
+  hasLineDragStarted,
+  normalizeLineGeometry
+} from '@/components/mt-edit/utils/line-interaction'
 import { computed, ref, watch } from 'vue'
 import { configStore } from '../../store/config'
 type LineRenderProps = {
@@ -304,9 +309,9 @@ const onMouseDown = (
     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)
-    lineRenderEmits('update:itemJson', {
+    const new_item_json = {
       ...lineRenderProps.itemJson,
       props: {
         ...lineRenderProps.itemJson.props,
@@ -315,7 +320,13 @@ const onMouseDown = (
           val: new_point_position
         }
       }
-    })
+    }
+    completeLineEdit(
+      new_item_json,
+      true,
+      (value) => lineRenderEmits('update:itemJson', value),
+      () => lineRenderEmits('lineMouseUp')
+    )
     return
   }
   // 记录鼠标按下时实际点的坐标
@@ -326,10 +337,15 @@ const onMouseDown = (
   let new_x = 0
   let new_y = 0
   let first_click = true
+  let has_dragged = false
   const onMouseMove = (e: MouseTouchEvent) => {
     // 记录鼠标移动的位置
     const m_x = e instanceof MouseEvent ? e.clientX : e.touches[0].pageX
     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)
       ? 0
@@ -445,47 +461,59 @@ const onMouseDown = (
     document.removeEventListener('mouseup', onMouseUp)
     document.removeEventListener('touchmove', onMouseMove)
     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 =
       (itemRect?.left - canvas_area_bounding_info?.left) / lineRenderProps.canvasCfg.scale
     const new_top =
       (itemRect?.top - canvas_area_bounding_info?.top) / lineRenderProps.canvasCfg.scale
     const move_x = new_left - lineRenderProps.itemJson.binfo.left
     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,
       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: {
         ...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('mouseup', onMouseUp)
   document.addEventListener('touchmove', onMouseMove)
   document.addEventListener('touchend', onMouseUp)
-  lineRenderEmits('lineMouseUp')
 }
 const getCenterPositions = (point_position: { 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"
           :done-json="renderCoreProps.doneJson"
           :lock-state="renderCoreProps.globalLock ? true : item.lock"
+          :preview-mode="renderCoreProps.preivewMode"
           :line-append-enable="renderCoreProps.lineAppendEnable"
           @update:item-json="onUpdateItemJson(index, $event)"
           @set-intention="(val) => renderCoreEmits('setIntention', val)"
@@ -63,6 +64,7 @@
       :grid="renderCoreProps.gridCfg"
       :done-json="renderCoreProps.doneJson"
       :lock-state="renderCoreProps.globalLock ? true : item.lock"
+      :preview-mode="renderCoreProps.preivewMode"
       :line-append-enable="renderCoreProps.lineAppendEnable"
       @update:item-json="onUpdateItemJson(index, $event)"
       @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 SysButtonVue from '@/components/custom-components/sys-button-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 { normalizeCoordinate } from '@/components/mt-dzr/utils/drag-position'
 const instance = getCurrentInstance()
 const now_include_keys = Object.keys(instance?.appContext?.components as any)
 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')) {
   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 = {
   doneJson: IDoneJson[]
   canvasCfg: IGlobalStoreCanvasCfg
@@ -211,8 +222,8 @@ const onItemMove = (e: any, id: string) => {
               ...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)
 }
 const onLineMouseUp = () => {
-  setTimeout(() => {
-    cacheStore.addHistory(globalStore.done_json)
-  }, 1000)
+  cacheStore.addHistory()
 }
 </script>

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

@@ -13,12 +13,13 @@
       :item-json="item_json"
       :grid="renderItemProps.grid"
       :canvas-cfg="renderItemProps.canvasCfg"
-      :canvas-dom="renderItemProps.canvasDom" />
+      :canvas-dom="renderItemProps.canvasDom"
+      :preview-mode="renderItemProps.previewMode" />
     <component
       v-else-if="item_json.type === 'vue'"
       draggable="false"
       :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)" />
     <img
       v-else-if="item_json.type === 'img'"
@@ -62,18 +63,28 @@ type RenderItemProps = {
   doneJson?: IDoneJson[]
   lockState: boolean
   lineAppendEnable?: boolean
+  previewMode?: boolean
 }
 const renderItemProps = withDefaults(defineProps<RenderItemProps>(), {
   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({
   get: () => renderItemProps.itemJson,
   set: (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) => {
   if (props.modelValue) {
     emits('update:itemJson', {

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

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

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

@@ -1,6 +1,7 @@
 import { nextTick } from 'vue'
 import type { IDoneJson, IDoneJsonBinfo } from '../store/types'
 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 move_x = new_left - f.binfo.left
     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,
-      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_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,
-          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 {
         f.props.bind_anchors.val.start = null
@@ -176,21 +182,22 @@ export const useUpdateSysLine = (
         }
         const move_x = new_left - f.binfo.left
         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,
-          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 {
         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">
     <el-container class="h-1/1">
       <el-header
-        height="45px"
+        height="72px"
         class="dark:bg-myDarkBgColor cb-border p-0 select-none flex"
         @mousedown="mainPanelRef?.stopListenerKeyDown()">
         <header-panel
           v-model:leftAside="aside_state.left_show"
           v-model:rightAside="aside_state.right_show"
           v-model:lock-state="globalStore.lock"
+          :title="mtEidtProps.title"
           :selected-items-id="globalStore.selected_items_id"
           :group-enabled="header_group_enabled"
           :un-group-enabled="header_un_group_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"
-          :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"
           :use-thumbnail="mtEidtProps.useThumbnail"
           :save-loading="mtEidtProps.saveLoading"
+          :unsaved="is_unsaved"
           @on-group-click="mainPanelRef?.createGroupItem"
           @on-ungroup-click="mainPanelRef?.onUngroup"
           @on-delete-click="onDeleteClick"
           @on-export-click="onExportClick"
           @on-tree-click="done_json_tree_visiable = true"
-          @on-help-click="onHelpClick"
+          @change-layer="onChangeLayer"
           @align-selected="onAlignSelected"
           @on-redo-click="onRedoClick"
           @on-undo-click="onUndoClick"
@@ -35,9 +42,9 @@
           @on-thumbnail-click="onThumbnailClick"
           @on-draw-line-click="onDrawLineClick" />
       </el-header>
-      <el-container class="h-[calc(100%-45px-40px)]">
+      <el-container class="h-[calc(100%-72px)]">
         <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"
           @mousedown="mainPanelRef?.stopListenerKeyDown()">
           <left-aside :leftAsideConfig="leftAsideStore.config" />
@@ -53,7 +60,7 @@
             :line-append-enable="line_append_enable" />
         </el-main>
         <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"
           @mousedown="mainPanelRef?.stopListenerKeyDown()">
           <right-aside>
@@ -85,12 +92,18 @@
         :canvas-cfg="globalStore.canvasCfg"
         :grid-cfg="globalStore.gridCfg" />
     </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-json="globalStore.done_json"
         :selected-items-id="globalStore.selected_items_id"
         @update-selected-items-id="onTreeUpdateSelectedItemsId"
         @update-selected-id-hide="onDoneTreeUpdateSelectedIdHide"
+        @update-all-hide="onDoneTreeUpdateAllHide"
         @move-layer="onDoneTreeMoveLayer" />
     </el-drawer>
   </div>
@@ -112,7 +125,7 @@ import {
   ElMessage
 } from 'element-plus'
 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 { cacheStore } from './store/cache'
 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 { genExportJson, useExportJsonToDoneJson } from './composables'
 import type { IExportJson } from './components/types'
+import { serializeEditorDocument } from './utils/editor-document'
 type MtEditProps = {
+  title?: string
   useThumbnail?: boolean
   saveLoading?: boolean
+  historyLimit?: number
 }
 const mtEidtProps = withDefaults(defineProps<MtEditProps>(), {
+  title: '未命名项目',
   useThumbnail: false,
-  saveLoading: false
+  saveLoading: false,
+  historyLimit: 80
 })
 const emits = defineEmits(['onPreviewClick', 'onReturnClick', 'onSaveClick', 'onThumbnailClick'])
 const slots = useSlots()
@@ -158,13 +176,39 @@ const header_align_enabled = computed(() => {
   )
   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 export_visible = ref(false)
 const done_json_tree_visiable = 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 = () => {
   globalStore.deleteSelectedItems()
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 const onImportClick = () => {
   import_visible.value = true
@@ -181,7 +225,22 @@ const onDoneTreeUpdateSelectedIdHide = (id: string) => {
   const item = globalStore.done_json.find((f) => f.id === id)
   if (item) {
     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 index = globalStore.done_json.findIndex((f) => f.id === id)
@@ -201,7 +260,29 @@ const onDoneTreeMoveLayer = ({ id, direction }: { id: string; direction: 'up' |
   doneJson[targetIndex] = temp
   globalStore.setGlobalStoreDoneJson(doneJson)
   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 = (
   type:
@@ -216,41 +297,28 @@ const onAlignSelected = (
 ) => {
   mainPanelRef.value?.onAlignSelected(type)
 }
-const onHelpClick = () => {
-  window.open('http://mt.yaolm.top')
-}
 const onRedoClick = () => {
-  mainPanelRef.value?.onRedo()
+  cacheStore.redo()
 }
 const onUndoClick = () => {
-  mainPanelRef.value?.onUndo()
+  cacheStore.undo()
 }
 const onImportYes = async () => {
   const res = await importJsonRef.value?.onImport()
   if (res) {
     import_visible.value = false
-    cacheStore.addHistory(globalStore.done_json)
+    cacheStore.addHistory()
   } else {
     ElMessage.error('导入失败,请检查数据格式')
   }
 }
 const onPreviewClick = () => {
-  // 获取导出json
-  const { exportJson } = genExportJson(
-    globalStore.canvasCfg,
-    globalStore.gridCfg,
-    globalStore.done_json
-  )
-  emits('onPreviewClick', exportJson)
+  emits('onPreviewClick', getExportJson())
 }
 const onSaveClick = () => {
-  // 获取导出json
-  const { exportJson } = genExportJson(
-    globalStore.canvasCfg,
-    globalStore.gridCfg,
-    globalStore.done_json
-  )
-  emits('onSaveClick', exportJson)
+  if (!mtEidtProps.saveLoading) {
+    emits('onSaveClick', getExportJson())
+  }
 }
 const onThumbnailClick = () => {
   emits('onThumbnailClick')
@@ -263,25 +331,44 @@ const setImportJson = (exportJson: IExportJson) => {
   globalStore.canvasCfg = canvasCfg
   globalStore.gridCfg = gridCfg
   globalStore.setGlobalStoreDoneJson(importDoneJson)
-  cacheStore.history = [objectDeepClone(importDoneJson)]
-  cacheStore.historyIndex = 0
+  cacheStore.resetHistory()
+  saved_snapshot.value = current_snapshot.value
   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 = () => {
   resetGlobalStore()
   cacheStore.setBoundingBox([])
   cacheStore.setCopy([])
   cacheStore.adsorbPoint = []
-  cacheStore.history = [[]]
-  cacheStore.historyIndex = 0
+  cacheStore.resetHistory()
   import_visible.value = false
   export_visible.value = false
   done_json_tree_visiable.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({
   setImportJson,
-  reset
+  reset,
+  markSaved,
+  hasUnsavedChanges,
+  commitHistory
 })
 </script>
 <style scoped>
@@ -289,3 +376,19 @@ defineExpose({
   transition: width 0.3s;
 }
 </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 { 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({
   boundingBox: [],
@@ -15,19 +90,57 @@ export const cacheStore: ICache = reactive({
   setCopy(val) {
     cacheStore.copy = val
   },
-  history: [[]],
+  history: [],
   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
-      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: {
       text: {
         title: '文字内容',
+        type: 'textArea',
+        val: '文字',
+        autosize: { minRows: 2, maxRows: 6 },
+        group: '内容',
+        wide: true,
+        bindable: true
+      },
+      prefix: {
+        title: '显示前缀',
         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: {
         title: '字体',
@@ -277,22 +321,53 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
             value: 'Consolas, Monaco, monospace',
             label: '等宽字体'
           }
-        ]
+        ],
+        group: '文字样式',
+        wide: true,
+        bindable: false
       },
       fontSize: {
         title: '文字大小',
         type: 'number',
-        val: 18
+        val: 18,
+        min: 1,
+        max: 300,
+        step: 1,
+        precision: 0,
+        group: '文字样式',
+        bindable: false
       },
       fill: {
         title: '文字颜色',
         type: 'color',
-        val: '#FFF700'
+        val: '#000000',
+        group: '文字样式',
+        bindable: false
       },
       bold: {
         title: '文字加粗',
         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: {
         title: '文字对齐',
@@ -311,12 +386,89 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
             value: 'right',
             label: '右对齐'
           }
-        ]
+        ],
+        group: '文字样式',
+        bindable: false
+      },
+      verticalAlign: {
+        title: '上下对齐',
+        type: 'select',
+        val: 'top',
+        options: [
+          {
+            value: 'top',
+            label: '顶部'
+          },
+          {
+            value: 'center',
+            label: '垂直居中'
+          },
+          {
+            value: 'bottom',
+            label: '底部'
+          }
+        ],
+        group: '文字样式',
+        bindable: false
       },
       vertical: {
         title: '竖排展示',
         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: {
@@ -577,7 +729,7 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
       color: {
         title: '文字颜色',
         type: 'color',
-        val: '#FFF700'
+        val: '#000000'
       },
       borderColor: {
         title: '边框颜色',
@@ -597,6 +749,28 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
       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',
     title: '按钮',

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

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

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

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

+ 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 getDeleteShortcutKeyName = () =>
+  isMacPlatform() ? 'Delete (⌫)' : 'Delete / Backspace'
+
+export const isDeleteShortcutKey = (event: KeyboardEvent) =>
+  event.key === 'Delete' || event.key === 'Backspace'
+
 export const isSystemShortcutKey = (event: MouseEvent | KeyboardEvent | WheelEvent) =>
   isMacPlatform() ? event.metaKey : event.ctrlKey
 
@@ -783,6 +789,20 @@ export const handleAlign = (
   useUpdateSysLine(sys_lines, selected_done_json, canvasDom, scale)
   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
@@ -793,9 +813,9 @@ export const handleAlign = (
  */
 export const setItemAttr = (id: string, key: string, val: any, json_arr: IDoneJson[]) => {
   return new Promise((res) => {
-    const find_item = json_arr.find((f) => f.id === id)
+    const find_item = findDoneJsonItem(json_arr, id)
     if (!find_item) {
-      res({
+      return res({
         status: false,
         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[]) => {
-  const find_item = json_arr.find((f) => f.id === id)
+  const find_item = findDoneJsonItem(json_arr, id)
   if (!find_item) {
     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}`
+}

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

@@ -1,91 +1,246 @@
 <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>
-        </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>
     </el-drawer>
   </div>
@@ -94,11 +249,22 @@
 <script setup lang="ts">
 import type { WebtopoProjectLinkedDeviceVO } from '@/api/pms/maotu'
 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 { computed, reactive, ref } from 'vue'
+import { computed, nextTick, reactive, ref } from 'vue'
 
 type DeviceBindRow = IDoneJsonDeviceBind & {
+  targetNodeId?: string
   editing?: boolean
+  isNew?: boolean
+}
+
+type NodePropOption = {
+  label: string
+  value: string
+  nodeId: string
+  nodeProp: string
 }
 
 type Props = {
@@ -109,12 +275,16 @@ type Props = {
 const props = withDefaults(defineProps<Props>(), {
   devices: () => []
 })
+const emits = defineEmits<{
+  historyCommit: []
+}>()
 
 const drawerVisible = ref(false)
 const formRef = ref<FormInstance>()
 const formModel = reactive({
   deviceBinds: [] as DeviceBindRow[]
 })
+const editBackup = ref<DeviceBindRow>()
 
 const rules: Record<string, FormItemRule | FormItemRule[]> = {
   deviceId: [{ required: true, message: '请选择设备', trigger: 'change' }],
@@ -122,14 +292,43 @@ const rules: Record<string, FormItemRule | FormItemRule[]> = {
   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() {
   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(' - ')
 }
 
-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() {
-  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
 }
 
@@ -173,8 +419,11 @@ function addBind() {
     deviceId: undefined,
     deviceProp: '',
     nodeProp: '',
-    editing: true
+    targetNodeId: undefined,
+    editing: true,
+    isNew: true
   })
+  editBackup.value = undefined
 }
 
 function onDeviceChange(bindItem: DeviceBindRow) {
@@ -182,15 +431,39 @@ function onDeviceChange(bindItem: DeviceBindRow) {
 }
 
 function getDeviceProps(deviceId?: number) {
-  const device = props.devices.find((item) => item.id === deviceId)
+  const device = getDevice(deviceId)
   return (device?.pointParams || [])
     .filter((item) => item.paramCode)
     .map((item) => ({
-      label: item.paramName || item.paramCode!,
+      label: `${item.paramName || item.paramCode!}${item.paramUnit ? ` (${item.paramUnit})` : ''}`,
       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) {
   await formRef.value?.validateField([
     `deviceBinds.${index}.deviceId`,
@@ -198,14 +471,462 @@ async function saveBind(index: number) {
     `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].isNew = false
+  editBackup.value = undefined
   syncDeviceBinds()
+  emits('historyCommit')
   ElMessage.success('绑定成功')
 }
 
 function removeBind(index: number) {
   formModel.deviceBinds.splice(index, 1)
   syncDeviceBinds()
+  emits('historyCommit')
   ElMessage.success('解绑成功')
 }
 </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 { MtEdit } from '@/export'
 import { Canvg } from 'canvg'
+import { ElMessage, ElMessageBox } from 'element-plus'
 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'
 const route = useRoute()
 const router = useRouter()
@@ -48,6 +48,8 @@ const loadProject = async () => {
     if (data?.dataModel) {
       await nextTick()
       MtEditRef.value?.setImportJson(data.dataModel as IExportJson)
+    } else {
+      MtEditRef.value?.markSaved()
     }
   } catch {
     ElMessage.error('拓扑数据加载失败')
@@ -119,6 +121,9 @@ const onPreviewClick = (exportJson: IExportJson) => {
   window.open(routeUrl.href, '_blank')
 }
 const onSaveClick = async (dataModel: IExportJson) => {
+  if (saveLoading.value) {
+    return
+  }
   const detail = projectDetail.value
   if (!detail?.id) {
     ElMessage.error('项目信息不存在,无法保存')
@@ -147,20 +152,68 @@ const onSaveClick = async (dataModel: IExportJson) => {
       thumbnail,
       dataModel: JSON.stringify(dataModel)
     }
+    MtEditRef.value?.markSaved(dataModel)
     ElMessage.success('更新成功')
   } finally {
     saveLoading.value = false
   }
 }
 const onReturnClick = () => {
-  router.go(-1)
+  router.push('/maotu/list')
 }
 const onThumbnailClick = () => {
   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(() => {
   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)
@@ -173,6 +226,7 @@ watch(() => route.params.id, resetAndLoadProject)
     class="w-1/1 h-100vh">
     <mt-edit
       ref="MtEditRef"
+      :title="projectDetail?.projectName || '拓扑项目'"
       :use-thumbnail="true"
       :save-loading="saveLoading"
       @on-preview-click="onPreviewClick"
@@ -180,10 +234,101 @@ watch(() => route.params.id, resetAndLoadProject)
       @on-save-click="onSaveClick"
       @on-thumbnail-click="onThumbnailClick">
       <template #deviceBind="{ item }">
-        <DeviceBindPanel :item="item" :devices="projectDetail?.linkedDevices || []" />
+        <DeviceBindPanel
+          :item="item"
+          :devices="projectDetail?.linkedDevices || []"
+          @history-commit="MtEditRef?.commitHistory()" />
       </template>
     </mt-edit>
   </div>
 </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 { IotDeviceApi } from '@/api/pms/device'
 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 { isAppWebtopoPreview } from '@/utils/auth'
 import { ElMessage } from 'element-plus'
@@ -62,14 +64,22 @@ const getDeviceBindItems = () => {
     ? previewData.value.pages.flatMap((page) => page.json)
     : previewData.value?.json || []
 
-  return previewItems.flatMap((item) =>
+  return flattenDoneJson(previewItems).flatMap((item) =>
     (item.deviceBinds || [])
       .filter((bind) => bind.deviceId && bind.deviceProp && bind.nodeProp)
       .map((bind) => ({
         itemId: item.id,
+        itemTag: item.tag,
         deviceId: bind.deviceId!,
         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 }
       })
     )
-    const valueMap = new Map<string, unknown>()
+    const pointMap = new Map<string, DevicePointData>()
     deviceDataList.forEach(({ deviceId, data }) => {
       data.forEach((item) => {
         if (item.identifier) {
-          valueMap.set(`${deviceId}:${item.identifier}`, formatDevicePointValue(item))
+          const key = `${deviceId}:${item.identifier}`
+          pointMap.set(key, item)
         }
       })
     })
 
     MtPreviewRef.value?.setItemAttrs(
       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 {
     polling.value = false

+ 14 - 0
vitest.config.mts

@@ -0,0 +1,14 @@
+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']
+  }
+})