Kaynağa Gözat

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

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

+ 1 - 0
package.json

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

+ 108 - 6
pnpm-lock.yaml

@@ -82,7 +82,7 @@ importers:
         version: 4.1.1
         version: 4.1.1
       axios:
       axios:
         specifier: ^1.6.8
         specifier: ^1.6.8
-        version: 1.7.8
+        version: 1.7.8(debug@4.3.7)
       benz-amr-recorder:
       benz-amr-recorder:
         specifier: ^1.1.5
         specifier: ^1.1.5
         version: 1.1.5
         version: 1.1.5
@@ -275,6 +275,9 @@ importers:
       web-storage-cache:
       web-storage-cache:
         specifier: ^1.1.1
         specifier: ^1.1.1
         version: 1.1.1
         version: 1.1.1
+      xlsx:
+        specifier: ^0.18.5
+        version: 0.18.5
       xml-js:
       xml-js:
         specifier: ^1.6.11
         specifier: ^1.6.11
         version: 1.6.11
         version: 1.6.11
@@ -1588,36 +1591,42 @@ packages:
     engines: {node: '>= 10.0.0'}
     engines: {node: '>= 10.0.0'}
     cpu: [arm]
     cpu: [arm]
     os: [linux]
     os: [linux]
+    libc: [glibc]
 
 
   '@parcel/watcher-linux-arm-musl@2.5.0':
   '@parcel/watcher-linux-arm-musl@2.5.0':
     resolution: {integrity: sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==}
     resolution: {integrity: sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==}
     engines: {node: '>= 10.0.0'}
     engines: {node: '>= 10.0.0'}
     cpu: [arm]
     cpu: [arm]
     os: [linux]
     os: [linux]
+    libc: [musl]
 
 
   '@parcel/watcher-linux-arm64-glibc@2.5.0':
   '@parcel/watcher-linux-arm64-glibc@2.5.0':
     resolution: {integrity: sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==}
     resolution: {integrity: sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==}
     engines: {node: '>= 10.0.0'}
     engines: {node: '>= 10.0.0'}
     cpu: [arm64]
     cpu: [arm64]
     os: [linux]
     os: [linux]
+    libc: [glibc]
 
 
   '@parcel/watcher-linux-arm64-musl@2.5.0':
   '@parcel/watcher-linux-arm64-musl@2.5.0':
     resolution: {integrity: sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==}
     resolution: {integrity: sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==}
     engines: {node: '>= 10.0.0'}
     engines: {node: '>= 10.0.0'}
     cpu: [arm64]
     cpu: [arm64]
     os: [linux]
     os: [linux]
+    libc: [musl]
 
 
   '@parcel/watcher-linux-x64-glibc@2.5.0':
   '@parcel/watcher-linux-x64-glibc@2.5.0':
     resolution: {integrity: sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==}
     resolution: {integrity: sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==}
     engines: {node: '>= 10.0.0'}
     engines: {node: '>= 10.0.0'}
     cpu: [x64]
     cpu: [x64]
     os: [linux]
     os: [linux]
+    libc: [glibc]
 
 
   '@parcel/watcher-linux-x64-musl@2.5.0':
   '@parcel/watcher-linux-x64-musl@2.5.0':
     resolution: {integrity: sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==}
     resolution: {integrity: sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==}
     engines: {node: '>= 10.0.0'}
     engines: {node: '>= 10.0.0'}
     cpu: [x64]
     cpu: [x64]
     os: [linux]
     os: [linux]
+    libc: [musl]
 
 
   '@parcel/watcher-win32-arm64@2.5.0':
   '@parcel/watcher-win32-arm64@2.5.0':
     resolution: {integrity: sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==}
     resolution: {integrity: sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==}
@@ -1726,46 +1735,55 @@ packages:
     resolution: {integrity: sha512-9OwUnK/xKw6DyRlgx8UizeqRFOfi9mf5TYCw1uolDaJSbUmBxP85DE6T4ouCMoN6pXw8ZoTeZCSEfSaYo+/s1w==}
     resolution: {integrity: sha512-9OwUnK/xKw6DyRlgx8UizeqRFOfi9mf5TYCw1uolDaJSbUmBxP85DE6T4ouCMoN6pXw8ZoTeZCSEfSaYo+/s1w==}
     cpu: [arm]
     cpu: [arm]
     os: [linux]
     os: [linux]
+    libc: [glibc]
 
 
   '@rollup/rollup-linux-arm-musleabihf@4.27.4':
   '@rollup/rollup-linux-arm-musleabihf@4.27.4':
     resolution: {integrity: sha512-Vgdo4fpuphS9V24WOV+KwkCVJ72u7idTgQaBoLRD0UxBAWTF9GWurJO9YD9yh00BzbkhpeXtm6na+MvJU7Z73A==}
     resolution: {integrity: sha512-Vgdo4fpuphS9V24WOV+KwkCVJ72u7idTgQaBoLRD0UxBAWTF9GWurJO9YD9yh00BzbkhpeXtm6na+MvJU7Z73A==}
     cpu: [arm]
     cpu: [arm]
     os: [linux]
     os: [linux]
