Selaa lähdekoodia

看板字体缩放问题

yanghao 1 kuukausi sitten
vanhempi
commit
cf7c6ec698

+ 1 - 0
package.json

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

+ 108 - 6
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
@@ -275,6 +275,9 @@ importers:
       web-storage-cache:
         specifier: ^1.1.1
         version: 1.1.1
+      xlsx:
+        specifier: ^0.18.5
+        version: 0.18.5
       xml-js:
         specifier: ^1.6.11
         version: 1.6.11
@@ -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==}
@@ -2541,9 +2564,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==}
@@ -2569,6 +2594,10 @@ packages:
     engines: {node: '>=0.4.0'}
     hasBin: true
 
+  adler-32@1.3.1:
+    resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
+    engines: {node: '>=0.8'}
+
   aes-decrypter@3.1.3:
     resolution: {integrity: sha512-VkG9g4BbhMBy+N5/XodDeV6F02chEk9IpgRTq/0bS80y4dzy79VH2Gtms02VXomf3HmyRe3yyJYkJ990ns+d6A==}
 
@@ -2866,6 +2895,10 @@ packages:
     resolution: {integrity: sha512-fKzMoMBwus3CWo1Uy8XJc4tqqn98RoRrGV6CsIkaNiQT5lOeHuMh4fOt+LXLzn2Wqtr4p/c2TOLz4xtu4oBlFA==}
     engines: {node: '>=12.0.0'}
 
+  cfb@1.2.2:
+    resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
+    engines: {node: '>=0.8'}
+
   chalk@1.1.3:
     resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==}
     engines: {node: '>=0.10.0'}
@@ -2933,6 +2966,10 @@ packages:
     resolution: {integrity: sha512-HcfnUFJwI2FvH73YWVbbMh7ObWxZiHIycEhv9ZEXy6e8ZKDjtZKbbYFUtsLN46HFXPvU5V2Uvc2d55Z//oFW5A==}
     deprecated: This is an accidentally mis-tagged instance of 5.65.7
 
+  codepage@1.15.0:
+    resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
+    engines: {node: '>=0.8'}
+
   collection-visit@1.0.0:
     resolution: {integrity: sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==}
     engines: {node: '>=0.10.0'}
@@ -3066,6 +3103,11 @@ packages:
       typescript:
         optional: true
 
+  crc-32@1.2.2:
+    resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
+    engines: {node: '>=0.8'}
+    hasBin: true
+
   crelt@1.0.6:
     resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
 
@@ -3081,6 +3123,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==}
@@ -3642,6 +3685,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==}
@@ -3888,6 +3932,10 @@ packages:
     resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==}
     engines: {node: '>= 6'}
 
+  frac@1.1.2:
+    resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
+    engines: {node: '>=0.8'}
+
   fraction.js@4.3.7:
     resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
 
@@ -3981,6 +4029,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 +4042,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 +4723,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==}
@@ -5833,6 +5884,10 @@ packages:
   sprintf-js@1.0.3:
     resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
 
+  ssf@0.11.2:
+    resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
+    engines: {node: '>=0.8'}
+
   ssr-window@3.0.0:
     resolution: {integrity: sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==}
 
@@ -6266,6 +6321,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:
@@ -6318,6 +6374,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'
 
@@ -6425,6 +6482,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
 
@@ -6551,10 +6609,18 @@ packages:
   wildcard@1.1.2:
     resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==}
 
+  wmf@1.0.2:
+    resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
+    engines: {node: '>=0.8'}
+
   word-wrap@1.2.5:
     resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
     engines: {node: '>=0.10.0'}
 
+  word@0.3.0:
+    resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
+    engines: {node: '>=0.8'}
+
   worker-factory@7.0.46:
     resolution: {integrity: sha512-Sr1hq2FMgNa04UVhYQacsw+i58BtMimzDb4+CqYphZ97OfefRpURu0UZ+JxMr/H36VVJBfuVkxTK7MytsanC3w==}
 
@@ -6602,6 +6668,11 @@ packages:
       utf-8-validate:
         optional: true
 
+  xlsx@0.18.5:
+    resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
+    engines: {node: '>=0.8'}
+    hasBin: true
+
   xml-js@1.6.11:
     resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
     hasBin: true
