Explorar o código

✨ feat(组态编辑器): 完善编辑历史与画布缩放控制

- 统一组件属性、事件、设备绑定、画布及网格配置的历史快照
- 支持连续输入防抖合并、重做分支截断、重复快照去重和历史容量限制
- 补全移动、缩放、删除、组合等操作的撤销与重做记录
- 新增画布底部缩放工具条,支持缩小、放大、恢复 100% 和适应画布
- 将画布缩放统一限制在 10%~400%,并修正旧项目中的非法缩放值
- 引入 Vitest,覆盖历史事务、缩放边界及适应画布计算
Zimo hai 12 horas
pai
achega
03f3db67df

+ 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",
@@ -173,6 +174,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"
   },

+ 290 - 12
pnpm-lock.yaml

@@ -82,7 +82,7 @@ importers:
         version: 4.1.1
       axios:
         specifier: ^1.6.8
-        version: 1.7.8
+        version: 1.7.8(debug@4.3.7)
       benz-amr-recorder:
         specifier: ^1.1.5
         version: 1.1.5
@@ -447,6 +447,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)
@@ -1588,36 +1591,42 @@ packages:
     engines: {node: '>= 10.0.0'}
     cpu: [arm]
     os: [linux]
+    libc: [glibc]
 
   '@parcel/watcher-linux-arm-musl@2.5.0':
     resolution: {integrity: sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==}
     engines: {node: '>= 10.0.0'}
     cpu: [arm]
     os: [linux]
+    libc: [musl]
 
   '@parcel/watcher-linux-arm64-glibc@2.5.0':
     resolution: {integrity: sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==}
     engines: {node: '>= 10.0.0'}
     cpu: [arm64]
     os: [linux]
+    libc: [glibc]
 
   '@parcel/watcher-linux-arm64-musl@2.5.0':
     resolution: {integrity: sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==}
     engines: {node: '>= 10.0.0'}
     cpu: [arm64]
     os: [linux]
+    libc: [musl]
 
   '@parcel/watcher-linux-x64-glibc@2.5.0':
     resolution: {integrity: sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==}
     engines: {node: '>= 10.0.0'}
     cpu: [x64]
     os: [linux]
+    libc: [glibc]
 
   '@parcel/watcher-linux-x64-musl@2.5.0':
     resolution: {integrity: sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==}
     engines: {node: '>= 10.0.0'}
     cpu: [x64]
     os: [linux]
+    libc: [musl]
 
   '@parcel/watcher-win32-arm64@2.5.0':
     resolution: {integrity: sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==}
@@ -1726,46 +1735,55 @@ packages:
     resolution: {integrity: sha512-9OwUnK/xKw6DyRlgx8UizeqRFOfi9mf5TYCw1uolDaJSbUmBxP85DE6T4ouCMoN6pXw8ZoTeZCSEfSaYo+/s1w==}
     cpu: [arm]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-arm-musleabihf@4.27.4':
     resolution: {integrity: sha512-Vgdo4fpuphS9V24WOV+KwkCVJ72u7idTgQaBoLRD0UxBAWTF9GWurJO9YD9yh00BzbkhpeXtm6na+MvJU7Z73A==}
     cpu: [arm]
     os: [linux]
+    libc: [musl]
 
   '@rollup/rollup-linux-arm64-gnu@4.27.4':
     resolution: {integrity: sha512-pleyNgyd1kkBkw2kOqlBx+0atfIIkkExOTiifoODo6qKDSpnc6WzUY5RhHdmTdIJXBdSnh6JknnYTtmQyobrVg==}
     cpu: [arm64]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-arm64-musl@4.27.4':
     resolution: {integrity: sha512-caluiUXvUuVyCHr5DxL8ohaaFFzPGmgmMvwmqAITMpV/Q+tPoaHZ/PWa3t8B2WyoRcIIuu1hkaW5KkeTDNSnMA==}
     cpu: [arm64]
     os: [linux]