+    libc: [musl]
 
 
   '@rollup/rollup-linux-arm64-gnu@4.27.4':
   '@rollup/rollup-linux-arm64-gnu@4.27.4':
     resolution: {integrity: sha512-pleyNgyd1kkBkw2kOqlBx+0atfIIkkExOTiifoODo6qKDSpnc6WzUY5RhHdmTdIJXBdSnh6JknnYTtmQyobrVg==}
     resolution: {integrity: sha512-pleyNgyd1kkBkw2kOqlBx+0atfIIkkExOTiifoODo6qKDSpnc6WzUY5RhHdmTdIJXBdSnh6JknnYTtmQyobrVg==}
     cpu: [arm64]
     cpu: [arm64]
     os: [linux]
     os: [linux]
+    libc: [glibc]
 
 
   '@rollup/rollup-linux-arm64-musl@4.27.4':
   '@rollup/rollup-linux-arm64-musl@4.27.4':
     resolution: {integrity: sha512-caluiUXvUuVyCHr5DxL8ohaaFFzPGmgmMvwmqAITMpV/Q+tPoaHZ/PWa3t8B2WyoRcIIuu1hkaW5KkeTDNSnMA==}
     resolution: {integrity: sha512-caluiUXvUuVyCHr5DxL8ohaaFFzPGmgmMvwmqAITMpV/Q+tPoaHZ/PWa3t8B2WyoRcIIuu1hkaW5KkeTDNSnMA==}
     cpu: [arm64]
     cpu: [arm64]
     os: [linux]
     os: [linux]
+    libc: [musl]
 
 
   '@rollup/rollup-linux-powerpc64le-gnu@4.27.4':
   '@rollup/rollup-linux-powerpc64le-gnu@4.27.4':
     resolution: {integrity: sha512-FScrpHrO60hARyHh7s1zHE97u0KlT/RECzCKAdmI+LEoC1eDh/RDji9JgFqyO+wPDb86Oa/sXkily1+oi4FzJQ==}
     resolution: {integrity: sha512-FScrpHrO60hARyHh7s1zHE97u0KlT/RECzCKAdmI+LEoC1eDh/RDji9JgFqyO+wPDb86Oa/sXkily1+oi4FzJQ==}
     cpu: [ppc64]
     cpu: [ppc64]
     os: [linux]
     os: [linux]
+    libc: [glibc]
 
 
   '@rollup/rollup-linux-riscv64-gnu@4.27.4':
   '@rollup/rollup-linux-riscv64-gnu@4.27.4':
     resolution: {integrity: sha512-qyyprhyGb7+RBfMPeww9FlHwKkCXdKHeGgSqmIXw9VSUtvyFZ6WZRtnxgbuz76FK7LyoN8t/eINRbPUcvXB5fw==}
     resolution: {integrity: sha512-qyyprhyGb7+RBfMPeww9FlHwKkCXdKHeGgSqmIXw9VSUtvyFZ6WZRtnxgbuz76FK7LyoN8t/eINRbPUcvXB5fw==}
     cpu: [riscv64]
     cpu: [riscv64]
     os: [linux]
     os: [linux]
+    libc: [glibc]
 
 
   '@rollup/rollup-linux-s390x-gnu@4.27.4':
   '@rollup/rollup-linux-s390x-gnu@4.27.4':
     resolution: {integrity: sha512-PFz+y2kb6tbh7m3A7nA9++eInGcDVZUACulf/KzDtovvdTizHpZaJty7Gp0lFwSQcrnebHOqxF1MaKZd7psVRg==}
     resolution: {integrity: sha512-PFz+y2kb6tbh7m3A7nA9++eInGcDVZUACulf/KzDtovvdTizHpZaJty7Gp0lFwSQcrnebHOqxF1MaKZd7psVRg==}
     cpu: [s390x]
     cpu: [s390x]
     os: [linux]
     os: [linux]
+    libc: [glibc]
 
 
   '@rollup/rollup-linux-x64-gnu@4.27.4':
   '@rollup/rollup-linux-x64-gnu@4.27.4':
     resolution: {integrity: sha512-Ni8mMtfo+o/G7DVtweXXV/Ol2TFf63KYjTtoZ5f078AUgJTmaIJnj4JFU7TK/9SVWTaSJGxPi5zMDgK4w+Ez7Q==}
     resolution: {integrity: sha512-Ni8mMtfo+o/G7DVtweXXV/Ol2TFf63KYjTtoZ5f078AUgJTmaIJnj4JFU7TK/9SVWTaSJGxPi5zMDgK4w+Ez7Q==}
     cpu: [x64]
     cpu: [x64]
     os: [linux]
     os: [linux]
+    libc: [glibc]
 
 
   '@rollup/rollup-linux-x64-musl@4.27.4':
   '@rollup/rollup-linux-x64-musl@4.27.4':
     resolution: {integrity: sha512-5AeeAF1PB9TUzD+3cROzFTnAJAcVUGLuR8ng0E0WXGkYhp6RD6L+6szYVX+64Rs0r72019KHZS1ka1q+zU/wUw==}
     resolution: {integrity: sha512-5AeeAF1PB9TUzD+3cROzFTnAJAcVUGLuR8ng0E0WXGkYhp6RD6L+6szYVX+64Rs0r72019KHZS1ka1q+zU/wUw==}
     cpu: [x64]
     cpu: [x64]
     os: [linux]
     os: [linux]