@@ -6706,7 +6777,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
@@ -7371,7 +7442,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
@@ -9248,6 +9319,8 @@ snapshots:
 
   acorn@8.14.0: {}
 
+  adler-32@1.3.1: {}
+
   aes-decrypter@3.1.3:
     dependencies:
       '@babel/runtime': 7.26.0
@@ -9369,7 +9442,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 +9670,11 @@ snapshots:
       stackblur-canvas: 2.7.0
       svg-pathdata: 6.0.3
 
+  cfb@1.2.2:
+    dependencies:
+      adler-32: 1.3.1
+      crc-32: 1.2.2
+
   chalk@1.1.3:
     dependencies:
       ansi-styles: 2.2.1
@@ -9691,6 +9769,8 @@ snapshots:
 
   codemirror@6.65.7: {}
 
+  codepage@1.15.0: {}
+
   collection-visit@1.0.0:
     dependencies:
       map-visit: 1.0.0
@@ -9806,6 +9886,8 @@ snapshots:
     optionalDependencies:
       typescript: 5.3.3
 
+  crc-32@1.2.2: {}
+
   crelt@1.0.6: {}
 
   cropperjs@1.6.2: {}
@@ -10824,6 +10906,8 @@ snapshots:
       combined-stream: 1.0.8
       mime-types: 2.1.35
 
+  frac@1.1.2: {}
+
   fraction.js@4.3.7: {}
 
   fragment-cache@0.2.1:
@@ -11781,7 +11865,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:
@@ -12778,6 +12862,10 @@ snapshots:
 
   sprintf-js@1.0.3: {}
 
+  ssf@0.11.2:
+    dependencies:
+      frac: 1.1.2
+
   ssr-window@3.0.0: {}
 
   stable@0.1.8: {}
@@ -13643,8 +13731,12 @@ snapshots:
 
   wildcard@1.1.2: {}
 
+  wmf@1.0.2: {}
+
   word-wrap@1.2.5: {}
 
+  word@0.3.0: {}
+
   worker-factory@7.0.46:
     dependencies:
       '@babel/runtime': 7.28.4
@@ -13705,6 +13797,16 @@ snapshots:
 
   ws@8.18.3: {}
 
+  xlsx@0.18.5:
+    dependencies:
+      adler-32: 1.3.1
+      cfb: 1.2.2
+      codepage: 1.15.0
+      crc-32: 1.2.2
+      ssf: 0.11.2
+      wmf: 1.0.2
+      word: 0.3.0
+
   xml-js@1.6.11:
     dependencies:
       sax: 1.4.1

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

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

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

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

+ 136 - 0
src/utils/useAlarmSound.ts

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

+ 9 - 1
src/utils/useSocketBus.ts

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

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

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

+ 90 - 220
src/views/oli-connection/monitoring-board/index.vue

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

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

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

+ 12 - 3
src/views/pms/video_center/haikang/index.vue

@@ -18,10 +18,8 @@ import {
 } from '@/utils/hikvisionH5Player'
 import { Loading, Location, Search, VideoCamera } from '@element-plus/icons-vue'
 import type { LoadFunction } from 'element-plus/es/components/tree/src/tree.type'
-import { IotDeviceApi } from '@/api/pms/device'
-import { IotOpeationFillApi } from '@/api/pms/iotopeationfill'
 
-defineOptions({ name: 'HikvisionVideoCenter' })
+defineOptions({ name: 'HaiKang' })
 
 const layoutTriggerRef = ref<HTMLElement>()
 const playerContainerRef = ref<HTMLElement>()
@@ -2185,6 +2183,17 @@ onBeforeUnmount(() => {
   inset: 0 !important;
 }
 
+/*
+ * 单窗口全屏会把 SDK 的 sub-wnd 从 flex 改为 block。draw-window 未设置坐标时会按静态位置
+ * 排到视频下方一整屏,导致电子放大的框选层无法命中;将它固定回目标视口左上角。
+ */
+.video-stage.is-single-window-fullscreen
+  .player-container
+  :deep(.sub-wnd.is-single-fullscreen-target > .draw-window) {
+  top: 0 !important;
+  left: 0 !important;
+}
+
 .video-stage.is-single-window-fullscreen .window-loading-layer {
   grid-template-rows: minmax(0, 1fr) !important;
   grid-template-columns: minmax(0, 1fr) !important;