+    libc: [musl]
 
   '@rollup/rollup-linux-powerpc64le-gnu@4.27.4':
     resolution: {integrity: sha512-FScrpHrO60hARyHh7s1zHE97u0KlT/RECzCKAdmI+LEoC1eDh/RDji9JgFqyO+wPDb86Oa/sXkily1+oi4FzJQ==}
     cpu: [ppc64]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-riscv64-gnu@4.27.4':
     resolution: {integrity: sha512-qyyprhyGb7+RBfMPeww9FlHwKkCXdKHeGgSqmIXw9VSUtvyFZ6WZRtnxgbuz76FK7LyoN8t/eINRbPUcvXB5fw==}
     cpu: [riscv64]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-s390x-gnu@4.27.4':
     resolution: {integrity: sha512-PFz+y2kb6tbh7m3A7nA9++eInGcDVZUACulf/KzDtovvdTizHpZaJty7Gp0lFwSQcrnebHOqxF1MaKZd7psVRg==}
     cpu: [s390x]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-x64-gnu@4.27.4':
     resolution: {integrity: sha512-Ni8mMtfo+o/G7DVtweXXV/Ol2TFf63KYjTtoZ5f078AUgJTmaIJnj4JFU7TK/9SVWTaSJGxPi5zMDgK4w+Ez7Q==}
     cpu: [x64]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-x64-musl@4.27.4':
     resolution: {integrity: sha512-5AeeAF1PB9TUzD+3cROzFTnAJAcVUGLuR8ng0E0WXGkYhp6RD6L+6szYVX+64Rs0r72019KHZS1ka1q+zU/wUw==}
     cpu: [x64]
     os: [linux]
+    libc: [musl]
 
   '@rollup/rollup-win32-arm64-msvc@4.27.4':
     resolution: {integrity: sha512-yOpVsA4K5qVwu2CaS3hHxluWIK5HQTjNV4tWjQXluMiiiu4pJj4BN98CvxohNCpcjMeTXk/ZMJBRbgRg8HBB6A==}
@@ -1811,24 +1829,28 @@ packages:
     engines: {node: '>=10'}
     cpu: [arm64]
     os: [linux]
+    libc: [glibc]
 
   '@swc/core-linux-arm64-musl@1.9.3':
     resolution: {integrity: sha512-tzVH480RY6RbMl/QRgh5HK3zn1ZTFsThuxDGo6Iuk1MdwIbdFYUY034heWUTI4u3Db97ArKh0hNL0xhO3+PZdg==}
     engines: {node: '>=10'}
     cpu: [arm64]
     os: [linux]
+    libc: [musl]
 
   '@swc/core-linux-x64-gnu@1.9.3':
     resolution: {integrity: sha512-ivXXBRDXDc9k4cdv10R21ccBmGebVOwKXT/UdH1PhxUn9m/h8erAWjz5pcELwjiMf27WokqPgaWVfaclDbgE+w==}
     engines: {node: '>=10'}
     cpu: [x64]
     os: [linux]
+    libc: [glibc]
 
   '@swc/core-linux-x64-musl@1.9.3':
     resolution: {integrity: sha512-ILsGMgfnOz1HwdDz+ZgEuomIwkP1PHT6maigZxaCIuC6OPEhKE8uYna22uU63XvYcLQvZYDzpR3ms47WQPuNEg==}
     engines: {node: '>=10'}
     cpu: [x64]
     os: [linux]
+    libc: [musl]
 
   '@swc/core-win32-arm64-msvc@1.9.3':
     resolution: {integrity: sha512-e+XmltDVIHieUnNJHtspn6B+PCcFOMYXNJB1GqoCcyinkEIQNwC8KtWgMqUucUbEWJkPc35NHy9k8aCXRmw9Kg==}
@@ -2168,6 +2190,7 @@ packages:
 
   '@ungap/structured-clone@1.2.0':
     resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
+    deprecated: Potential CWE-502 - Update to 1.3.1 or higher
 
   '@unocss/astro@0.58.9':
     resolution: {integrity: sha512-VWfHNC0EfawFxLfb3uI+QcMGBN+ju+BYtutzeZTjilLKj31X2UpqIh8fepixL6ljgZzB3fweqg2xtUMC0gMnoQ==}