+    libc: [musl]
 
 
   '@rollup/rollup-win32-arm64-msvc@4.27.4':
   '@rollup/rollup-win32-arm64-msvc@4.27.4':
     resolution: {integrity: sha512-yOpVsA4K5qVwu2CaS3hHxluWIK5HQTjNV4tWjQXluMiiiu4pJj4BN98CvxohNCpcjMeTXk/ZMJBRbgRg8HBB6A==}
     resolution: {integrity: sha512-yOpVsA4K5qVwu2CaS3hHxluWIK5HQTjNV4tWjQXluMiiiu4pJj4BN98CvxohNCpcjMeTXk/ZMJBRbgRg8HBB6A==}
@@ -1811,24 +1829,28 @@ packages:
     engines: {node: '>=10'}
     engines: {node: '>=10'}
     cpu: [arm64]
     cpu: [arm64]
     os: [linux]
     os: [linux]
+    libc: [glibc]
 
 
   '@swc/core-linux-arm64-musl@1.9.3':
   '@swc/core-linux-arm64-musl@1.9.3':
     resolution: {integrity: sha512-tzVH480RY6RbMl/QRgh5HK3zn1ZTFsThuxDGo6Iuk1MdwIbdFYUY034heWUTI4u3Db97ArKh0hNL0xhO3+PZdg==}
     resolution: {integrity: sha512-tzVH480RY6RbMl/QRgh5HK3zn1ZTFsThuxDGo6Iuk1MdwIbdFYUY034heWUTI4u3Db97ArKh0hNL0xhO3+PZdg==}
     engines: {node: '>=10'}
     engines: {node: '>=10'}
     cpu: [arm64]
     cpu: [arm64]
     os: [linux]
     os: [linux]
+    libc: [musl]
 
 
   '@swc/core-linux-x64-gnu@1.9.3':
   '@swc/core-linux-x64-gnu@1.9.3':
     resolution: {integrity: sha512-ivXXBRDXDc9k4cdv10R21ccBmGebVOwKXT/UdH1PhxUn9m/h8erAWjz5pcELwjiMf27WokqPgaWVfaclDbgE+w==}
     resolution: {integrity: sha512-ivXXBRDXDc9k4cdv10R21ccBmGebVOwKXT/UdH1PhxUn9m/h8erAWjz5pcELwjiMf27WokqPgaWVfaclDbgE+w==}
     engines: {node: '>=10'}
     engines: {node: '>=10'}
     cpu: [x64]
     cpu: [x64]
     os: [linux]
     os: [linux]
+    libc: [glibc]
 
 
   '@swc/core-linux-x64-musl@1.9.3':
   '@swc/core-linux-x64-musl@1.9.3':
     resolution: {integrity: sha512-ILsGMgfnOz1HwdDz+ZgEuomIwkP1PHT6maigZxaCIuC6OPEhKE8uYna22uU63XvYcLQvZYDzpR3ms47WQPuNEg==}
     resolution: {integrity: sha512-ILsGMgfnOz1HwdDz+ZgEuomIwkP1PHT6maigZxaCIuC6OPEhKE8uYna22uU63XvYcLQvZYDzpR3ms47WQPuNEg==}
     engines: {node: '>=10'}
     engines: {node: '>=10'}
     cpu: [x64]
     cpu: [x64]
     os: [linux]
     os: [linux]
+    libc: [musl]
 
 
   '@swc/core-win32-arm64-msvc@1.9.3':
   '@swc/core-win32-arm64-msvc@1.9.3':
     resolution: {integrity: sha512-e+XmltDVIHieUnNJHtspn6B+PCcFOMYXNJB1GqoCcyinkEIQNwC8KtWgMqUucUbEWJkPc35NHy9k8aCXRmw9Kg==}
     resolution: {integrity: sha512-e+XmltDVIHieUnNJHtspn6B+PCcFOMYXNJB1GqoCcyinkEIQNwC8KtWgMqUucUbEWJkPc35NHy9k8aCXRmw9Kg==}
@@ -2168,6 +2190,7 @@ packages:
 
 
   '@ungap/structured-clone@1.2.0':
   '@ungap/structured-clone@1.2.0':
     resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
     resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
+    deprecated: Potential CWE-502 - Update to 1.3.1 or higher
 
 
   '@unocss/astro@0.58.9':
   '@unocss/astro@0.58.9':
     resolution: {integrity: sha512-VWfHNC0EfawFxLfb3uI+QcMGBN+ju+BYtutzeZTjilLKj31X2UpqIh8fepixL6ljgZzB3fweqg2xtUMC0gMnoQ==}
     resolution: {integrity: sha512-VWfHNC0EfawFxLfb3uI+QcMGBN+ju+BYtutzeZTjilLKj31X2UpqIh8fepixL6ljgZzB3fweqg2xtUMC0gMnoQ==}
@@ -2541,9 +2564,11 @@ packages:
   '@xmldom/xmldom@0.8.10':
   '@xmldom/xmldom@0.8.10':
     resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==}
     resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==}
     engines: {node: '>=10.0.0'}
     engines: {node: '>=10.0.0'}
