Przeglądaj źródła

✨ feat(设备监控): 优化实时图表并完善监控看板功能

- 使用浅层响应式、二分查找和 LTTB 降采样优化实时数据处理性能
- 仅更新已选中的图表序列,减少高频数据上报造成的页面卡顿
- 新增属性阈值越界告警,异常持续期间每 3 秒播放一次提示音
- 详情页面新增监控数据导出,使用 SheetJS 生成 XLSX 文件
- 支持按已选属性导出,第一列为属性名称、首行为数据时间
- 为监控看板补充 KeepAlive 组件名称,保留筛选、分页和图表状态
Zimo 5 dni temu
rodzic
commit
98b364585f

+ 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

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

+ 65 - 19
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,
@@ -79,6 +80,8 @@ const emit = defineEmits<{
 const dimensions = ref<Dimensions[]>([])
 const selectedDimension = ref<Record<string, boolean>>({})
 const message = useMessage()
+const { setAlarmActive } = useAlarmSound()
+const exceededThresholds = new Set<string>()
 
 const currentOtherName = ref(props.otherName)
 
@@ -142,6 +145,7 @@ const handleMessageUpdate = (_topic: string, data: any) => {
   if (!updatedNames.size) return
 
   updateDimensionValues(valueMap)
+  updateThresholdAlarm(valueMap)
   scheduleRealtimeChartUpdate(Array.from(updatedNames))
 }
 
@@ -165,6 +169,9 @@ watch(isConnected, (newVal) => {
 
     subscribe(props.mqttUrl)
     // subscribe('/636/YF649/property/post')
+  } else {
+    exceededThresholds.clear()
+    setAlarmActive(false)
   }
 })
 
@@ -194,7 +201,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
@@ -296,16 +303,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 +367,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 +382,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 +410,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 +424,26 @@ function updateDimensionValues(valueMap: Map<string, number>) {
   })
 }
 
+function updateThresholdAlarm(valueMap: Map<string, number>) {
+  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}`
+    const exceeded = (min !== undefined && value < min) || (max !== undefined && value > max)
+
+    if (exceeded) {
+      exceededThresholds.add(alarmKey)
+    } else {
+      exceededThresholds.delete(alarmKey)
+    }
+  })
+
+  setAlarmActive(exceededThresholds.size > 0)
+}
+
 function getChartAnimationOptions() {
   return {
     animation: !chartRealtimeMode.value,
@@ -474,9 +513,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)
 }
@@ -637,6 +678,7 @@ function render() {
         id: item.name,
         name: item.name,
         type: 'line',
+        sampling: 'lttb',
         smooth: 0.24,
         showSymbol: false,
         connectNulls: true,
@@ -710,7 +752,11 @@ 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
   }

+ 2 - 0
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
 

+ 138 - 22
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) => {
@@ -290,12 +292,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 +307,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 +381,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 +396,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 +430,7 @@ function updateSingleSeries(name: string) {
       {
         id: name,
         name,
-        data: chartData.value[name]?.map((item) => mapChartPoint(item, name)) ?? []
+        data: buildSeriesData(name)
       }
     ]
   })
@@ -430,7 +449,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 +479,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 +602,7 @@ function renderChart() {
         id: item.name,
         name: item.name,
         type: 'line',
+        sampling: 'lttb',
         smooth: 0.24,
         showSymbol: false,
         connectNulls: true,
@@ -603,7 +625,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 +734,11 @@ 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 +825,88 @@ 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({
@@ -1040,6 +1148,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