@@ -2343,6 +2366,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==}
 
@@ -2541,9 +2579,11 @@ packages:
   '@xmldom/xmldom@0.8.10':
     resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==}
     engines: {node: '>=10.0.0'}
+    deprecated: this version has critical issues, please update to the latest version
 
   '@zumer/snapdom@2.0.2':
     resolution: {integrity: sha512-W6quT4lMcPu8Q9O/Q6witSfc6/+xuY8C8yDoHug/+o7zYKCNE/e0I3//XsWDkyq9C0mDE0TAWF/8bwCR7x3gHQ==}
+    deprecated: Old versions have known fidelity bugs (text re-wrap, width freezing, KaTeX clipping). Upgrade to ^2.22 — drop-in for any 2.x. https://github.com/zumerlab/snapdom/releases
 
   '@zxcvbn-ts/core@3.0.4':
     resolution: {integrity: sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==}
@@ -2564,6 +2604,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'}
@@ -2658,6 +2702,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'}
@@ -2866,6 +2913,10 @@ packages:
     resolution: {integrity: sha512-fKzMoMBwus3CWo1Uy8XJc4tqqn98RoRrGV6CsIkaNiQT5lOeHuMh4fOt+LXLzn2Wqtr4p/c2TOLz4xtu4oBlFA==}
     engines: {node: '>=12.0.0'}
 
+  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'}
@@ -2878,6 +2929,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==}
 
@@ -3081,6 +3135,7 @@ packages:
 
   crypto-js@4.2.0:
     resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
+    deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained.
 
   css-functions-list@3.2.3:
     resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==}
@@ -3344,6 +3399,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==}
 
@@ -3409,6 +3468,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==}
 
@@ -3642,6 +3705,7 @@ packages:
   eslint-define-config@2.1.0:
     resolution: {integrity: sha512-QUp6pM9pjKEVannNAbSJNeRuYwW3LshejfyBBpjeMGaJjaDUpVps4C6KVR8R7dWZnD3i0synmrE36znjTkJvdQ==}
     engines: {node: '>=18.0.0', npm: '>=9.0.0', pnpm: '>=8.6.0'}
+    deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
 
   eslint-plugin-prettier@5.2.1:
     resolution: {integrity: sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==}
@@ -3954,6 +4018,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'}
@@ -3981,6 +4048,7 @@ packages:
   git-raw-commits@4.0.0:
     resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==}
     engines: {node: '>=16'}
+    deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.
     hasBin: true
 
   glob-parent@5.1.2:
@@ -3993,11 +4061,12 @@ packages:
 
   glob@10.4.5:
     resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
+    deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
     hasBin: true
 
   glob@7.2.3:
     resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
-    deprecated: Glob versions prior to v9 are no longer supported
+    deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
 
   global-directory@4.0.1:
     resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
@@ -4673,6 +4742,7 @@ packages:
 
   lodash.isequal@4.5.0:
     resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
+    deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
 
   lodash.isplainobject@4.0.6:
     resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
@@ -4721,6 +4791,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==}
 
@@ -5112,6 +5185,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'}
@@ -5209,6 +5286,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==}
 
@@ -5734,6 +5814,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'}
@@ -5840,6 +5923,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'}
@@ -5848,6 +5934,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'}
@@ -6040,6 +6129,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==}
 
@@ -6047,6 +6139,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'}
@@ -6096,6 +6196,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'}
@@ -6266,6 +6370,7 @@ packages:
 
   uuid@10.0.0:
     resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
+    deprecated: uuid@10 and below is no longer supported.  For ESM codebases, update to uuid@latest.  For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
     hasBin: true
 
   v3-jsoneditor@0.0.6:
@@ -6287,6 +6392,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:
@@ -6318,6 +6428,7 @@ packages:
   vite-plugin-svg-icons-ng@1.3.1:
     resolution: {integrity: sha512-86oYE/MACMyqebcbuKpUTUJsptHoAfgnPS8680jQ0VigDEM2oxb59Nj2G+1FqA8CzyLCFftAKtwMAz8UGqiRfg==}
     engines: {node: ^18.0.0 || >=20.0.0}