+    deprecated: this version has critical issues, please update to the latest version
 
 
   '@zumer/snapdom@2.0.2':
   '@zumer/snapdom@2.0.2':
     resolution: {integrity: sha512-W6quT4lMcPu8Q9O/Q6witSfc6/+xuY8C8yDoHug/+o7zYKCNE/e0I3//XsWDkyq9C0mDE0TAWF/8bwCR7x3gHQ==}
     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':
   '@zxcvbn-ts/core@3.0.4':
     resolution: {integrity: sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==}
     resolution: {integrity: sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==}
@@ -2569,6 +2594,10 @@ packages:
     engines: {node: '>=0.4.0'}
     engines: {node: '>=0.4.0'}
     hasBin: true
     hasBin: true
 
 
+  adler-32@1.3.1:
+    resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
+    engines: {node: '>=0.8'}
+
   aes-decrypter@3.1.3:
   aes-decrypter@3.1.3:
     resolution: {integrity: sha512-VkG9g4BbhMBy+N5/XodDeV6F02chEk9IpgRTq/0bS80y4dzy79VH2Gtms02VXomf3HmyRe3yyJYkJ990ns+d6A==}
     resolution: {integrity: sha512-VkG9g4BbhMBy+N5/XodDeV6F02chEk9IpgRTq/0bS80y4dzy79VH2Gtms02VXomf3HmyRe3yyJYkJ990ns+d6A==}
 
 
@@ -2866,6 +2895,10 @@ packages:
     resolution: {integrity: sha512-fKzMoMBwus3CWo1Uy8XJc4tqqn98RoRrGV6CsIkaNiQT5lOeHuMh4fOt+LXLzn2Wqtr4p/c2TOLz4xtu4oBlFA==}
     resolution: {integrity: sha512-fKzMoMBwus3CWo1Uy8XJc4tqqn98RoRrGV6CsIkaNiQT5lOeHuMh4fOt+LXLzn2Wqtr4p/c2TOLz4xtu4oBlFA==}
     engines: {node: '>=12.0.0'}
     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:
   chalk@1.1.3:
     resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==}
     resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==}
     engines: {node: '>=0.10.0'}
     engines: {node: '>=0.10.0'}
@@ -2933,6 +2966,10 @@ packages:
     resolution: {integrity: sha512-HcfnUFJwI2FvH73YWVbbMh7ObWxZiHIycEhv9ZEXy6e8ZKDjtZKbbYFUtsLN46HFXPvU5V2Uvc2d55Z//oFW5A==}
     resolution: {integrity: sha512-HcfnUFJwI2FvH73YWVbbMh7ObWxZiHIycEhv9ZEXy6e8ZKDjtZKbbYFUtsLN46HFXPvU5V2Uvc2d55Z//oFW5A==}
     deprecated: This is an accidentally mis-tagged instance of 5.65.7
     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:
   collection-visit@1.0.0:
     resolution: {integrity: sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==}
     resolution: {integrity: sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==}
     engines: {node: '>=0.10.0'}
     engines: {node: '>=0.10.0'}
@@ -3066,6 +3103,11 @@ packages:
       typescript:
       typescript:
         optional: true
         optional: true
 
 
+  crc-32@1.2.2:
+    resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
+    engines: {node: '>=0.8'}
+    hasBin: true
+
   crelt@1.0.6:
   crelt@1.0.6:
     resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
     resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
 
 
@@ -3081,6 +3123,7 @@ packages:
 
 
   crypto-js@4.2.0:
   crypto-js@4.2.0:
     resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
     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:
   css-functions-list@3.2.3:
     resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==}
     resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==}
@@ -3642,6 +3685,7 @@ packages:
   eslint-define-config@2.1.0:
   eslint-define-config@2.1.0:
     resolution: {integrity: sha512-QUp6pM9pjKEVannNAbSJNeRuYwW3LshejfyBBpjeMGaJjaDUpVps4C6KVR8R7dWZnD3i0synmrE36znjTkJvdQ==}
     resolution: {integrity: sha512-QUp6pM9pjKEVannNAbSJNeRuYwW3LshejfyBBpjeMGaJjaDUpVps4C6KVR8R7dWZnD3i0synmrE36znjTkJvdQ==}
     engines: {node: '>=18.0.0', npm: '>=9.0.0', pnpm: '>=8.6.0'}
     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:
   eslint-plugin-prettier@5.2.1:
     resolution: {integrity: sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==}
     resolution: {integrity: sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==}
@@ -3888,6 +3932,10 @@ packages:
     resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==}
     resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==}
     engines: {node: '>= 6'}
     engines: {node: '>= 6'}
 
 
+  frac@1.1.2:
+    resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
+    engines: {node: '>=0.8'}
+
   fraction.js@4.3.7:
   fraction.js@4.3.7:
     resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
     resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
 
 
@@ -3981,6 +4029,7 @@ packages:
   git-raw-commits@4.0.0:
   git-raw-commits@4.0.0:
     resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==}
     resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==}
     engines: {node: '>=16'}
     engines: {node: '>=16'}
+    deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.
     hasBin: true
     hasBin: true
 
 
   glob-parent@5.1.2:
   glob-parent@5.1.2:
@@ -3993,11 +4042,12 @@ packages:
 
 
   glob@10.4.5:
   glob@10.4.5:
     resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
     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
     hasBin: true
 
 
   glob@7.2.3:
   glob@7.2.3:
     resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
     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:
   global-directory@4.0.1:
     resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
     resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
@@ -4673,6 +4723,7 @@ packages:
 
 
   lodash.isequal@4.5.0:
   lodash.isequal@4.5.0:
     resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
     resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
+    deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
 
 
   lodash.isplainobject@4.0.6:
   lodash.isplainobject@4.0.6:
     resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
     resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
@@ -5833,6 +5884,10 @@ packages:
   sprintf-js@1.0.3:
   sprintf-js@1.0.3:
     resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
     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:
   ssr-window@3.0.0:
     resolution: {integrity: sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==}
     resolution: {integrity: sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==}
 
 
@@ -6266,6 +6321,7 @@ packages:
 
 
   uuid@10.0.0:
   uuid@10.0.0:
     resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
     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
     hasBin: true
 
 
   v3-jsoneditor@0.0.6:
   v3-jsoneditor@0.0.6:
@@ -6318,6 +6374,7 @@ packages:
   vite-plugin-svg-icons-ng@1.3.1:
   vite-plugin-svg-icons-ng@1.3.1:
     resolution: {integrity: sha512-86oYE/MACMyqebcbuKpUTUJsptHoAfgnPS8680jQ0VigDEM2oxb59Nj2G+1FqA8CzyLCFftAKtwMAz8UGqiRfg==}
     resolution: {integrity: sha512-86oYE/MACMyqebcbuKpUTUJsptHoAfgnPS8680jQ0VigDEM2oxb59Nj2G+1FqA8CzyLCFftAKtwMAz8UGqiRfg==}
     engines: {node: ^18.0.0 || >=20.0.0}
     engines: {node: ^18.0.0 || >=20.0.0}
+    deprecated: This version has incorrect functionality. Please upgrade to version 1.5.2 or above.
     peerDependencies:
     peerDependencies:
       vite: '>=5.0.0'
       vite: '>=5.0.0'
 
 
@@ -6425,6 +6482,7 @@ packages:
   vue-i18n@9.10.2:
   vue-i18n@9.10.2:
     resolution: {integrity: sha512-ECJ8RIFd+3c1d3m1pctQ6ywG5Yj8Efy1oYoAKQ9neRdkLbuKLVeW4gaY5HPkD/9ssf1pOnUrmIFjx2/gkGxmEw==}
     resolution: {integrity: sha512-ECJ8RIFd+3c1d3m1pctQ6ywG5Yj8Efy1oYoAKQ9neRdkLbuKLVeW4gaY5HPkD/9ssf1pOnUrmIFjx2/gkGxmEw==}
     engines: {node: '>= 16'}
     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:
     peerDependencies:
       vue: ^3.0.0
       vue: ^3.0.0
 
 
@@ -6551,10 +6609,18 @@ packages:
   wildcard@1.1.2:
   wildcard@1.1.2:
     resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==}
     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:
   word-wrap@1.2.5:
     resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
     resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
     engines: {node: '>=0.10.0'}
     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:
   worker-factory@7.0.46:
     resolution: {integrity: sha512-Sr1hq2FMgNa04UVhYQacsw+i58BtMimzDb4+CqYphZ97OfefRpURu0UZ+JxMr/H36VVJBfuVkxTK7MytsanC3w==}
     resolution: {integrity: sha512-Sr1hq2FMgNa04UVhYQacsw+i58BtMimzDb4+CqYphZ97OfefRpURu0UZ+JxMr/H36VVJBfuVkxTK7MytsanC3w==}
 
 
@@ -6602,6 +6668,11 @@ packages:
       utf-8-validate:
       utf-8-validate:
         optional: true
         optional: true
 
 
+  xlsx@0.18.5:
+    resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
+    engines: {node: '>=0.8'}
+    hasBin: true
+
   xml-js@1.6.11:
   xml-js@1.6.11:
     resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
     resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
     hasBin: true
     hasBin: true
@@ -6706,7 +6777,7 @@ snapshots:
       '@babel/traverse': 7.25.9
       '@babel/traverse': 7.25.9
       '@babel/types': 7.26.0
       '@babel/types': 7.26.0
       convert-source-map: 2.0.0
       convert-source-map: 2.0.0
-      debug: 4.3.7
+      debug: 4.4.3
       gensync: 1.0.0-beta.2
       gensync: 1.0.0-beta.2
       json5: 2.2.3
       json5: 2.2.3
       semver: 6.3.1
       semver: 6.3.1
@@ -7371,7 +7442,7 @@ snapshots:
       '@babel/parser': 7.26.2
       '@babel/parser': 7.26.2
       '@babel/template': 7.25.9
       '@babel/template': 7.25.9
       '@babel/types': 7.26.0
       '@babel/types': 7.26.0
-      debug: 4.3.7
+      debug: 4.4.3
       globals: 11.12.0
       globals: 11.12.0
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
@@ -9248,6 +9319,8 @@ snapshots:
 
 
   acorn@8.14.0: {}
   acorn@8.14.0: {}
 
 
+  adler-32@1.3.1: {}
+
   aes-decrypter@3.1.3:
   aes-decrypter@3.1.3:
     dependencies:
     dependencies:
       '@babel/runtime': 7.26.0
       '@babel/runtime': 7.26.0
@@ -9369,7 +9442,7 @@ snapshots:
     transitivePeerDependencies:
     transitivePeerDependencies:
       - debug
       - debug
 
 
-  axios@1.7.8:
+  axios@1.7.8(debug@4.3.7):
     dependencies:
     dependencies:
       follow-redirects: 1.15.9(debug@4.3.7)
       follow-redirects: 1.15.9(debug@4.3.7)
       form-data: 4.0.1
       form-data: 4.0.1