+    deprecated: This version has incorrect functionality. Please upgrade to version 1.5.2 or above.
     peerDependencies:
       vite: '>=5.0.0'
 
@@ -6359,6 +6470,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'}
@@ -6425,6 +6561,7 @@ packages:
   vue-i18n@9.10.2:
     resolution: {integrity: sha512-ECJ8RIFd+3c1d3m1pctQ6ywG5Yj8Efy1oYoAKQ9neRdkLbuKLVeW4gaY5HPkD/9ssf1pOnUrmIFjx2/gkGxmEw==}
     engines: {node: '>= 16'}
+    deprecated: v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html
     peerDependencies:
       vue: ^3.0.0
 
@@ -6548,6 +6685,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==}
 
@@ -6706,7 +6848,7 @@ snapshots:
       '@babel/traverse': 7.25.9
       '@babel/types': 7.26.0
       convert-source-map: 2.0.0
-      debug: 4.3.7
+      debug: 4.4.3
       gensync: 1.0.0-beta.2
       json5: 2.2.3
       semver: 6.3.1
@@ -6765,7 +6907,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:
@@ -7371,7 +7513,7 @@ snapshots:
       '@babel/parser': 7.26.2
       '@babel/template': 7.25.9
       '@babel/types': 7.26.0
-      debug: 4.3.7
+      debug: 4.4.3
       globals: 11.12.0
     transitivePeerDependencies:
       - supports-color
@@ -7869,7 +8011,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
@@ -8542,7 +8684,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
@@ -8557,7 +8699,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
@@ -8572,7 +8714,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
@@ -8788,7 +8930,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:
@@ -8928,6 +9070,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
@@ -9246,6 +9417,10 @@ snapshots:
     dependencies:
       acorn: 8.14.0
 
+  acorn-walk@8.3.5:
+    dependencies:
+      acorn: 8.14.0
+
   acorn@8.14.0: {}
 
   aes-decrypter@3.1.3:
@@ -9331,6 +9506,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: {}
@@ -9369,7 +9546,7 @@ snapshots:
     transitivePeerDependencies:
       - debug
 
-  axios@1.7.8:
+  axios@1.7.8(debug@4.3.7):
     dependencies:
       follow-redirects: 1.15.9(debug@4.3.7)
       form-data: 4.0.1
@@ -9597,6 +9774,16 @@ snapshots:
       stackblur-canvas: 2.7.0
       svg-pathdata: 6.0.3
 
+  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
@@ -9612,6 +9799,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
@@ -10099,6 +10290,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: {}
@@ -10178,6 +10373,8 @@ snapshots:
 
   didi@9.0.2: {}
 
+  diff-sequences@29.6.3: {}
+
   dijkstrajs@1.0.3: {}
 
   dingtalk-jsapi@3.2.2:
@@ -10876,6 +11073,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
@@ -11616,6 +11815,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:
@@ -11781,7 +11984,7 @@ snapshots:
     dependencies:
       component-event: 0.2.1
       domify: 1.4.2
-      min-dash: 4.2.2
+      min-dash: 4.2.3
 
   min-dom@5.1.2:
     dependencies:
@@ -12066,6 +12269,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
@@ -12141,6 +12348,8 @@ snapshots:
 
   pathe@2.0.3: {}
 
+  pathval@1.1.1: {}
+
   perfect-debounce@1.0.0: {}
 
   performance-now@2.1.0: {}
@@ -12674,6 +12883,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: {}
@@ -12782,6 +12993,8 @@ snapshots:
 
   stable@0.1.8: {}
 
+  stackback@0.0.2: {}
+
   stackblur-canvas@2.7.0: {}
 
   static-extend@0.1.2:
@@ -12789,6 +13002,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:
@@ -13043,6 +13258,8 @@ snapshots:
 
   tiny-warning@1.0.3: {}
 
+  tinybench@2.9.0: {}
+
   tinyexec@0.3.1: {}
 
   tinyglobby@0.2.10:
@@ -13050,6 +13267,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
@@ -13096,6 +13317,8 @@ snapshots:
     dependencies:
       prelude-ls: 1.2.1
 
+  type-detect@4.1.0: {}
+
   type-fest@0.20.2: {}
 
   type@2.7.3: {}
@@ -13360,6 +13583,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
@@ -13444,6 +13684,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)
@@ -13641,6 +13914,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: {}
 
   word-wrap@1.2.5: {}

+ 186 - 35
src/components/mt-edit/components/layout/main-panel/index.vue

@@ -1,5 +1,5 @@
 <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" @wheel="onMouseWheel">
     <div
       id="mtCanvasArea"
       ref="canvasAreaRef"
@@ -69,12 +69,45 @@
       :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>
+      <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'
@@ -110,6 +143,15 @@ 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 } from '@element-plus/icons-vue'
+import {
+  calculateFitCanvas,
+  MAX_CANVAS_SCALE,
+  MIN_CANVAS_SCALE,
+  normalizeCanvasScale,
+  stepCanvasScale,
+  type CanvasScaleDirection
+} from '@/components/mt-edit/utils/canvas-scale'
 type MainPanelProps = {
   groupEnabled: boolean
   unGroupEnabled: boolean
@@ -120,6 +162,7 @@ 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>>()
@@ -141,6 +184,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)) {
@@ -282,7 +328,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()
@@ -431,7 +477,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,找到对应的需要解除组合的组件
@@ -462,7 +508,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) => {
   // 拿到画布的边界信息
@@ -566,7 +612,7 @@ const onMoveMouseUp = () => {
   //移动完毕之后隐藏参考线
   globalStore.guideCfg.x.display = false
   globalStore.guideCfg.y.display = false
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 /**
  * 缓存连线可以吸附的四个点
@@ -714,7 +760,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(
@@ -728,7 +774,7 @@ const onItemRotateDone = (item: IDoneJson) => {
     canvasAreaRef.value,
     globalStore.canvasCfg.scale
   )
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 const onAlignSelected = (
   type:
@@ -752,7 +798,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
@@ -787,6 +833,7 @@ const dragCanvasMouseMove = (move_x: number, move_y: number) => {
 const dragCanvasMouseUp = () => {
   if (globalStore.intention == 'runDragCanvas') {
     globalStore.setIntention('endDragCanvas')
+    cacheStore.addHistory()
   } else {
     if (shortcut_drag_select_item_id.value) {
       const find_item = globalStore.done_json.find(
@@ -908,7 +955,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()
 }
 /**
  * 删除
@@ -918,7 +965,7 @@ const onContextMenuDelete = () => {
     return
   }
   globalStore.deleteSelectedItems()
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 /**
  * 组合
@@ -947,7 +994,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) {
@@ -958,7 +1005,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) {
@@ -974,7 +1021,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) {
@@ -991,23 +1038,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()
 }
 /**
  * 右键菜单点击事件
@@ -1191,17 +1228,67 @@ const onKeydown = (e: KeyboardEvent) => {
       canvasAreaRef.value,
       globalStore.canvasCfg.scale
     )
-    cacheStore.addHistory(globalStore.done_json)
+    cacheStore.addHistory()
+  }
+}
+const updateCanvasScale = (scale: number, debounced = false) => {
+  const nextScale = normalizeCanvasScale(scale)
+  if (nextScale === globalStore.canvasCfg.scale) {
+    return false
+  }
+  globalStore.canvasCfg.scale = nextScale
+  if (debounced) {
+    cacheStore.addHistoryDebounced('canvas-scale')
+  } else {
+    cacheStore.addHistory()
   }
+  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
+  cacheStore.addHistory()
+}
+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),
+      true
+    )
+    if (!didScale) {
+      return
     }
     if (!is_need_recal_center.value) {
       return
@@ -1210,7 +1297,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 = {
@@ -1250,7 +1337,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()
@@ -1269,6 +1356,10 @@ defineExpose({
 })
 </script>
 <style scoped>
+.main-panel {
+  position: relative;
+}
+
 .canvasArea {
   position: relative;
   top: v-bind('globalStore.canvasCfg.drag_offset.y + "px"');
@@ -1283,6 +1374,66 @@ 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__icon:hover,
+.zoom-toolbar__text:hover,
+.zoom-toolbar__fit:hover {
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+}
+
+.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"');

+ 24 - 5
src/components/mt-edit/components/layout/right-aside/index.vue

@@ -18,15 +18,18 @@
           :item-json="selected_item_json"
           :done-json="globalStore.done_json"
           @update:item-json="onUpdateItemJson"
-          @add-history="onAddHistory">
+          @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
-          v-model:canvasCfg="globalStore.canvasCfg"
-          v-model:grid-cfg="globalStore.gridCfg" />
+          :canvas-cfg="globalStore.canvasCfg"
+          :grid-cfg="globalStore.gridCfg"
+          @update:canvas-cfg="onUpdateCanvasCfg"
+          @update:grid-cfg="onUpdateGridCfg" />
       </el-scrollbar>
     </main>
   </div>
@@ -34,7 +37,11 @@
 <script setup lang="ts">
 import { cacheStore } from '@/components/mt-edit/store/cache'
 import { globalStore } from '@/components/mt-edit/store/global'
-import type { IDoneJson } from '@/components/mt-edit/store/types'
+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'
@@ -55,9 +62,21 @@ 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) => {
+  globalStore.canvasCfg = canvasCfg
+  cacheStore.addHistoryDebounced('page-setting')
+}
+const onUpdateGridCfg = (gridCfg: IGlobalStoreGridCfg) => {
+  globalStore.gridCfg = gridCfg
+  cacheStore.addHistoryDebounced('page-setting')
 }
 const hasDeviceBindSlot = computed(() => {
   return !!slots.deviceBind

+ 4 - 2
src/components/mt-edit/components/layout/right-aside/page-setting.vue

@@ -51,6 +51,7 @@
                 size="small"
                 :step="0.1"
                 :min="0.1"
+                :max="4"
                 class="mx-5px" />
             </div>
           </el-select>
@@ -143,6 +144,7 @@ import {
 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
@@ -232,7 +234,7 @@ const canvas_size_scale_input = computed({
   set: (value) => {
     emits('update:canvasCfg', {
       ...rightAsideProps.canvasCfg,
-      scale: value
+      scale: normalizeCanvasScale(value)
     })
   }
 })
@@ -243,7 +245,7 @@ const canvas_size_scale = computed({
   set: (value) => {
     emits('update:canvasCfg', {
       ...rightAsideProps.canvasCfg,
-      scale: value
+      scale: normalizeCanvasScale(value)
     })
   }
 })

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

@@ -12,7 +12,8 @@
         v-model="attr_item.val"
         placeholder="Select"
         size="small"
-        :disabled="attr_item?.disabled">
+        :disabled="attr_item?.disabled"
+        @update:model-value="onPropertyChange">
         <el-option
           v-for="item in attr_item.options"
           :key="item.value"
@@ -22,32 +23,38 @@
       <el-input-number
         v-else-if="attr_item.type === 'number' && !attr_item.disabled"
         v-model="attr_item.val"
-        :disabled="attr_item?.disabled" />
+        :disabled="attr_item?.disabled"
+        @update:model-value="onPropertyChange" />
       <el-input
         v-else-if="attr_item.type === 'input' && !attr_item.disabled"
         v-model="attr_item.val"
-        :disabled="attr_item?.disabled" />
+        :disabled="attr_item?.disabled"
+        @update:model-value="onPropertyChange" />
       <el-input
         v-else-if="attr_item.type === 'textArea' && !attr_item.disabled"
         v-model="attr_item.val"
         :disabled="attr_item?.disabled"
-        type="textarea" />
+        type="textarea"
+        @update:model-value="onPropertyChange" />
       <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" />
+        :disabled="attr_item?.disabled"
+        @update:model-value="onPropertyChange" />
       <el-switch
         v-else-if="attr_item.type === 'switch' && !attr_item.disabled"
         v-model="attr_item.val"
-        :disabled="attr_item?.disabled" />
+        :disabled="attr_item?.disabled"
+        @update:model-value="onPropertyChange" />
       <div v-else-if="attr_item.type === 'jsonEdit' && !attr_item.disabled">
-        <json-edit v-model:contentObj="attr_item.val" />
+        <json-edit v-model:contentObj="attr_item.val" @update:content-obj="onPropertyChange" />
       </div>
       <status-color-rules-editor
         v-else-if="attr_item.type === 'statusColorRules' && !attr_item.disabled"
-        v-model="attr_item.val" />
+        v-model="attr_item.val"
+        @update:model-value="onPropertyChange" />
     </el-form-item>
   </div>
 </template>
@@ -69,6 +76,8 @@ type SelectItemPropsSettingProps = {
   propsInfo: ILeftAsideConfigItemPublicProps | undefined
 }
 const selectItemPropsSettingProps = withDefaults(defineProps<SelectItemPropsSettingProps>(), {})
+const emits = defineEmits(['historyChange'])
+const onPropertyChange = () => emits('historyChange')
 </script>
 <style scoped>
 .mt-edit-prop-form-item :deep(.el-form-item__label) {

+ 3 - 2
src/components/mt-edit/components/layout/right-aside/select-item-setting.vue

@@ -57,7 +57,8 @@
             </el-form-item>
             <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">
@@ -104,7 +105,7 @@ type SelectItemSettingProps = {
   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')

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

@@ -282,7 +282,7 @@ const onRightClick = (e: MouseEvent, item: IDoneJson) => {
 }
 const onLineMouseUp = () => {
   setTimeout(() => {
-    cacheStore.addHistory(globalStore.done_json)
+    cacheStore.addHistory()
   }, 1000)
 }
 </script>

+ 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
   }

+ 24 - 17
src/components/mt-edit/index.vue

@@ -18,8 +18,10 @@
           :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"
@@ -135,11 +137,13 @@ 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()
@@ -203,7 +207,7 @@ const is_unsaved = computed(
 )
 const onDeleteClick = () => {
   globalStore.deleteSelectedItems()
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 const onImportClick = () => {
   import_visible.value = true
@@ -221,7 +225,7 @@ const onDoneTreeUpdateSelectedIdHide = (id: string) => {
   if (item) {
     item.hide = !item.hide
     globalStore.setGlobalStoreDoneJson([...globalStore.done_json])
-    cacheStore.addHistory(globalStore.done_json)
+    cacheStore.addHistory()
   }
 }
 const onDoneTreeUpdateAllHide = (hide: boolean) => {
@@ -235,7 +239,7 @@ const onDoneTreeUpdateAllHide = (hide: boolean) => {
       hide
     }))
   )
-  cacheStore.addHistory(globalStore.done_json)
+  cacheStore.addHistory()
 }
 const onDoneTreeMoveLayer = ({ id, direction }: { id: string; direction: 'up' | 'down' }) => {
   const index = globalStore.done_json.findIndex((f) => f.id === id)
@@ -255,7 +259,7 @@ 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
@@ -277,7 +281,7 @@ const onChangeLayer = (type: 'top' | 'bottom') => {
   doneJson.splice(targetIndex, 0, selectedItem)
   globalStore.setGlobalStoreDoneJson(doneJson)
   globalStore.setSingleSelect(selectedId)
-  cacheStore.addHistory(doneJson)
+  cacheStore.addHistory()
 }
 const onAlignSelected = (
   type:
@@ -293,16 +297,16 @@ const onAlignSelected = (
   mainPanelRef.value?.onAlignSelected(type)
 }
 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('导入失败,请检查数据格式')
   }
@@ -326,8 +330,7 @@ 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
 }
@@ -335,6 +338,7 @@ const markSaved = (exportJson: IExportJson = getExportJson()) => {
   saved_snapshot.value = JSON.stringify(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()
@@ -346,21 +350,24 @@ const reset = () => {
   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(() => document.addEventListener('keydown', onGlobalKeyDown, true))
+onMounted(() => {
+  cacheStore.setHistoryLimit(mtEidtProps.historyLimit)
+  document.addEventListener('keydown', onGlobalKeyDown, true)
+})
 onBeforeUnmount(() => document.removeEventListener('keydown', onGlobalKeyDown, true))
 defineExpose({
   setImportJson,
   reset,
   markSaved,
-  hasUnsavedChanges
+  hasUnsavedChanges,
+  commitHistory
 })
 </script>
 <style scoped>

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

@@ -0,0 +1,173 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { cacheStore } from '../cache'
+import {
+  createDefaultCanvasCfg,
+  createDefaultGridCfg,
+  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'
+    globalStore.canvasCfg.drag_offset = { x: 35, y: 48 }
+    globalStore.gridCfg.enabled = false
+    globalStore.gridCfg.size = 24
+    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(globalStore.gridCfg).toEqual(createDefaultGridCfg())
+
+    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')
+    expect(globalStore.canvasCfg.drag_offset).toEqual({ x: 35, y: 48 })
+    expect(globalStore.gridCfg).toMatchObject({ enabled: false, size: 24 })
+  })
+
+  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.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.gridCfg.size = size
+      cacheStore.addHistory()
+    }
+
+    expect(cacheStore.history).toHaveLength(50)
+    expect(cacheStore.historyIndex).toBe(49)
+    expect(cacheStore.history[0].gridCfg.size).toBe(21)
+
+    cacheStore.setHistoryLimit(1000)
+    expect(cacheStore.historyLimit).toBe(100)
+  })
+})

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

@@ -1,6 +1,71 @@
-import { nextTick, reactive } from 'vue'
-import type { CacheBoundingBox, ICache, IDoneJson } from './types'
+import { reactive } from 'vue'
+import type {
+  CacheBoundingBox,
+  ICache,
+  IDoneJson,
+  IEditorHistorySnapshot,
+  IGlobalStoreCanvasCfg,
+  IGlobalStoreGridCfg
+} from './types'
 import { objectDeepClone } from '../utils'
+import { globalStore } from './global'
+
+const DEFAULT_HISTORY_LIMIT = 80
+const pendingHistoryTimers = new Map<string, ReturnType<typeof setTimeout>>()
+
+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: objectDeepClone<IGlobalStoreCanvasCfg>(globalStore.canvasCfg),
+    gridCfg: objectDeepClone<IGlobalStoreGridCfg>(globalStore.gridCfg)
+  }
+}
+
+const applyHistorySnapshot = (snapshot: IEditorHistorySnapshot) => {
+  const nextSnapshot = objectDeepClone<IEditorHistorySnapshot>(snapshot)
+  globalStore.setGlobalStoreDoneJson(nextSnapshot.doneJson)
+  globalStore.canvasCfg = nextSnapshot.canvasCfg
+  globalStore.gridCfg = nextSnapshot.gridCfg
+  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 +80,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
   }
 })

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

@@ -234,6 +234,12 @@ export interface ILeftAside {
   registerConfig: (title: string, config: ILeftAsideConfigItemPublic[]) => void
 }
 // 缓存配置
+export interface IEditorHistorySnapshot {
+  doneJson: IDoneJson[]
+  canvasCfg: IGlobalStoreCanvasCfg
+  gridCfg: IGlobalStoreGridCfg
+}
+
 export interface ICache {
   boundingBox: CacheBoundingBox[]
   setBoundingBox: (val: CacheBoundingBox[]) => void
@@ -241,9 +247,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 {

+ 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)
+  })
+})

+ 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)
+    }
+  }
+}

+ 5 - 0
src/views/maotu/components/DeviceBindPanel.vue

@@ -275,6 +275,9 @@ type Props = {
 const props = withDefaults(defineProps<Props>(), {
   devices: () => []
 })
+const emits = defineEmits<{
+  historyCommit: []
+}>()
 
 const drawerVisible = ref(false)
 const formRef = ref<FormInstance>()
@@ -481,12 +484,14 @@ async function saveBind(index: number) {
   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>

+ 4 - 1
src/views/maotu/edit.vue

@@ -234,7 +234,10 @@ 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>

+ 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']
+  }
+})