@@ -9597,6 +9670,11 @@ snapshots:
       stackblur-canvas: 2.7.0
       stackblur-canvas: 2.7.0
       svg-pathdata: 6.0.3
       svg-pathdata: 6.0.3
 
 
+  cfb@1.2.2:
+    dependencies:
+      adler-32: 1.3.1
+      crc-32: 1.2.2
+
   chalk@1.1.3:
   chalk@1.1.3:
     dependencies:
     dependencies:
       ansi-styles: 2.2.1
       ansi-styles: 2.2.1
@@ -9691,6 +9769,8 @@ snapshots:
 
 
   codemirror@6.65.7: {}
   codemirror@6.65.7: {}
 
 
+  codepage@1.15.0: {}
+
   collection-visit@1.0.0:
   collection-visit@1.0.0:
     dependencies:
     dependencies:
       map-visit: 1.0.0
       map-visit: 1.0.0
@@ -9806,6 +9886,8 @@ snapshots:
     optionalDependencies:
     optionalDependencies:
       typescript: 5.3.3
       typescript: 5.3.3
 
 
+  crc-32@1.2.2: {}
+
   crelt@1.0.6: {}
   crelt@1.0.6: {}
 
 
   cropperjs@1.6.2: {}
   cropperjs@1.6.2: {}
@@ -10824,6 +10906,8 @@ snapshots:
       combined-stream: 1.0.8
       combined-stream: 1.0.8
       mime-types: 2.1.35
       mime-types: 2.1.35
 
 
+  frac@1.1.2: {}
+
   fraction.js@4.3.7: {}
   fraction.js@4.3.7: {}
 
 
   fragment-cache@0.2.1:
   fragment-cache@0.2.1:
@@ -11781,7 +11865,7 @@ snapshots:
     dependencies:
     dependencies:
       component-event: 0.2.1
       component-event: 0.2.1
       domify: 1.4.2
       domify: 1.4.2
-      min-dash: 4.2.2
+      min-dash: 4.2.3
 
 
   min-dom@5.1.2:
   min-dom@5.1.2:
     dependencies:
     dependencies:
@@ -12778,6 +12862,10 @@ snapshots:
 
 
   sprintf-js@1.0.3: {}
   sprintf-js@1.0.3: {}
 
 
+  ssf@0.11.2:
+    dependencies:
+      frac: 1.1.2
+
   ssr-window@3.0.0: {}
   ssr-window@3.0.0: {}
 
 
   stable@0.1.8: {}
   stable@0.1.8: {}
@@ -13643,8 +13731,12 @@ snapshots:
 
 
   wildcard@1.1.2: {}
   wildcard@1.1.2: {}
 
 
+  wmf@1.0.2: {}
+
   word-wrap@1.2.5: {}
   word-wrap@1.2.5: {}
 
 
+  word@0.3.0: {}
+
   worker-factory@7.0.46:
   worker-factory@7.0.46:
     dependencies:
     dependencies:
       '@babel/runtime': 7.28.4
       '@babel/runtime': 7.28.4
@@ -13705,6 +13797,16 @@ snapshots:
 
 
   ws@8.18.3: {}
   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:
   xml-js@1.6.11:
     dependencies:
     dependencies:
       sax: 1.4.1
       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 { IotDeviceApi } from '@/api/pms/device'
 import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
 import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
 import { useTableComponents } from '@/components/ZmTable/useTableComponents'
 import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { useAlarmSound } from '@/utils/useAlarmSound'
 import { useMqtt } from '@/utils/useMqtt'
 import { useMqtt } from '@/utils/useMqtt'
 import {
 import {
   Dimensions,
   Dimensions,
@@ -79,6 +80,8 @@ const emit = defineEmits<{
 const dimensions = ref<Dimensions[]>([])
 const dimensions = ref<Dimensions[]>([])
 const selectedDimension = ref<Record<string, boolean>>({})
 const selectedDimension = ref<Record<string, boolean>>({})
 const message = useMessage()
 const message = useMessage()
+const { setAlarmActive } = useAlarmSound()
+const exceededThresholds = new Set<string>()
 
 
 const currentOtherName = ref(props.otherName)
 const currentOtherName = ref(props.otherName)
 
 
@@ -142,6 +145,7 @@ const handleMessageUpdate = (_topic: string, data: any) => {
   if (!updatedNames.size) return
   if (!updatedNames.size) return
 
 
   updateDimensionValues(valueMap)
   updateDimensionValues(valueMap)
+  updateThresholdAlarm(valueMap)
   scheduleRealtimeChartUpdate(Array.from(updatedNames))
   scheduleRealtimeChartUpdate(Array.from(updatedNames))
 }
 }
 
 
@@ -165,6 +169,9 @@ watch(isConnected, (newVal) => {
 
 
     subscribe(props.mqttUrl)
     subscribe(props.mqttUrl)
     // subscribe('/636/YF649/property/post')
     // subscribe('/636/YF649/property/post')
+  } else {
+    exceededThresholds.clear()
+    setAlarmActive(false)
   }
   }
 })
 })
 
 
@@ -194,7 +201,7 @@ interface ChartData {
   [key: Dimensions['name']]: ChartPoint[]
   [key: Dimensions['name']]: ChartPoint[]
 }
 }
 
 
-const chartData = ref<ChartData>({})
+const chartData = shallowRef<ChartData>({})
 
 
 const chartRef = ref<HTMLDivElement | null>(null)
 const chartRef = ref<HTMLDivElement | null>(null)
 let chart: echarts.ECharts | null = null
 let chart: echarts.ECharts | null = null
@@ -296,16 +303,26 @@ function getLatestTimestamp() {
   let latest: number | null = null
   let latest: number | null = null
 
 
   Object.values(chartData.value).forEach((series) => {
   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
   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() {
 function getSelectedDateRangeTimestamps() {
   const fallbackEnd = Date.now()
   const fallbackEnd = Date.now()
   const startTs = dayjs(props.date[0]).valueOf()
   const startTs = dayjs(props.date[0]).valueOf()
@@ -350,9 +367,9 @@ function trimChartDataToRealtimeWindow(anchorTs = Date.now()) {
 
 
   Object.keys(chartData.value).forEach((name) => {
   Object.keys(chartData.value).forEach((name) => {
     const series = chartData.value[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)
       chartData.value[name] = series.slice(-1)
       return
       return
     }
     }
@@ -365,24 +382,25 @@ function trimChartDataToRealtimeWindow(anchorTs = Date.now()) {
 
 
 function appendChartPoint(name: string, point: ChartPoint) {
 function appendChartPoint(name: string, point: ChartPoint) {
   const series = chartData.value[name] ?? []
   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 {
   } else {
-    series.push(point)
+    series.splice(pointIndex, 0, point)
   }
   }
 
 
-  series.sort((a, b) => a.ts - b.ts)
   chartData.value[name] = series
   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)
   const numberValue = Number(value)
 
 
   if (!Number.isFinite(numberValue)) return [ts, null, null]
   if (!Number.isFinite(numberValue)) return [ts, null, null]
 
 
-  const { min, max } = getSeriesRange(name)
+  const { min, max } = range
   const denominator = max - min || 1
   const denominator = max - min || 1
   const mappedValue = ((numberValue - min) / denominator) * 100
   const mappedValue = ((numberValue - min) / denominator) * 100
   const displayValue = Math.min(TREND_AXIS_MAX, Math.max(TREND_AXIS_MIN, mappedValue))
   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) {
 function buildSeriesData(name: string) {
   const dataset = chartData.value[name] || []
   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>) {
 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() {
 function getChartAnimationOptions() {
   return {
   return {
     animation: !chartRealtimeMode.value,
     animation: !chartRealtimeMode.value,
@@ -474,9 +513,11 @@ function flushRealtimeChartUpdate() {
 }
 }
 
 
 function scheduleRealtimeChartUpdate(names: string[]) {
 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)
   realtimeUpdateFrame = window.requestAnimationFrame(flushRealtimeChartUpdate)
 }
 }
@@ -637,6 +678,7 @@ function render() {
         id: item.name,
         id: item.name,
         name: item.name,
         name: item.name,
         type: 'line',
         type: 'line',
+        sampling: 'lttb',
         smooth: 0.24,
         smooth: 0.24,
         showSymbol: false,
         showSymbol: false,
         connectNulls: true,
         connectNulls: true,
@@ -710,7 +752,11 @@ async function initLoadChartData(real_time: boolean = true) {
     } else {
     } else {
       applySelectedDateWindow()
       applySelectedDateWindow()
     }
     }
-    updateSeriesByNames(dimensions.value.map((item) => item.name))
+    updateSeriesByNames(
+      dimensions.value
+        .filter((item) => selectedDimension.value[item.name])
+        .map((item) => item.name)
+    )
   } finally {
   } finally {
     chartLoading.value = false
     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 { useFullscreen } from '@vueuse/core'
 import { Crop, FullScreen } from '@element-plus/icons-vue'
 import { Crop, FullScreen } from '@element-plus/icons-vue'
 
 
+defineOptions({ name: 'MonitoringBoard' })
+
 const userStore = useUserStore()
 const userStore = useUserStore()
 const userDeptId = userStore.getUser.deptId
 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 { Crop, DataLine, FullScreen, Odometer, Setting } from '@element-plus/icons-vue'
 import dayjs from 'dayjs'
 import dayjs from 'dayjs'
 import * as echarts from 'echarts'
 import * as echarts from 'echarts'
+import * as XLSX from 'xlsx'
 
 
 const { query } = useRoute()
 const { query } = useRoute()
 
 
@@ -118,6 +119,7 @@ const chartRef = ref<HTMLDivElement | null>(null)
 const targetArea = ref<HTMLElement | null>(null)
 const targetArea = ref<HTMLElement | null>(null)
 const downloadRef = ref<HTMLElement | null>(null)
 const downloadRef = ref<HTMLElement | null>(null)
 const chartLoading = ref(false)
 const chartLoading = ref(false)
+const exportDataLoading = ref(false)
 const chartRealtimeMode = ref(true)
 const chartRealtimeMode = ref(true)
 const token = ref('')
 const token = ref('')
 let chart: echarts.ECharts | null = null
 let chart: echarts.ECharts | null = null
@@ -135,7 +137,7 @@ interface ChartData {
   [key: Dimensions['name']]: ChartPoint[]
   [key: Dimensions['name']]: ChartPoint[]
 }
 }
 
 
-const chartData = ref<ChartData>({})
+const chartData = shallowRef<ChartData>({})
 const resizeChart = () => chart?.resize()
 const resizeChart = () => chart?.resize()
 
 
 watch(isConnected, (newVal) => {
 watch(isConnected, (newVal) => {
@@ -290,12 +292,14 @@ function getPointTimestamp(value: unknown) {
   return Number.isFinite(parsedValue) ? parsedValue : Date.now()
   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)
   const numberValue = Number(value)
 
 
   if (!Number.isFinite(numberValue)) return [ts, null, null]
   if (!Number.isFinite(numberValue)) return [ts, null, null]
 
 
-  const { min, max } = getSeriesRange(name)
+  const { min, max } = range
   const denominator = max - min || 1
   const denominator = max - min || 1
   const mappedValue = ((numberValue - min) / denominator) * 100
   const mappedValue = ((numberValue - min) / denominator) * 100
   const displayValue = Math.min(100, Math.max(0, mappedValue))
   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]
   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() {
 function getLatestTimestamp() {
   let latest: number | null = null
   let latest: number | null = null
 
 
   Object.values(chartData.value).forEach((series) => {
   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
   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() {
 function getSelectedDateRangeTimestamps() {
   const fallbackEnd = Date.now()
   const fallbackEnd = Date.now()
   const startTs = dayjs(selectedDate.value[0]).valueOf()
   const startTs = dayjs(selectedDate.value[0]).valueOf()
@@ -361,9 +381,9 @@ function trimChartDataToRealtimeWindow(anchorTs = Date.now()) {
 
 
   Object.keys(chartData.value).forEach((name) => {
   Object.keys(chartData.value).forEach((name) => {
     const series = chartData.value[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)
       chartData.value[name] = series.slice(-1)
       return
       return
     }
     }
@@ -376,15 +396,14 @@ function trimChartDataToRealtimeWindow(anchorTs = Date.now()) {
 
 
 function appendChartPoint(name: string, point: ChartPoint) {
 function appendChartPoint(name: string, point: ChartPoint) {
   const series = chartData.value[name] ?? []
   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 {
   } else {
-    series.push(point)
+    series.splice(pointIndex, 0, point)
   }
   }
 
 
-  series.sort((a, b) => a.ts - b.ts)
   chartData.value[name] = series
   chartData.value[name] = series
 }
 }
 
 
@@ -411,7 +430,7 @@ function updateSingleSeries(name: string) {
       {
       {
         id: name,
         id: name,
         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) => ({
     series: names.map((name) => ({
       id: name,
       id: name,
       name,
       name,
-      data: chartData.value[name]?.map((item) => mapChartPoint(item, name)) ?? []
+      data: buildSeriesData(name)
     }))
     }))
   })
   })
 }
 }
@@ -460,15 +479,17 @@ function flushRealtimeChartUpdate() {
     series: names.map((name) => ({
     series: names.map((name) => ({
       id: name,
       id: name,
       name,
       name,
-      data: chartData.value[name]?.map((item) => mapChartPoint(item, name)) ?? []
+      data: buildSeriesData(name)
     }))
     }))
   })
   })
 }
 }
 
 
 function scheduleRealtimeChartUpdate(names: string[]) {
 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)
   realtimeUpdateFrame = window.requestAnimationFrame(flushRealtimeChartUpdate)
 }
 }
@@ -581,6 +602,7 @@ function renderChart() {
         id: item.name,
         id: item.name,
         name: item.name,
         name: item.name,
         type: 'line',
         type: 'line',
+        sampling: 'lttb',
         smooth: 0.24,
         smooth: 0.24,
         showSymbol: false,
         showSymbol: false,
         connectNulls: true,
         connectNulls: true,
@@ -603,7 +625,7 @@ function renderChart() {
           shadowColor: 'rgba(15, 23, 42, 0.16)'
           shadowColor: 'rgba(15, 23, 42, 0.16)'
         },
         },
         color: item.color,
         color: item.color,
-        data: chartData.value[item.name]?.map((point) => mapChartPoint(point, item.name)) ?? []
+        data: buildSeriesData(item.name)
       }))
       }))
     },
     },
     true
     true
@@ -712,7 +734,11 @@ async function initLoadChartData({
     } else {
     } else {
       applySelectedDateWindow()
       applySelectedDateWindow()
     }
     }
-    updateSeriesByNames(dimensions.value.map((item) => item.name))
+    updateSeriesByNames(
+      dimensions.value
+        .filter((item) => selectedDimension.value[item.name])
+        .map((item) => item.name)
+    )
   } finally {
   } finally {
     chartLoading.value = false
     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) {
 function handleClickSpec(modelName: string) {
   selectedDimension.value[modelName] = !selectedDimension.value[modelName]
   selectedDimension.value[modelName] = !selectedDimension.value[modelName]
   chart?.setOption({
   chart?.setOption({
@@ -1040,6 +1148,14 @@ onUnmounted(() => {
           <span>数据趋势</span>
           <span>数据趋势</span>
         </h3>
         </h3>
         <div class="chart-panel__actions">
         <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 type="primary" size="default" @click="exportChart">导出为图片</el-button>
           <el-button size="default" @click="resetChart">重置</el-button>
           <el-button size="default" @click="resetChart">重置</el-button>
           <el-date-picker
           <el-date-picker