Parcourir la source

[Fix R5] 合并 master 并按复核建议处理 AI 助手冲突

- DwgPreview:采用 master 新版实现(fileId 直取 + svgTextCache),移植 20 条 FIFO 缓存上限
- WebPreviewPanel:sandbox 补 allow-modals,保留登录态/弹窗/下载能力
- 空令牌头:streamPost 单次取令牌;同步 attachment/filePreview/DownloadFiles
- 合并处理说明见 docs/ai-changes/20261019-audit-r5-fix.md ⑨
wangkeyi il y a 2 semaines
Parent
commit
2c9a36cd6d
95 fichiers modifiés avec 7111 ajouts et 1511 suppressions
  1. 6 0
      README.md
  2. 9 0
      docs/ai-changes/20261019-audit-r5-fix.md
  3. 4 4
      src/App.vue
  4. BIN
      src/assets/images/beltFire/bg-model-green.png
  5. 9 0
      src/assets/images/vent/home/selfIgnition/form-icon.svg
  6. 9 0
      src/assets/images/vent/home/selfIgnition/goaf-icon.svg
  7. 18 0
      src/assets/images/vent/home/selfIgnition/info-icon.svg
  8. BIN
      src/assets/images/vent/home/selfIgnition/list-icon-bg.png
  9. BIN
      src/assets/images/vent/home/selfIgnition/result-block-bg.png
  10. BIN
      src/assets/images/vent/home/selfIgnition/result-row-glow.png
  11. BIN
      src/assets/images/vent/home/selfIgnition/result-table-head.png
  12. BIN
      src/assets/images/vent/home/selfIgnition/result-top-bg.png
  13. BIN
      src/assets/images/vent/home/selfIgnition/risk-bar.png
  14. BIN
      src/assets/images/vent/home/selfIgnition/risk-gauge.png
  15. 11 0
      src/assets/images/vent/home/selfIgnition/risk-tag.svg
  16. BIN
      src/assets/images/vent/home/selfIgnition/select-bg.png
  17. BIN
      src/assets/images/vent/home/selfIgnition/submit-bg.png
  18. BIN
      src/assets/images/vent/home/selfIgnition/submit-glow.png
  19. BIN
      src/assets/images/vent/home/selfIgnition/tab-bg.png
  20. BIN
      src/assets/images/vent/home/selfIgnition/tab-item-bg.png
  21. BIN
      src/assets/images/vent/home/selfIgnition/top-bg.png
  22. 1 1
      src/utils/threejs/loadGltf.worker.js
  23. 75 119
      src/utils/threejs/useThree.ts
  24. 1 5
      src/utils/threejs/util.ts
  25. 1 0
      src/views/vent/deviceManager/configurationTable/types.ts
  26. 189 189
      src/views/vent/home/configurable/blue/components/ModuleFireNew.vue
  27. 187 187
      src/views/vent/home/configurable/blue/components/ModuleFireNewDual.vue
  28. 6 2
      src/views/vent/home/configurable/blue/components/NewNavFire.vue
  29. 4 4
      src/views/vent/home/configurable/blue/components/detail/MiniBoard-Warn.vue
  30. 1 1
      src/views/vent/home/configurable/blue/components/yj_gasWarn.vue
  31. 2 2
      src/views/vent/home/configurable/blue/components/yj_ventWarn.vue
  32. 143 158
      src/views/vent/home/configurable/blue/dustNew.vue
  33. 20 33
      src/views/vent/home/configurable/blue/fireNew.vue
  34. 14 12
      src/views/vent/home/configurable/blue/homeBlue.vue
  35. 127 142
      src/views/vent/home/configurable/blue/ventNew.vue
  36. 30 70
      src/views/vent/home/configurable/blue/warnMonitor.vue
  37. 0 0
      src/views/vent/home/configurable/components/detail/CustomChart.vue
  38. 4 1
      src/views/vent/home/configurable/components/detail/CustomList-fire.vue
  39. 63 18
      src/views/vent/home/configurable/components/preset/QHCurve.vue
  40. 10 13
      src/views/vent/home/configurable/components/preset/earlyWarnMonitor.vue
  41. 30 24
      src/views/vent/home/configurable/configurable.data.ts
  42. 7 0
      src/views/vent/home/configurable/green/components/CustomGallery-green.vue
  43. 17 10
      src/views/vent/home/configurable/green/components/CustomList-green.vue
  44. 11 1
      src/views/vent/home/configurable/green/components/CustomTable-green.vue
  45. 76 11
      src/views/vent/home/configurable/green/components/MiniBoard-green.vue
  46. 6 1
      src/views/vent/home/configurable/green/components/ModuleOriginal-green.vue
  47. 225 7
      src/views/vent/home/configurable/green/components/content-green.vue
  48. 53 54
      src/views/vent/home/configurable/green/components/dz-chart.vue
  49. 2 0
      src/views/vent/home/configurable/green/components/green-nav.vue
  50. 6 1
      src/views/vent/home/configurable/green/components/moduleDual-green.vue
  51. 5 0
      src/views/vent/home/configurable/green/dust-green.vue
  52. 7 3
      src/views/vent/home/configurable/green/fire-green.vue
  53. 6 0
      src/views/vent/home/configurable/green/fusion-warn-green.vue
  54. 6 1
      src/views/vent/home/configurable/green/vent-green.vue
  55. 678 0
      src/views/vent/home/selfIgnition/components/ManualView.vue
  56. 586 0
      src/views/vent/home/selfIgnition/components/MonitorView.vue
  57. 435 0
      src/views/vent/home/selfIgnition/components/OverviewView.vue
  58. 200 0
      src/views/vent/home/selfIgnition/components/PanelSelect.vue
  59. 457 0
      src/views/vent/home/selfIgnition/data.ts
  60. 241 0
      src/views/vent/home/selfIgnition/index.vue
  61. 14 0
      src/views/vent/home/selfIgnition/utils.ts
  62. 308 0
      src/views/vent/monitorManager/compressor/components/ceshi.json
  63. 7 16
      src/views/vent/monitorManager/compressor/components/nitrogenHome.vue
  64. 610 0
      src/views/vent/monitorManager/compressor/components/nitrogenHomeNew.vue
  65. 36 45
      src/views/vent/monitorManager/compressor/components/nitrogenHome_bet.vue
  66. 2 4
      src/views/vent/monitorManager/compressor/nitrogen.data.ts
  67. 28 19
      src/views/vent/monitorManager/compressor/nitrogen.dishang.threejs.ts
  68. 216 32
      src/views/vent/monitorManager/compressor/nitrogen.dixia.threejs.ts
  69. 20 12
      src/views/vent/monitorManager/compressor/nitrogen.flex.threejs.ts
  70. 19 9
      src/views/vent/monitorManager/compressor/nitrogen.threejs.ts
  71. 7 1
      src/views/vent/monitorManager/nitrogen/nitrogen.threejs.ts
  72. 72 0
      src/views/ventAI/manageAssistent/api/attachment.ts
  73. 25 33
      src/views/ventAI/manageAssistent/api/cad.ts
  74. 9 0
      src/views/ventAI/manageAssistent/api/errors.ts
  75. 1 0
      src/views/ventAI/manageAssistent/api/index.ts
  76. 2 1
      src/views/ventAI/manageAssistent/api/streamPost.ts
  77. 311 73
      src/views/ventAI/manageAssistent/components/AiAssistantModal.vue
  78. 2 0
      src/views/ventAI/manageAssistent/components/chat/ChatHeader.vue
  79. 157 53
      src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue
  80. 2 1
      src/views/ventAI/manageAssistent/components/chat/DownloadFiles.vue
  81. 28 1
      src/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue
  82. 70 93
      src/views/ventAI/manageAssistent/components/resource/DwgPreview.vue
  83. 373 0
      src/views/ventAI/manageAssistent/components/resource/ModalPreview.vue
  84. 6 0
      src/views/ventAI/manageAssistent/components/resource/ResourceCapsule.vue
  85. 259 31
      src/views/ventAI/manageAssistent/components/resource/WebPreviewPanel.vue
  86. 176 0
      src/views/ventAI/manageAssistent/hooks/useChatAttachments.ts
  87. 36 2
      src/views/ventAI/manageAssistent/hooks/useChatPanels.ts
  88. 25 5
      src/views/ventAI/manageAssistent/hooks/useChatSend.ts
  89. 29 0
      src/views/ventAI/manageAssistent/lib/fileIcon.ts
  90. 9 4
      src/views/ventAI/manageAssistent/lib/filePreview.ts
  91. 193 0
      src/views/ventAI/manageAssistent/lib/modalPreviewBridge.ts
  92. 40 0
      src/views/ventAI/manageAssistent/lib/webLink.ts
  93. 4 0
      src/views/ventAI/manageAssistent/types/chat.ts
  94. 9 1
      src/views/ventAI/manageAssistent/types/panel.ts
  95. 8 1
      src/views/ventAI/manageAssistent/types/resource.ts

+ 6 - 0
README.md

@@ -546,3 +546,9 @@ main();
 | `fanmain`         | `mainFanType`           |
 | `windrect`        | `windrecModel`          |
 | `door`            | `fireDoorStyle`         |
+
+4、常见问题
+
+A:想用原本的文本类模型选择下拉框,只需要将把步骤 2 中的输入类型改回 `selectdict` 即可
+
+B:如果使用模型预览图的下拉框但是没有图片出现,可以检查一下是否上传了对应设备的预览图,后端接口`/getAllModelFileJson`是否可用

+ 9 - 0
docs/ai-changes/20261019-audit-r5-fix.md

@@ -132,3 +132,12 @@
 - **交付状态**:**已本地提交**(代码 + 本说明同一提交);已推送 fork(`lizuo/mky-vent-base`)并交付团队仓同名分支(`hrx/mky-vent-base`),对照页 `compare/master...fix/audit-r5-20261019` 实测 **HTTP 200**;**未推送/合并 master、未部署**。
 - **上游同步**:分支基线 = `upstream/master @ e3d4295c`,behind=0;本轮未改动任何与上游冲突的面。
 - 部署与浏览器验收(AI 网页链接 sandbox 实际加载、色谱仪报表首屏真实数据、green 首页失败静默)留待后续在 88 环境安排。
+
+## ⑨ 合并 master 后的处理(2026-10-21,AI 助手部分复核)
+
+> 本节记录按第五轮审计复核建议把 `origin/master`(`7d3cddad`)合入本分支时的冲突处理与收口,代码以合并结果为准。
+
+- **DwgPreview.vue(M-12)**:master 已于 10-20(`242753f1`)重写该组件——历史附件按 `fileId` 直接取服务端转换结果,模块级缓存改存 svg 正文(`svgTextCache`),静态 URL 链路整体删除——与本分支 M-12 冲突。合并采用 master 版本,**不保留** `svgUrlCache` 实现;仅把 M-12 的「上限 20 条 FIFO、重复键刷新插入序」移植到 `svgTextCache`,避免长会话无界增长(每份约 1MB 文本)。
+- **WebPreviewPanel.vue(M-2)**:sandbox 在 master 的界面改版上自动合并;按内网系统实际用法补充 `allow-modals`(`alert/confirm/prompt/window.print`),其余放行项与未放行的 top-navigation 不变。
+- **空令牌头(M-3)**:streamPost 改为单次读取令牌后条件放行;同口径同步到 `api/attachment.ts`、`components/chat/DownloadFiles.vue`、`lib/filePreview.ts`,避免附件下载/预览在未登录时发出 `"null"` 令牌头。
+- **范围**:仅上述 5 个文件按合并结果调整,其余审计改动随合并保留;本地 master 未改动、未推送远端,未部署。

+ 4 - 4
src/App.vue

@@ -71,28 +71,28 @@
     overflow: hidden;
   }
   @font-face {
-    font-family: 'douyuFont';
+    font-family: douyuFont;
     src: url('/@/assets/font/douyuFont.otf');
     font-weight: normal;
     font-style: normal;
     font-display: swap; // [perf-base-v1] 字体加载期间先用系统字体渲染,避免不可见文本阻塞
   }
   @font-face {
-    font-family: 'numberFont';
+    font-family: numberFont;
     src: url('/@/assets/font/yjsz.TTF');
     font-weight: normal;
     font-style: normal;
     font-display: swap; // [perf-base-v1] 字体加载期间先用系统字体渲染,避免不可见文本阻塞
   }
   @font-face {
-    font-family: 'electronicFont';
+    font-family: electronicFont;
     src: url('/@/assets/font/DS-DIGIT.TTF');
     font-weight: normal;
     font-style: normal;
     font-display: swap; // [perf-base-v1] 字体加载期间先用系统字体渲染,避免不可见文本阻塞
   }
   @font-face {
-    font-family: 'ysbtFont';
+    font-family: ysbtFont;
     src: url('/@/assets/font/ysbtFont.ttf');
     font-weight: normal;
     font-style: normal;

BIN
src/assets/images/beltFire/bg-model-green.png


+ 9 - 0
src/assets/images/vent/home/selfIgnition/form-icon.svg

@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="15.267" height="17.644" viewBox="0 0 15.267 17.644">
+  <defs>
+    <linearGradient id="linear-gradient" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox">
+      <stop offset="0" stop-color="#3df6ff"/>
+      <stop offset="1" stop-color="#b3fff5"/>
+    </linearGradient>
+  </defs>
+  <path id="路径_58015" data-name="路径 58015" d="M136.97,64a.476.476,0,0,1,.476.476v.238a.477.477,0,0,0,.476.476h3.87a.477.477,0,0,1,.476.476v6.122a.476.476,0,0,1-.476.476h-.238a.476.476,0,0,1-.476-.476V66.853a.476.476,0,0,0-.476-.476h-1.427a.476.476,0,0,0-.476.476V68.28a.476.476,0,0,1-.476.476h-6.182a.476.476,0,0,1-.476-.476V66.853a.476.476,0,0,0-.476-.476h-1.427a.476.476,0,0,0-.476.476V78.98a.477.477,0,0,0,.476.476h3.8a.477.477,0,0,1,.476.476v.238a.476.476,0,0,1-.476.476h-4.993a.476.476,0,0,1-.476-.476v-14.5a.477.477,0,0,1,.476-.476h3.8a.476.476,0,0,0,.476-.476v-.238a.477.477,0,0,1,.476-.476Zm4.475,9.848.485.485a.476.476,0,0,1,.061.6l-.059.075-5.288,5.285a.476.476,0,0,1-.253.132l-.923.163a.238.238,0,0,1-.276-.275l.162-.924a.476.476,0,0,1,.133-.254l5.286-5.285a.476.476,0,0,1,.673,0Zm-7.976,2.041a.477.477,0,0,1,.476.476V76.6a.476.476,0,0,1-.476.476h-1.427a.476.476,0,0,1-.476-.476v-.238a.477.477,0,0,1,.476-.476Zm2.378-2.378a.477.477,0,0,1,.476.476v.238a.476.476,0,0,1-.476.476h-3.8a.476.476,0,0,1-.476-.476v-.238a.477.477,0,0,1,.476-.476Zm2.378-2.378a.477.477,0,0,1,.476.476v.238a.476.476,0,0,1-.476.476h-6.182a.476.476,0,0,1-.476-.476v-.238a.477.477,0,0,1,.476-.476Zm-2.14-5.944H134.23a.238.238,0,0,0-.238.238v.713a.238.238,0,0,1-.238.238h-.769a.238.238,0,0,0-.238.238v.713a.238.238,0,0,0,.238.238h4.288a.238.238,0,0,0,.238-.238v-.713a.238.238,0,0,0-.238-.238h-.713a.238.238,0,0,1-.238-.238v-.713A.238.238,0,0,0,136.084,65.189Z" transform="translate(-127.5 -63.5)" stroke="rgba(0,0,0,0)" stroke-width="1" fill="url(#linear-gradient)"/>
+</svg>

+ 9 - 0
src/assets/images/vent/home/selfIgnition/goaf-icon.svg

@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="23.971" height="15.553" viewBox="0 0 23.971 15.553">
+  <defs>
+    <linearGradient id="linear-gradient" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox">
+      <stop offset="0" stop-color="#abdcff"/>
+      <stop offset="1" stop-color="#fff"/>
+    </linearGradient>
+  </defs>
+  <path id="路径_58031" data-name="路径 58031" d="M160.223,312.65l-.88,4.26a4,4,0,0,1-3.776,3.428,20.444,20.444,0,0,1-6.752,0,4,4,0,0,1-3.775-3.427h0a3.558,3.558,0,0,1,0-.775l-.019-3.285-.035-.2h-3.865V327.2h22.971V312.65h-3.865Z" transform="translate(-140.617 -312.15)" stroke="rgba(0,0,0,0)" stroke-width="1" fill="url(#linear-gradient)"/>
+</svg>

+ 18 - 0
src/assets/images/vent/home/selfIgnition/info-icon.svg

@@ -0,0 +1,18 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="12.369" height="11.645" viewBox="0 0 12.369 11.645">
+  <defs>
+    <linearGradient id="linear-gradient" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox">
+      <stop offset="0" stop-color="#3df6ff"/>
+      <stop offset="1" stop-color="#b3fff5"/>
+    </linearGradient>
+  </defs>
+  <g id="组_16798" data-name="组 16798" transform="translate(-64 -90.24)">
+    <path id="路径_58016" data-name="路径 58016" d="M420.1,90.24h2.485V99.4H420.1Z" transform="translate(-351.18)" fill="url(#linear-gradient)"/>
+    <path id="路径_58017" data-name="路径 58017" d="M180.224,397.632h2.484v4.917h-2.484Z" transform="translate(-114.62 -303.148)" fill="url(#linear-gradient)"/>
+    <path id="路径_58018" data-name="路径 58018" d="M660.032,278.656h2.485v6.56h-2.485Z" transform="translate(-587.804 -185.815)" fill="url(#linear-gradient)"/>
+    <path id="路径_58019" data-name="路径 58019" d="M64,816.64H76.369v1.617H64Z" transform="translate(0 -716.372)" fill="url(#linear-gradient)"/>
+    <path id="路径_58020" data-name="路径 58020" d="M485.76,159.616h1.579v8.2H485.76Z" transform="translate(-415.938 -68.418)" fill="url(#linear-gradient)"/>
+    <path id="路径_58021" data-name="路径 58021" d="M245.824,465.152H247.4v3.986h-1.579Z" transform="translate(-179.314 -369.736)" fill="url(#linear-gradient)"/>
+    <path id="路径_58022" data-name="路径 58022" d="M725.7,341.44h1.579v5.692H725.7Z" transform="translate(-652.561 -247.732)" fill="url(#linear-gradient)"/>
+    <path id="路径_58023" data-name="路径 58023" d="M123.072,875.648h11.554v.8H123.072Z" transform="translate(-58.257 -774.566)" fill="url(#linear-gradient)"/>
+  </g>
+</svg>

BIN
src/assets/images/vent/home/selfIgnition/list-icon-bg.png


BIN
src/assets/images/vent/home/selfIgnition/result-block-bg.png


BIN
src/assets/images/vent/home/selfIgnition/result-row-glow.png


BIN
src/assets/images/vent/home/selfIgnition/result-table-head.png


BIN
src/assets/images/vent/home/selfIgnition/result-top-bg.png


BIN
src/assets/images/vent/home/selfIgnition/risk-bar.png


BIN
src/assets/images/vent/home/selfIgnition/risk-gauge.png


+ 11 - 0
src/assets/images/vent/home/selfIgnition/risk-tag.svg

@@ -0,0 +1,11 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="158.716" height="33.632" viewBox="0 0 158.716 33.632">
+  <defs>
+    <linearGradient id="linear-gradient" x1="0.029" y1="0.5" x2="0.961" y2="0.5" gradientUnits="objectBoundingBox">
+      <stop offset="0" stop-color="#ff9d1c"/>
+      <stop offset="1" stop-color="#ff861c"/>
+    </linearGradient>
+  </defs>
+  <g id="组_16818" data-name="组 16818" transform="translate(-1123.784 -221.585)">
+    <path id="路径_58028" data-name="路径 58028" d="M-13164.9,3557.585l19.338,33.631h117.321l22.057-33.631Z" transform="translate(14288.682 -3336)" fill="url(#linear-gradient)"/>
+  </g>
+</svg>

BIN
src/assets/images/vent/home/selfIgnition/select-bg.png


BIN
src/assets/images/vent/home/selfIgnition/submit-bg.png


BIN
src/assets/images/vent/home/selfIgnition/submit-glow.png


BIN
src/assets/images/vent/home/selfIgnition/tab-bg.png


BIN
src/assets/images/vent/home/selfIgnition/tab-item-bg.png


BIN
src/assets/images/vent/home/selfIgnition/top-bg.png


+ 1 - 1
src/utils/threejs/loadGltf.worker.js

@@ -67,7 +67,7 @@ function loadModal(url) {
           modalVal: data,
           versionStr: version,
         };
-        await db.modal.add(model);
+        await db.modal.put(model);
         self.postMessage({ message: 'save', data: model });
         resolve(model);
       });

+ 75 - 119
src/utils/threejs/useThree.ts

@@ -13,9 +13,7 @@ import Stats from 'three/examples/jsm/libs/stats.module.js';
 import { useModelStore } from '/@/store/modules/threejs';
 import TWEEN from 'three/examples/jsm/libs/tween.module.js';
 import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js';
-import { getList } from '@/views/vent/sys/resources/file.api';
-import { saveModel } from '/@/utils/threejs/util';
-import { getToken, getTenantId } from '/@/utils/auth';
+import { getToken } from '/@/utils/auth';
 
 import { useGlobSetting } from '/@/hooks/setting';
 import { get } from 'sortablejs';
@@ -220,68 +218,55 @@ class UseThree {
               const modalNameStr = modalNameArr[i];
               let modalValue;
               const modalArr = await db.modal.where('modalName').equals(modalNameStr).toArray();
-              if (modalArr.length > 0 && modalArr[0].modalVal.byteLength > 0) {
+              if (modalArr.length > 0 && modalArr[0].modalVal?.byteLength > 0) {
                 modalValue = modalArr[0].modalVal;
               } else if (modalArr.length > 0) {
-                db.modal.delete(modalArr[0].id);
+                // 主键是 modalName,不是自增 id
+                await db.modal.delete(modalNameStr);
               }
-              if (modalValue) {
-                gltfLoader.parse(
-                  modalValue,
-                  '/model/glft/',
-                  (gltf) => {
-                    let object: THREE.Object3D = gltf.scene;
-                    object.traverse((obj) => {
-                      if (obj instanceof THREE.Mesh) {
-                        obj.material.emissiveIntensity = 1;
-                        obj.material.emissiveMap = obj.material.map;
-                        obj.material.blending = THREE.CustomBlending;
-
-                        if (obj.material.opacity < 1) {
-                          obj.material.transparent = true;
-                        }
-                        if (obj.material.map) {
-                          obj.material.map.colorSpace = THREE.SRGBColorSpace;
-                          obj.material.map.flipY = false;
-                          obj.material.map.anisotropy = 1;
-                        }
-                        if (obj.material.emissiveMap) {
-                          obj.material.emissiveMap.colorSpace = THREE.SRGBColorSpace;
-                          obj.material.emissiveMap.flipY = false;
-                        }
-
-                        if (obj.material.map || obj.material.emissiveMap) {
-                          obj.material.needsUpdate = true;
-                        }
-
-                        // if (envMap) {
-                        //   obj.material.envMap = envMap;
-                        //   obj.material.envMapIntensity = 1;
-                        // }
-                        // obj.renderOrder = 1;
-                      }
-                    });
-                    if (isBlender) {
-                      object = object.children[0];
+
+              const finishObject = (gltf) => {
+                let object: THREE.Object3D = gltf.scene;
+                object.traverse((obj) => {
+                  if (obj instanceof THREE.Mesh) {
+                    obj.material.emissiveIntensity = 1;
+                    obj.material.emissiveMap = obj.material.map;
+                    obj.material.blending = THREE.CustomBlending;
+
+                    if (obj.material.opacity < 1) {
+                      obj.material.transparent = true;
+                    }
+                    if (obj.material.map) {
+                      obj.material.map.colorSpace = THREE.SRGBColorSpace;
+                      obj.material.map.flipY = false;
+                      obj.material.map.anisotropy = 1;
+                    }
+                    if (obj.material.emissiveMap) {
+                      obj.material.emissiveMap.colorSpace = THREE.SRGBColorSpace;
+                      obj.material.emissiveMap.flipY = false;
+                    }
+
+                    if (obj.material.map || obj.material.emissiveMap) {
+                      obj.material.needsUpdate = true;
                     }
-                    object.animations = gltf.animations;
-                    object.name = modalNameStr;
-                    if (group) group.add(object);
-                    childResolve(object);
-                  },
-                  (err) => {
-                    console.log(err);
-                    childResolve(null);
                   }
-                );
-              } else {
-                // 开启线程下载
+                });
+                if (isBlender) {
+                  object = object.children[0];
+                }
+                object.animations = gltf.animations;
+                object.name = modalNameStr;
+                if (group) group.add(object);
+                childResolve(object);
+              };
 
-                const result = (await getList({ fileName: modalNameStr })) || [];
-                const file = result['records'][0];
+              if (modalValue) {
+                gltfLoader.parse(modalValue, '/model/glft/', finishObject, (err) => {
+                  console.log(err);
+                  childResolve(null);
+                });
+              } else {
                 console.log('需要开启线程下载模型资源。。。。。');
-
-                // 如果是常村时
                 const token = getToken();
                 fetch(`${baseApiUrl}/sys/common/static/webfile/${modalNameStr}.glb`, {
                   method: 'GET',
@@ -292,67 +277,38 @@ class UseThree {
                     'X-Access-Token': token,
                     token: token,
                   },
-                }).then(async (data) => {
-                  const arrayBuffer = await data.arrayBuffer();
-                  // 将资源存放到db
-                  const fileName = modalNameStr;
-                  const model = {
-                    modalName: fileName,
-                    modalVal: arrayBuffer,
-                    versionStr: fileName,
-                  };
-                  db.open();
-                  await db.modal.add(model);
-                  gltfLoader.parse(
-                    arrayBuffer,
-                    '/model/glft/',
-                    (gltf) => {
-                      let object: THREE.Object3D = gltf.scene;
-                      // setModalCenter(object);
-                      object.traverse((obj) => {
-                        if (obj instanceof THREE.Mesh) {
-                          obj.material.emissiveIntensity = 1;
-                          obj.material.emissiveMap = obj.material.map;
-                          obj.material.blending = THREE.CustomBlending;
-
-                          if (obj.material.opacity < 1) {
-                            obj.material.transparent = true;
-                          }
-                          if (obj.material.map) {
-                            obj.material.map.colorSpace = THREE.SRGBColorSpace;
-                            obj.material.map.flipY = false;
-                            obj.material.map.anisotropy = 1;
-                          }
-                          if (obj.material.emissiveMap) {
-                            obj.material.emissiveMap.colorSpace = THREE.SRGBColorSpace;
-                            obj.material.emissiveMap.flipY = false;
-                          }
-
-                          if (obj.material.map || obj.material.emissiveMap) {
-                            obj.material.needsUpdate = true;
-                          }
-
-                          // if (envMap) {
-                          //   obj.material.envMap = envMap;
-                          //   obj.material.envMapIntensity = 1;
-                          // }
-                          // obj.renderOrder = 1;
-                        }
-                      });
-                      if (isBlender) {
-                        object = object.children[0];
-                      }
-                      object.animations = gltf.animations;
-                      object.name = modalNameStr;
-                      if (group) group.add(object);
-                      childResolve(object);
-                    },
-                    (err) => {
-                      console.log(err);
+                })
+                  .then(async (data) => {
+                    if (!data.ok) {
+                      console.warn(`[setGLTFModel] 模型下载失败: ${modalNameStr}.glb`, data.status);
                       childResolve(null);
+                      return;
                     }
-                  );
-                });
+                    const arrayBuffer = await data.arrayBuffer();
+                    if (!arrayBuffer || arrayBuffer.byteLength < 1) {
+                      childResolve(null);
+                      return;
+                    }
+                    const model = {
+                      modalName: modalNameStr,
+                      modalVal: arrayBuffer,
+                      versionStr: modalNameStr,
+                    };
+                    try {
+                      // put 可覆盖同名主键,避免 ConstraintError: Key already exists
+                      await db.modal.put(model);
+                    } catch (dbError) {
+                      console.warn(`[setGLTFModel] 缓存模型失败: ${modalNameStr}`, dbError);
+                    }
+                    gltfLoader.parse(arrayBuffer, '/model/glft/', finishObject, (err) => {
+                      console.log(err);
+                      childResolve(null);
+                    });
+                  })
+                  .catch((error) => {
+                    console.warn(`[setGLTFModel] 模型请求异常: ${modalNameStr}`, error);
+                    childResolve(null);
+                  });
               }
             } catch (error) {
               console.log(error);
@@ -623,7 +579,6 @@ class UseThree {
     this.renderer?.getRenderTarget()?.dispose();
 
     if (this.renderer && this.canvasContainer) this.canvasContainer.innerHTML = '';
-    if (this.CSSCanvasContainer && this.css3dRender) this.CSSCanvasContainer.innerHTML = '';
 
     // if (this.renderer) this.renderer.domElement = null;
     this.renderer?.clear();
@@ -632,7 +587,8 @@ class UseThree {
     if (this.css2dRender) this.css2dRender.domElement = null;
 
     if (this.canvasContainer) this.canvasContainer.innerHTML = '';
-    if (this.CSSCanvasContainer) this.CSSCanvasContainer.innerHTML = '';
+    // CSS 容器(如 #nitrogenCss3D)是 Vue 渲染的节点,里面的详情框等元素归 Vue 管理,这里不能清空它:
+    // 清空会把 Vue 的 DOM 物理删除,vdom 与 DOM 就此脱节(重进页面/切场景后详情框消失)
 
     this.camera = null;
     this.orbitControls = null;

+ 1 - 5
src/utils/threejs/util.ts

@@ -563,11 +563,7 @@ export function saveModel(fileName, path, version) {
         modalVal: data,
         versionStr: version,
       };
-      const modalArr = await db.modal.where('modalName').equals(fileName).toArray();
-      if (modalArr.length > 0) {
-        db.modal.delete(fileName);
-      }
-      await db.modal.add(model);
+      await db.modal.put(model);
     });
   } catch (error) {}
 }

+ 1 - 0
src/views/vent/deviceManager/configurationTable/types.ts

@@ -290,6 +290,7 @@ export interface ModuleDataChart extends ReadFrom {
     | 'bar_cylinder'
     | 'bar_cylinder_wide'
     | 'pie_halo'
+    | 'pie_rose'
     | 'line_enhance'
     | 'pie_drag'
     | 'scatter';

+ 189 - 189
src/views/vent/home/configurable/blue/components/ModuleFireNew.vue

@@ -31,202 +31,202 @@
 </template>
 
 <script setup lang="ts">
-  import ContentFireNew from './content-FireNew.vue';
-  import { defineEmits, computed, watch } from 'vue';
-  import { useInitModule } from '../../hooks/useInit';
-  import { getFormattedText } from '../../hooks/helper';
-  import { openWindow } from '/@/utils';
-  // import { ModuleProps } from '../types';
-
-  const props = defineProps<{
-    /** 配置的详细模块信息 */
-    moduleData: any;
-    /** 配置的详细样式信息 */
-    showStyle: any;
-    /** 该模块配置中的设备标识符 */
-    deviceType: string;
-    /** api返回的数据 */
-    data: any;
-    moduleName: string;
-    visible: boolean;
-  }>();
-  const emit = defineEmits(['close', 'select']);
-
-  const { header } = props.moduleData;
-
-  const { selectedDeviceID, selectedDevice, options, init } = useInitModule(props.deviceType, props.moduleData);
-
-  const style = computed(() => {
-    const size = props.showStyle.size;
-    const position = props.showStyle.position;
-    return size + position;
-  });
-
-  // 根据配置里的定位判断应该使用哪个module组件
-  const daneClass = computed(() => {
-    const position = props.showStyle.position;
-    const size = props.showStyle.size;
-    const [_, width] = size.match(/width:([0-9]+)px/) || [];
-    if (position.includes('bottom') || parseInt(width) > 800) {
-      return 'dane-w';
-    }
-    if (position.includes('left')) {
-      return 'dane-m';
-    }
-    if (position.includes('right')) {
-      return 'dane-m';
-    }
-    // return 'dane-m';
-    return 'dane-m'; // 默认返回顶部模块
-  });
-  //切换时间选项
-  // function onChange(value, dateString) {
-  //   console.log('Selected Time: ', value);
-  //   console.log('Formatted Selected Time: ', dateString);
-  // }
-  // function onOk(val) {
-  //   console.log('onOk: ', val);
-  // }
-
-  //下拉框选项切换
-  function selectHandler(id) {
-    selectedDeviceID.value = id;
-    emit('select', selectedDevice);
-  }
-
-  function redirectTo() {
-    const { to } = props.moduleData;
-    if (!to) return;
-    openWindow(to);
-  }
-
-  watch(
-    () => props.data,
-    (d) => {
-      init(d);
-      selectedDeviceID.value = options.value[0]?.value;
-    },
-    {
-      immediate: true,
-    }
-  );
+import ContentFireNew from './content-FireNew.vue';
+import { defineEmits, computed, watch } from 'vue';
+import { useInitModule } from '../../hooks/useInit';
+import { getFormattedText } from '../../hooks/helper';
+import { openWindow } from '/@/utils';
+// import { ModuleProps } from '../types';
+
+const props = defineProps<{
+  /** 配置的详细模块信息 */
+  moduleData: any;
+  /** 配置的详细样式信息 */
+  showStyle: any;
+  /** 该模块配置中的设备标识符 */
+  deviceType: string;
+  /** api返回的数据 */
+  data: any;
+  moduleName: string;
+  visible: boolean;
+}>();
+const emit = defineEmits(['close', 'select']);
+
+const { header } = props.moduleData;
+
+const { selectedDeviceID, selectedDevice, options, init } = useInitModule(props.deviceType, props.moduleData);
+
+const style = computed(() => {
+  const size = props.showStyle.size;
+  const position = props.showStyle.position;
+  return size + position;
+});
+
+// 根据配置里的定位判断应该使用哪个module组件
+const daneClass = computed(() => {
+  const position = props.showStyle.position;
+  const size = props.showStyle.size;
+  const [_, width] = size.match(/width:([0-9]+)px/) || [];
+  if (position.includes('bottom') || parseInt(width) > 800) {
+    return 'dane-w';
+  }
+  if (position.includes('left')) {
+    return 'dane-m';
+  }
+  if (position.includes('right')) {
+    return 'dane-m';
+  }
+  // return 'dane-m';
+  return 'dane-m'; // 默认返回顶部模块
+});
+//切换时间选项
+// function onChange(value, dateString) {
+//   console.log('Selected Time: ', value);
+//   console.log('Formatted Selected Time: ', dateString);
+// }
+// function onOk(val) {
+//   console.log('onOk: ', val);
+// }
+
+//下拉框选项切换
+function selectHandler(id) {
+  selectedDeviceID.value = id;
+  emit('select', selectedDevice);
+}
+
+function redirectTo() {
+  const { to } = props.moduleData;
+  if (!to) return;
+  openWindow(to);
+}
+
+watch(
+  () => props.data,
+  (d) => {
+    init(d);
+    selectedDeviceID.value = options.value[0]?.value;
+  },
+  {
+    immediate: true,
+  }
+);
 </script>
 
 <style scoped lang="less">
-  @import '/@/design/theme.less';
-  @font-face {
-    font-family: 'douyuFont';
-    src: url('/@/assets/font/douyuFont.otf');
-  }
-  @{theme-deepblue} {
-    .dane-bd {
-      --image-Content: url('@/assets/images/fireNew/4-3.png');
-      --image-Select: url('@/assets/images/fireNew/5.png');
-    }
-  }
-
+@import '/@/design/theme.less';
+@font-face {
+  font-family: 'douyuFont';
+  src: url('/@/assets/font/douyuFont.otf');
+}
+@{theme-deepblue} {
   .dane-bd {
-    --image-Select: url('@/assets/images/fireNew/5.png');
     --image-Content: url('@/assets/images/fireNew/4-3.png');
-    position: absolute;
-    width: 100%;
-    height: 100%;
-    z-index: 2;
-
-    .dane-title {
-      width: 94%;
-      height: 40px;
-      text-align: center;
-      padding-top: 9px;
-
-      .common-navL {
-        position: relative;
-        align-items: center;
-        color: #fff;
-        font-size: 13px;
-        font-weight: bold;
-        font-family: 'douyuFont';
-      }
-    }
-
-    .dane-content {
-      height: calc(100% - 38px);
-      box-sizing: border-box;
-      border-top: none;
-      .common-navR {
-        display: flex;
-        align-items: center;
-        justify-content: center;
-        background: var(--image-Select) no-repeat;
-        background-size: 100% 100%;
-        width: 87%;
-        margin-left: 30px;
-      }
-    }
-  }
-  .dane-m {
-    background: var(--image-Content) no-repeat;
-    background-size: 100% 100%;
-  }
-  :deep(.zxm-select-selector) {
-    height: 22px !important;
-    border: none !important;
-    // background-color: rgb(15 64 88) !important;
-    background-color: transparent !important;
-    color: #8087a1 !important;
-  }
-
-  :deep(.zxm-select-selection-placeholder) {
-    color: #8087a1 !important;
-  }
-
-  :deep(.zxm-select-arrow) {
-    color: #8087a1 !important;
-  }
-
-  :deep(.zxm-picker) {
-    border: none !important;
-    background-color: rgb(15 64 88) !important;
-    box-shadow: none;
-    color: #a1dff8 !important;
-  }
-
-  :deep(.zxm-picker-input > input) {
-    color: #a1dff8 !important;
-    text-align: center !important;
-  }
-
-  :deep(.zxm-picker-separator) {
-    color: #a1dff8 !important;
-  }
-
-  :deep(.zxm-picker-active-bar) {
-    display: none !important;
-  }
-
-  :deep(.zxm-picker-suffix) {
-    color: #a1dff8 !important;
-  }
-
-  :deep(.zxm-switch) {
-    min-width: 48px !important;
-  }
-
-  :deep(.zxm-switch-checked) {
-    background-color: rgb(15 64 89) !important;
-  }
-
-  :deep(.zxm-switch-handle::before) {
-    background-color: rgb(33 179 247) !important;
+    --image-Select: url('@/assets/images/fireNew/5.png');
   }
-
-  :deep(.zxm-select-selection-item) {
-    color: #fff !important;
+}
+
+.dane-bd {
+  --image-Select: url('@/assets/images/fireNew/5.png');
+  --image-Content: url('@/assets/images/fireNew/4-3.png');
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  z-index: 2;
+
+  .dane-title {
+    width: 94%;
+    height: 40px;
+    text-align: center;
+    padding-top: 9px;
+
+    .common-navL {
+      position: relative;
+      align-items: center;
+      color: #fff;
+      font-size: 13px;
+      font-weight: bold;
+      font-family: 'douyuFont';
+    }
   }
 
-  .zxm-select-single.zxm-select-sm:not(.zxm-select-customize-input) .zxm-select-selector::after,
-  .zxm-select-single.zxm-select-sm:not(.zxm-select-customize-input) .zxm-select-selector .zxm-select-selection-item,
-  .zxm-select-single.zxm-select-sm:not(.zxm-select-customize-input) .zxm-select-selector .zxm-select-selection-placeholder {
-    line-height: 27px !important;
+  .dane-content {
+    height: calc(100% - 38px);
+    box-sizing: border-box;
+    border-top: none;
+    .common-navR {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      background: var(--image-Select) no-repeat;
+      background-size: 100% 100%;
+      width: 87%;
+      margin-left: 30px;
+    }
   }
+}
+.dane-m {
+  background: var(--image-Content) no-repeat;
+  background-size: 100% 100%;
+}
+:deep(.zxm-select-selector) {
+  height: 22px !important;
+  border: none !important;
+  // background-color: rgb(15 64 88) !important;
+  background-color: transparent !important;
+  color: #8087a1 !important;
+}
+
+:deep(.zxm-select-selection-placeholder) {
+  color: #8087a1 !important;
+}
+
+:deep(.zxm-select-arrow) {
+  color: #8087a1 !important;
+}
+
+:deep(.zxm-picker) {
+  border: none !important;
+  background-color: rgb(15 64 88) !important;
+  box-shadow: none;
+  color: #a1dff8 !important;
+}
+
+:deep(.zxm-picker-input > input) {
+  color: #a1dff8 !important;
+  text-align: center !important;
+}
+
+:deep(.zxm-picker-separator) {
+  color: #a1dff8 !important;
+}
+
+:deep(.zxm-picker-active-bar) {
+  display: none !important;
+}
+
+:deep(.zxm-picker-suffix) {
+  color: #a1dff8 !important;
+}
+
+:deep(.zxm-switch) {
+  min-width: 48px !important;
+}
+
+:deep(.zxm-switch-checked) {
+  background-color: rgb(15 64 89) !important;
+}
+
+:deep(.zxm-switch-handle::before) {
+  background-color: rgb(33 179 247) !important;
+}
+
+:deep(.zxm-select-selection-item) {
+  color: #fff !important;
+}
+
+.zxm-select-single.zxm-select-sm:not(.zxm-select-customize-input) .zxm-select-selector::after,
+.zxm-select-single.zxm-select-sm:not(.zxm-select-customize-input) .zxm-select-selector .zxm-select-selection-item,
+.zxm-select-single.zxm-select-sm:not(.zxm-select-customize-input) .zxm-select-selector .zxm-select-selection-placeholder {
+  line-height: 27px !important;
+}
 </style>

+ 187 - 187
src/views/vent/home/configurable/blue/components/ModuleFireNewDual.vue

@@ -47,232 +47,232 @@
 </template>
 
 <script setup lang="ts">
-  import Content from './content-FireNew.vue';
-  import { defineEmits, computed, ref, watch } from 'vue';
-  import { ModuleData, ShowStyle } from '../../../../deviceManager/configurationTable/types';
-  import { useInitModule } from '../../hooks/useInit';
+import Content from './content-FireNew.vue';
+import { defineEmits, computed, ref, watch } from 'vue';
+import { ModuleData, ShowStyle } from '../../../../deviceManager/configurationTable/types';
+import { useInitModule } from '../../hooks/useInit';
 
-  const props = defineProps<{
-    moduleDataA: ModuleData;
-    moduleNameA: string;
-    deviceTypeA: string;
-    moduleDataB: ModuleData;
-    moduleNameB: string;
-    deviceTypeB: string;
-    showStyle: ShowStyle;
-    visible: boolean;
-    data: any;
-  }>();
-  const emit = defineEmits(['close', 'select']);
+const props = defineProps<{
+  moduleDataA: ModuleData;
+  moduleNameA: string;
+  deviceTypeA: string;
+  moduleDataB: ModuleData;
+  moduleNameB: string;
+  deviceTypeB: string;
+  showStyle: ShowStyle;
+  visible: boolean;
+  data: any;
+}>();
+const emit = defineEmits(['close', 'select']);
 
-  const index = ref(0);
+const index = ref(0);
 
-  const headerA = props.moduleDataA.header;
-  const headerB = props.moduleDataB.header;
+const headerA = props.moduleDataA.header;
+const headerB = props.moduleDataB.header;
 
-  const {
-    selectedDeviceID: selectedDeviceIDA,
-    selectedDevice: selectedDeviceA,
-    options: optionsA,
-    init: initA,
-  } = useInitModule(props.deviceTypeA, props.moduleDataA);
-  const {
-    selectedDeviceID: selectedDeviceIDB,
-    selectedDevice: selectedDeviceB,
-    options: optionsB,
-    init: initB,
-  } = useInitModule(props.deviceTypeB, props.moduleDataB);
+const {
+  selectedDeviceID: selectedDeviceIDA,
+  selectedDevice: selectedDeviceA,
+  options: optionsA,
+  init: initA,
+} = useInitModule(props.deviceTypeA, props.moduleDataA);
+const {
+  selectedDeviceID: selectedDeviceIDB,
+  selectedDevice: selectedDeviceB,
+  options: optionsB,
+  init: initB,
+} = useInitModule(props.deviceTypeB, props.moduleDataB);
 
-  const style = computed(() => {
-    const size = props.showStyle.size;
-    const position = props.showStyle.position;
-    return size + position;
-  });
+const style = computed(() => {
+  const size = props.showStyle.size;
+  const position = props.showStyle.position;
+  return size + position;
+});
 
-  // 根据配置里的定位判断应该使用哪个module组件
-  const daneClass = computed(() => {
-    const headerPosition = props.showStyle.headerPosition;
-    const size = props.showStyle.size;
-    const [_, width] = size.match(/width:([0-9]+)px/) || [];
-    // if (position.includes('bottom') || parseInt(width) > 800) {
-    //   return 'dane-w';
-    // }
-    // if (position.includes('left')) {
-    //   return 'dane-m';
-    // }
-    // if (position.includes('right')) {
-    //   return 'dane-m';
-    // }
-    if (headerPosition === 'centerBottom') {
-      return 'center-Bottom';
-    }
-    if (headerPosition === 'centerTop') {
-      return 'center-Top';
-    }
-    return 'dane-m'; // 默认返回顶部模块
-  });
-
-  //切换时间选项
-  // function onChange(value, dateString) {
-  //   console.log('Selected Time: ', value);
-  //   console.log('Formatted Selected Time: ', dateString);
+// 根据配置里的定位判断应该使用哪个module组件
+const daneClass = computed(() => {
+  const headerPosition = props.showStyle.headerPosition;
+  const size = props.showStyle.size;
+  const [_, width] = size.match(/width:([0-9]+)px/) || [];
+  // if (position.includes('bottom') || parseInt(width) > 800) {
+  //   return 'dane-w';
   // }
-  // function onOk(val) {
-  //   console.log('onOk: ', val);
+  // if (position.includes('left')) {
+  //   return 'dane-m';
   // }
-
-  //下拉框选项切换
-  function selectHandlerA(id) {
-    selectedDeviceIDA.value = id;
-    emit('select', selectedDeviceA);
+  // if (position.includes('right')) {
+  //   return 'dane-m';
+  // }
+  if (headerPosition === 'centerBottom') {
+    return 'center-Bottom';
   }
-  function selectHandlerB(id) {
-    selectedDeviceIDB.value = id;
-    emit('select', selectedDeviceB);
+  if (headerPosition === 'centerTop') {
+    return 'center-Top';
   }
+  return 'dane-m'; // 默认返回顶部模块
+});
 
-  watch(
-    () => props.data,
-    (d) => {
-      initA(d);
-      initB(d);
-      selectedDeviceIDA.value = optionsA.value[0]?.value;
-      selectedDeviceIDB.value = optionsB.value[0]?.value;
-    },
-    {
-      immediate: true,
-    }
-  );
-</script>
+//切换时间选项
+// function onChange(value, dateString) {
+//   console.log('Selected Time: ', value);
+//   console.log('Formatted Selected Time: ', dateString);
+// }
+// function onOk(val) {
+//   console.log('onOk: ', val);
+// }
 
-<style scoped lang="less">
-  @import '/@/design/theme.less';
+//下拉框选项切换
+function selectHandlerA(id) {
+  selectedDeviceIDA.value = id;
+  emit('select', selectedDeviceA);
+}
+function selectHandlerB(id) {
+  selectedDeviceIDB.value = id;
+  emit('select', selectedDeviceB);
+}
 
-  @font-face {
-    font-family: 'douyuFont';
-    src: url('/@/assets/font/douyuFont.otf');
-  }
-  @{theme-deepblue} {
-    .dane-bd {
-      --image-CenterBottom: url('@/assets/images/fireNew/4-2.png');
-      --image-CenterTop: url('@/assets/images/fireNew/3-1.png');
-    }
+watch(
+  () => props.data,
+  (d) => {
+    initA(d);
+    initB(d);
+    selectedDeviceIDA.value = optionsA.value[0]?.value;
+    selectedDeviceIDB.value = optionsB.value[0]?.value;
+  },
+  {
+    immediate: true,
   }
+);
+</script>
+
+<style scoped lang="less">
+@import '/@/design/theme.less';
 
+@font-face {
+  font-family: 'douyuFont';
+  src: url('/@/assets/font/douyuFont.otf');
+}
+@{theme-deepblue} {
   .dane-bd {
     --image-CenterBottom: url('@/assets/images/fireNew/4-2.png');
     --image-CenterTop: url('@/assets/images/fireNew/3-1.png');
-    position: absolute;
+  }
+}
+
+.dane-bd {
+  --image-CenterBottom: url('@/assets/images/fireNew/4-2.png');
+  --image-CenterTop: url('@/assets/images/fireNew/3-1.png');
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  background-repeat: no-repeat;
+  background-position: center top;
+  background-size: 100% auto;
+  z-index: 2;
+  .dane-title {
+    display: flex;
+    box-sizing: border-box;
+    align-items: center;
+    justify-content: space-between;
     width: 100%;
-    height: 100%;
-    background-repeat: no-repeat;
-    background-position: center top;
-    background-size: 100% auto;
-    z-index: 2;
-    .dane-title {
+    height: 34px;
+    padding: 20px 173px 15px 133px;
+    .common-navL {
       display: flex;
-      box-sizing: border-box;
+      position: relative;
       align-items: center;
-      justify-content: space-between;
-      width: 100%;
-      height: 34px;
-      padding: 20px 173px 15px 133px;
-      .common-navL {
+      color: #fff;
+      font-size: 15px;
+      font-family: 'douyuFont';
+
+      .common-navR {
         display: flex;
-        position: relative;
         align-items: center;
-        color: #fff;
-        font-size: 15px;
-        font-family: 'douyuFont';
-
-        .common-navR {
-          display: flex;
-          align-items: center;
-          justify-content: flex-end;
-          .common-navR-select {
-            background: url('@/assets/images/fireNew/4-1.png') no-repeat;
-            background-size: 100% 100%;
-          }
+        justify-content: flex-end;
+        .common-navR-select {
+          background: url('@/assets/images/fireNew/4-1.png') no-repeat;
+          background-size: 100% 100%;
         }
       }
     }
-    .dane-content {
-      height: calc(100% - 34px);
-      box-sizing: border-box;
-      border-top: none;
-    }
-  }
-  .center-Top {
-    background: var(--image-CenterTop) no-repeat;
-    background-size: 100% 100%;
   }
-  .center-Bottom {
-    background: var(--image-CenterBottom) no-repeat;
-    background-size: 100% 100%;
-  }
-  .dane-m {
-    background-image: var(--image-MidBottom);
-    background-size: 100% 100%;
+  .dane-content {
+    height: calc(100% - 34px);
+    box-sizing: border-box;
+    border-top: none;
   }
+}
+.center-Top {
+  background: var(--image-CenterTop) no-repeat;
+  background-size: 100% 100%;
+}
+.center-Bottom {
+  background: var(--image-CenterBottom) no-repeat;
+  background-size: 100% 100%;
+}
+.dane-m {
+  background-image: var(--image-MidBottom);
+  background-size: 100% 100%;
+}
 
-  .deactived {
-    cursor: pointer;
-    color: #8087a1;
-  }
+.deactived {
+  cursor: pointer;
+  color: #ddd;
+}
 
-  :deep(.zxm-select-selector) {
-    height: 22px !important;
-    border: none !important;
-    // background-color: rgb(15 64 88) !important;
-    background-color: transparent !important;
-    color: #8087a1 !important;
-  }
+:deep(.zxm-select-selector) {
+  height: 22px !important;
+  border: none !important;
+  // background-color: rgb(15 64 88) !important;
+  background-color: transparent !important;
+  color: #8087a1 !important;
+}
 
-  :deep(.zxm-select-selection-placeholder) {
-    color: #8087a1 !important;
-  }
+:deep(.zxm-select-selection-placeholder) {
+  color: #8087a1 !important;
+}
 
-  :deep(.zxm-select-arrow) {
-    color: #8087a1 !important;
-  }
+:deep(.zxm-select-arrow) {
+  color: #8087a1 !important;
+}
 
-  :deep(.zxm-picker) {
-    border: none !important;
-    background-color: rgb(15 64 88) !important;
-    box-shadow: none;
-    color: #a1dff8 !important;
-  }
+:deep(.zxm-picker) {
+  border: none !important;
+  background-color: rgb(15 64 88) !important;
+  box-shadow: none;
+  color: #a1dff8 !important;
+}
 
-  :deep(.zxm-picker-input > input) {
-    color: #a1dff8 !important;
-    text-align: center !important;
-  }
+:deep(.zxm-picker-input > input) {
+  color: #a1dff8 !important;
+  text-align: center !important;
+}
 
-  :deep(.zxm-picker-separator) {
-    color: #a1dff8 !important;
-  }
+:deep(.zxm-picker-separator) {
+  color: #a1dff8 !important;
+}
 
-  :deep(.zxm-picker-active-bar) {
-    display: none !important;
-  }
+:deep(.zxm-picker-active-bar) {
+  display: none !important;
+}
 
-  :deep(.zxm-picker-suffix) {
-    color: #a1dff8 !important;
-  }
+:deep(.zxm-picker-suffix) {
+  color: #a1dff8 !important;
+}
 
-  :deep(.zxm-switch) {
-    min-width: 48px !important;
-  }
+:deep(.zxm-switch) {
+  min-width: 48px !important;
+}
 
-  :deep(.zxm-switch-checked) {
-    background-color: rgb(15 64 89) !important;
-  }
+:deep(.zxm-switch-checked) {
+  background-color: rgb(15 64 89) !important;
+}
 
-  :deep(.zxm-switch-handle::before) {
-    background-color: rgb(33 179 247) !important;
-  }
+:deep(.zxm-switch-handle::before) {
+  background-color: rgb(33 179 247) !important;
+}
 
-  :deep(.zxm-select-selection-item) {
-    color: #fff !important;
-  }
+:deep(.zxm-select-selection-item) {
+  color: #fff !important;
+}
 </style>

+ 6 - 2
src/views/vent/home/configurable/blue/components/NewNavFire.vue

@@ -100,11 +100,15 @@ function menuItemClick(index) {
   menuItemActive.value = index;
   isShowMenuItem.value = false;
 }
+// 2D/3D 微前端前缀会被路由守卫互相替换,匹配时统一只比较前缀之后的路径
+function normalizeMenuPath(path?: string) {
+  return (path || '').replace(/^\/micro-vent-[23]dModal/, '').replace(/\/+$/, '');
+}
 function updateActiveState(path: string) {
-  const currentPath = route.path;
+  const currentPath = normalizeMenuPath(path);
   menuList.value.forEach((menu, index) => {
     // 处理有直接链接的菜单项
-    if (menu.targatUrl === currentPath) {
+    if (menu.targatUrl && normalizeMenuPath(menu.targatUrl) === currentPath) {
       activeIndex.value = index;
       isShowMenuItem.value = false;
       return;

+ 4 - 4
src/views/vent/home/configurable/blue/components/detail/MiniBoard-Warn.vue

@@ -358,8 +358,8 @@ defineEmits(['click']);
 }
 .mini-board_T:nth-child(1) {
   .mini-board__value_T {
-    width: 50px;
-    height: 50px;
+    width: 60px;
+    height: 60px;
     margin: auto;
     background: url('@/assets/images/home-warn/7-2.png') no-repeat;
     background-size: 100% 100%;
@@ -367,8 +367,8 @@ defineEmits(['click']);
 }
 .mini-board_T:nth-child(2) {
   .mini-board__value_T {
-    width: 50px;
-    height: 50px;
+    width: 60px;
+    height: 60px;
     margin: auto;
     background: url('@/assets/images/home-warn/7-1.png') no-repeat;
     background-size: 100% 100%;

+ 1 - 1
src/views/vent/home/configurable/blue/components/yj_gasWarn.vue

@@ -48,7 +48,7 @@ let props = defineProps({
   --image-model_fire-container_A: url('@/assets/images/home-warn/5-1.png');
   --image-model_fire-container_B: url('@/assets/images/home-warn/5-2.png');
   height: 100%;
-  padding-left: 10px;
+  padding-left: 20px;
   padding-top: 10px;
   box-sizing: border-box;
 

+ 2 - 2
src/views/vent/home/configurable/blue/components/yj_ventWarn.vue

@@ -84,7 +84,7 @@ watch(
     }
 
     .risk-value {
-      flex: 1;
+      flex: 0;
       margin-right: 10px;
       color: #ffffff;
       text-align: right;
@@ -95,7 +95,7 @@ watch(
       margin-left: 20px;
       flex: 3;
       height: 8px;
-      margin: 0 10px;
+      margin: 0 6px;
       background: rgba(255, 255, 255, 0.1);
       border-radius: 4px;
       min-width: 80px;

+ 143 - 158
src/views/vent/home/configurable/blue/dustNew.vue

@@ -9,7 +9,7 @@
         style="width: 34px; height: 34px; position: absolute; right: 5px; bottom: 5px; z-index: 5"
         @click="redirectTo('/micro-vent-3dModal/dashboard/analysis?type=model3D&deviceType=model3D')"
       >
-        <!-- <EyeFilled /> -->
+        <EyeFilled />
       </a-button>
     </div>
     <div class="top-bg">
@@ -21,8 +21,8 @@
     </div>
     <!-- <ModuleDustNew -->
     <ModuleDustNew
-      v-for="cfg in configs"
-      :key="cfg.deviceType"
+      v-for="(cfg, index) in configs"
+      :key="cfg.deviceType + index"
       :show-style="cfg.showStyle"
       :module-data="cfg.moduleData"
       :module-name="cfg.moduleName"
@@ -33,177 +33,162 @@
   </div>
 </template>
 <script lang="ts" setup>
-  import { onMounted, onUnmounted } from 'vue';
-  // import { CaretDownOutlined } from '@ant-design/icons-vue';
-  import ModuleDustNew from './components/ModuleDustNew.vue';
-  import NewNav from './components/NewNavFire.vue';
-  import NavCard from './components/NavCard.vue';
-  import { useInitConfigs, useInitPage } from '../hooks/useInit';
-  import SubApp from '/@/components/vent/micro/createSubApp.vue';
-  import { getDisHome } from '../configurable.api';
-  // import { EyeFilled } from '@ant-design/icons-vue';
-  import { testConfigNewDust } from '../configurable.data.New';
-  // const cfgs = computed(() =>
-  //   configs.value.filter((_, index) => {
-  //     return index !== 4 && index !== 3;
-  //   })
-  // );
-  // const cfgA = computed<any>(() =>
-  //   configs.value.find((_, index) => {
-  //     return index === 3;
-  //   })
-  // );
-  // const cfgB = computed<any>(() =>
-  //   configs.value.find((_, index) => {
-  //     return index === 4;
-  //   })
-  // );
-  const { configs, devicesTypes, fetchConfigs } = useInitConfigs();
-  const { mainTitle, data, updateData } = useInitPage('粉尘预警系统');
-  let interval: number | undefined;
-  onMounted(() => {
-    fetchConfigs('New_dust').then(() => {
-      configs.value = testConfigNewDust;
-      getDisHome({
-        dataList: devicesTypes.value.concat('dustAllMineWarn').join(','),
-      }).then(updateData);
-    });
-    // [perf-base-v1] 定时器句柄赋值给 interval,修复卸载时清理无效的泄漏
-    interval = setInterval(() => {
-      getDisHome({
-        dataList: devicesTypes.value.concat('dustAllMineWarn').join(','),
-      }).then(updateData);
-    }, 2000);
-  });
+import { onMounted, onUnmounted } from 'vue';
+// import { CaretDownOutlined } from '@ant-design/icons-vue';
+import ModuleDustNew from './components/ModuleDustNew.vue';
+import NewNav from './components/NewNavFire.vue';
+import NavCard from './components/NavCard.vue';
+import { useInitConfigs, useInitPage } from '../hooks/useInit';
+import SubApp from '/@/components/vent/micro/createSubApp.vue';
+import { getDisHome } from '../configurable.api';
+import { EyeFilled } from '@ant-design/icons-vue';
+import { testConfigNewDust } from '../configurable.data.New';
+// const cfgs = computed(() =>
+//   configs.value.filter((_, index) => {
+//     return index !== 4 && index !== 3;
+//   })
+// );
+// const cfgA = computed<any>(() =>
+//   configs.value.find((_, index) => {
+//     return index === 3;
+//   })
+// );
+// const cfgB = computed<any>(() =>
+//   configs.value.find((_, index) => {
+//     return index === 4;
+//   })
+// );
+const { configs, devicesTypes } = useInitConfigs();
+const { mainTitle, data, updateData } = useInitPage('粉尘预警系统');
+let interval: number | undefined;
 
-  onUnmounted(() => {
-    clearInterval(interval);
-  });
+function fetchData() {
+  getDisHome({
+    dataList: devicesTypes.value.concat('dustAllMineWarn').join(','),
+  })
+    .then(updateData)
+    .catch((e) => console.error('粉尘首页数据获取失败:', e));
+}
 
-  function redirectTo(url) {
-    window.open(url);
-  }
+onMounted(() => {
+  // 首页结构与数据渲染使用本地配置,不再等待配置接口,避免接口异常导致首页空白
+  configs.value = testConfigNewDust;
+  fetchData();
+  // [perf-base-v1] 定时器句柄赋值给 interval,修复卸载时清理无效的泄漏
+  interval = setInterval(fetchData, 2000);
+});
+
+onUnmounted(() => {
+  clearInterval(interval);
+});
+
+function redirectTo(url) {
+  window.open(url);
+}
 </script>
 <style lang="less" scoped>
-  @import '/@/design/theme.less';
-
-  @font-face {
-    font-family: 'douyuFont';
-    src: url('../../../../assets/font/douyuFont.otf');
-  }
+@import '/@/design/theme.less';
 
-  @{theme-deepblue} {
-    .company-home {
-      --image-modal-top: url('@/assets/images/vent/homeNew/home-new-bg.png');
-    }
-  }
+@font-face {
+  font-family: 'douyuFont';
+  src: url('../../../../assets/font/douyuFont.otf');
+}
 
+@{theme-deepblue} {
   .company-home {
     --image-modal-top: url('@/assets/images/vent/homeNew/home-new-bg.png');
-    width: 100%;
-    height: 100%;
-    color: @white;
-    position: relative;
-    background: url('@/assets/images/vent/homeNew/bg.png') no-repeat center;
-
-    .top-bg {
-      width: 100%;
-      height: 56px;
-      background: var(--image-modal-top) no-repeat center;
-      position: absolute;
-      z-index: 1;
-
-      .main-title {
-        height: 56px;
-        font-family: 'douyuFont';
-        font-size: 20px;
-        letter-spacing: 2px;
-        display: flex;
-        justify-content: center;
-        align-items: center;
-      }
+  }
+}
 
-      .top-nav {
-        position: absolute;
-        top: 0;
-        width: 880px;
-        height: 100%;
-        display: flex;
-        justify-content: flex-start;
-      }
-    }
+.company-home {
+  --image-modal-top: url('@/assets/images/vent/homeNew/home-new-bg.png');
+  width: 100%;
+  height: 100%;
+  color: @white;
+  position: relative;
+  background: url('@/assets/images/vent/homeNew/bg.png') no-repeat center;
 
-    .left-t {
-      position: absolute;
-      width: 28%;
-      height: 100%;
-      background: url('@/assets/images/vent/homeNew/leftContent.png') no-repeat center;
-      z-index: 0;
-    }
+  .top-bg {
+    width: 100%;
+    height: 56px;
+    // 横幅原图 790×74,按顶栏高度等比缩放,避免上下各被裁掉 9px
+    background: var(--image-modal-top) no-repeat center;
+    background-size: auto 100%;
+    position: absolute;
+    z-index: 1;
+  }
 
-    .right-t {
-      position: absolute;
-      width: 172%;
-      height: 100%;
-      background: url('@/assets/images/vent/homeNew/rightContent.png') no-repeat center;
-      z-index: 0;
-    }
+  .left-t {
+    position: absolute;
+    width: 28%;
+    height: 100%;
+    background: url('@/assets/images/vent/homeNew/leftContent.png') no-repeat center;
+    z-index: 0;
+  }
 
-    // .module-left {
-    //   position: absolute;
-    //   width: 450px;
-    //   height: 280px;
-    //   left: 0;
-    // }
-    // .module-right {
-    //   position: absolute;
-    //   width: 450px;
-    //   height: 280px;
-    //   right: 0;
-    // }
-    // .module-bottom {
-    //   position: absolute;
-    //   width: 1000px;
-    //   height: 280px;
-    // }
-    .module-dropdown {
-      padding: 10px;
-      background-image: @vent-configurable-dropdown;
-      border-bottom: 2px solid @vent-configurable-home-light-border;
-      color: @vent-font-color;
-      position: absolute;
-      top: 70px;
-      right: 460px;
-    }
+  .right-t {
+    position: absolute;
+    width: 172%;
+    height: 100%;
+    background: url('@/assets/images/vent/homeNew/rightContent.png') no-repeat center;
+    z-index: 0;
+  }
 
-    .module-dropdown-original {
-      padding: 10px;
-      background-image: @vent-configurable-dropdown;
-      border-bottom: 2px solid @vent-configurable-home-light-border;
-      color: @vent-font-color;
-      position: absolute;
-      top: 70px;
-      right: 460px;
-    }
+  // .module-left {
+  //   position: absolute;
+  //   width: 450px;
+  //   height: 280px;
+  //   left: 0;
+  // }
+  // .module-right {
+  //   position: absolute;
+  //   width: 450px;
+  //   height: 280px;
+  //   right: 0;
+  // }
+  // .module-bottom {
+  //   position: absolute;
+  //   width: 1000px;
+  //   height: 280px;
+  // }
+  .module-dropdown {
+    padding: 10px;
+    background-image: @vent-configurable-dropdown;
+    border-bottom: 2px solid @vent-configurable-home-light-border;
+    color: @vent-font-color;
+    position: absolute;
+    top: 70px;
+    right: 460px;
+  }
 
-    .module-trigger-button {
-      color: @vent-font-color;
-      background-image: @vent-configurable-dropdown;
-      border: none;
-      border-bottom: 2px solid @vent-configurable-home-light-border;
-    }
+  .module-dropdown-original {
+    padding: 10px;
+    background-image: @vent-configurable-dropdown;
+    border-bottom: 2px solid @vent-configurable-home-light-border;
+    color: @vent-font-color;
+    position: absolute;
+    top: 70px;
+    right: 460px;
+  }
 
-    .nav-cards {
-      position: absolute;
-      left: 50%;
-      top: 60px;
-      transform: translate(-50%, 0);
-      width: 523px;
-      height: 89px;
-    }
+  .module-trigger-button {
+    color: @vent-font-color;
+    background-image: @vent-configurable-dropdown;
+    border: none;
+    border-bottom: 2px solid @vent-configurable-home-light-border;
   }
 
-  :deep(.loading-box) {
-    position: unset;
+  .nav-cards {
+    position: absolute;
+    left: 50%;
+    top: 60px;
+    transform: translate(-50%, 0);
+    width: 523px;
+    height: 89px;
   }
+}
+
+:deep(.loading-box) {
+  position: unset;
+}
 </style>

+ 20 - 33
src/views/vent/home/configurable/blue/fireNew.vue

@@ -9,7 +9,7 @@
         style="width: 34px; height: 34px; position: absolute; right: 5px; bottom: 5px; z-index: 5"
         @click="redirectTo('/micro-vent-3dModal/dashboard/analysis?type=model3D&deviceType=model3D')"
       >
-        <!-- <EyeFilled /> -->
+        <EyeFilled />
       </a-button>
     </div>
     <div class="top-bg">
@@ -18,8 +18,8 @@
       <NewNav :Title="mainTitle" />
     </div>
     <ModuleFireNew
-      v-for="cfg in cfgs"
-      :key="cfg.deviceType"
+      v-for="(cfg, index) in cfgs"
+      :key="cfg.deviceType + index"
       :show-style="cfg.showStyle"
       :module-data="cfg.moduleData"
       :module-name="cfg.moduleName"
@@ -61,7 +61,7 @@ import ModuleFireNewDual from './components/ModuleFireNewDual.vue';
 import SubApp from '/@/components/vent/micro/createSubApp.vue';
 // import { BDFireMock } from './mock';
 import { getDisHome } from '../configurable.api';
-// import { EyeFilled } from '@ant-design/icons-vue';
+import { EyeFilled } from '@ant-design/icons-vue';
 import { testConfigNewFire } from '../configurable.data.New';
 const cfgs = computed(() =>
   configs.value.filter((_, index) => {
@@ -83,9 +83,18 @@ const cfgTop = computed<any>(() =>
     return index === 5;
   })
 );
-const { configs, devicesTypes, fetchConfigs } = useInitConfigs();
+const { configs, devicesTypes } = useInitConfigs();
 const { mainTitle, data, updateData /** updateEnhancedConfigs */ } = useInitPage('火灾预警系统');
 let interval: number | undefined;
+
+function fetchData() {
+  getDisHome({
+    dataList: devicesTypes.value.concat('fireAllMineWarn').join(','),
+  })
+    .then(updateData)
+    .catch((e) => console.error('火灾首页数据获取失败:', e));
+}
+
 onMounted(() => {
   // fetchConfigs('New_fire').then(() => {
   //   configs.value = testConfigNewFire;
@@ -94,18 +103,11 @@ onMounted(() => {
   // setInterval(() => {
   //   Promise.resolve(BDFireMock).then(updateData);
   // }, 2000);
-  fetchConfigs('New_fire').then(() => {
-    configs.value = testConfigNewFire;
-    getDisHome({
-      dataList: devicesTypes.value.concat('fireAllMineWarn').join(','),
-    }).then(updateData);
-  });
+  // 首页结构与数据渲染使用本地配置,不再等待配置接口,避免接口异常导致首页空白
+  configs.value = testConfigNewFire;
+  fetchData();
   // [perf-base-v1] 定时器句柄赋值给 interval,修复卸载时清理无效的泄漏
-  interval = setInterval(() => {
-    getDisHome({
-      dataList: devicesTypes.value.concat('fireAllMineWarn').join(','),
-    }).then(updateData);
-  }, 2000);
+  interval = setInterval(fetchData, 2000);
 });
 
 onUnmounted(() => {
@@ -141,26 +143,11 @@ function redirectTo(url) {
   .top-bg {
     width: 100%;
     height: 56px;
+    // 横幅原图 790×74,按顶栏高度等比缩放,避免上下各被裁掉 9px
     background: var(--image-modal-top) no-repeat center;
+    background-size: auto 100%;
     position: absolute;
     z-index: 1;
-    .main-title {
-      height: 56px;
-      font-family: 'douyuFont';
-      font-size: 20px;
-      letter-spacing: 2px;
-      display: flex;
-      justify-content: center;
-      align-items: center;
-    }
-    .top-nav {
-      position: absolute;
-      top: 0;
-      width: 880px;
-      height: 100%;
-      display: flex;
-      justify-content: flex-start;
-    }
   }
   .left-t {
     position: absolute;

+ 14 - 12
src/views/vent/home/configurable/blue/homeBlue.vue

@@ -13,8 +13,8 @@
     <div class="right-t"> </div>
     <template v-if="isNew">
       <ModuleNew
-        v-for="cfg in configs"
-        :key="cfg.deviceType"
+        v-for="(cfg, index) in configs"
+        :key="cfg.deviceType + index"
         :show-style="cfg.showStyle"
         :module-data="cfg.moduleData"
         :module-name="cfg.moduleName"
@@ -33,26 +33,28 @@ import NewNav from './components/originalNew/NewNavFire.vue';
 import { useInitConfigs, useInitPage } from '../hooks/useInit';
 import ModuleNew from './components/ModuleNew.vue';
 // import { useRoute } from 'vue-router';
-import VentModal from '/@/components/vent/micro/ventModal.vue';
 import { getHomeData } from '../configurable.api';
 // import { useRoute } from 'vue-router';
 import { testConfigVentNew } from '../configurable.data';
-const { configs, isNew, fetchConfigs } = useInitConfigs();
+const { configs, isNew } = useInitConfigs();
 const { mainTitle, data, updateData, updateEnhancedConfigs } = useInitPage('一通三防智能管控平台');
 // const route = useRoute();
 let interval: number | undefined;
 
+function fetchData() {
+  getHomeData({})
+    .then(updateData)
+    .catch((e) => console.error('首页数据获取失败:', e));
+}
+
 onMounted(() => {
-  fetchConfigs('vent').then(() => {
-    configs.value = testConfigVentNew;
-    updateEnhancedConfigs(configs.value);
+  // 首页结构与数据渲染使用本地配置,不再等待配置接口,避免接口异常导致首页空白
+  configs.value = testConfigVentNew;
+  updateEnhancedConfigs(configs.value);
 
-    getHomeData({}).then(updateData);
-  });
+  fetchData();
   // [perf-base-v1] 定时器句柄赋值给 interval,修复卸载时清理无效的泄漏
-  interval = setInterval(() => {
-    getHomeData({}).then(updateData);
-  }, 60000);
+  interval = setInterval(fetchData, 60000);
 });
 
 onUnmounted(() => {

+ 127 - 142
src/views/vent/home/configurable/blue/ventNew.vue

@@ -13,8 +13,8 @@
       <NavVentCard />
     </div>
     <ModuleDustNew
-      v-for="cfg in configs"
-      :key="cfg.deviceType"
+      v-for="(cfg, index) in configs"
+      :key="cfg.deviceType + index"
       :show-style="cfg.showStyle"
       :module-data="cfg.moduleData"
       :module-name="cfg.moduleName"
@@ -25,160 +25,145 @@
   </div>
 </template>
 <script lang="ts" setup>
-  import { onMounted, onUnmounted } from 'vue';
-  // import { CaretDownOutlined } from '@ant-design/icons-vue';
-  // import MonitorCenter from './components/MonitorCenter.vue';
-  import NewNav from './components/NewNavFire.vue';
-  import { useInitConfigs, useInitPage } from '../hooks/useInit';
-  import ModuleDustNew from './components/ModuleDustNew.vue';
-  import NavVentCard from './components/NavVentCard.vue';
-  // import { useRoute } from 'vue-router';
-  import VentModal from '/@/components/vent/micro/ventModal.vue';
-  import { getHomeData } from '../configurable.api';
-  // import { useRoute } from 'vue-router';
-  import { testConfigVentNew } from '../configurable.data';
-  const { configs, fetchConfigs } = useInitConfigs();
-  const { mainTitle, data, updateData } = useInitPage('一通三防智能管控平台');
-  // const route = useRoute();
-  let interval: number | undefined;
-
-  onMounted(() => {
-    // fetchConfigs('vent_new').then(() => {
-    configs.value = testConfigVentNew;
-    // updateEnhancedConfigs(configs.value);
-
-    getHomeData({}).then(updateData);
-    // });
-    // [perf-base-v1] 定时器句柄赋值给 interval,修复卸载时清理无效的泄漏
-    interval = setInterval(() => {
-      getHomeData({}).then(updateData);
-    }, 60000);
-  });
-
-  onUnmounted(() => {
-    clearInterval(interval);
-  });
+import { onMounted, onUnmounted } from 'vue';
+// import { CaretDownOutlined } from '@ant-design/icons-vue';
+// import MonitorCenter from './components/MonitorCenter.vue';
+import NewNav from './components/NewNavFire.vue';
+import { useInitConfigs, useInitPage } from '../hooks/useInit';
+import ModuleDustNew from './components/ModuleDustNew.vue';
+import NavVentCard from './components/NavVentCard.vue';
+// import { useRoute } from 'vue-router';
+import SubApp from '/@/components/vent/micro/createSubApp.vue';
+import { getHomeData } from '../configurable.api';
+// import { useRoute } from 'vue-router';
+import { testConfigVentNew } from '../configurable.data';
+const { configs } = useInitConfigs();
+const { mainTitle, data, updateData } = useInitPage('一通三防智能管控平台');
+// const route = useRoute();
+let interval: number | undefined;
+
+function fetchData() {
+  getHomeData({})
+    .then(updateData)
+    .catch((e) => console.error('首页数据获取失败:', e));
+}
+
+onMounted(() => {
+  configs.value = testConfigVentNew;
+  // updateEnhancedConfigs(configs.value);
+
+  fetchData();
+  // [perf-base-v1] 定时器句柄赋值给 interval,修复卸载时清理无效的泄漏
+  interval = setInterval(fetchData, 60000);
+});
+
+onUnmounted(() => {
+  clearInterval(interval);
+});
 </script>
 <style lang="less" scoped>
-  @import '/@/design/theme.less';
-
-  @font-face {
-    font-family: 'douyuFont';
-    src: url('../../../../assets/font/douyuFont.otf');
-  }
+@import '/@/design/theme.less';
 
-  @{theme-deepblue} {
-    .company-home {
-      --image-modal-top: url('@/assets/images/vent/homeNew/home-new-bg.png');
-    }
-  }
+@font-face {
+  font-family: 'douyuFont';
+  src: url('../../../../assets/font/douyuFont.otf');
+}
 
+@{theme-deepblue} {
   .company-home {
     --image-modal-top: url('@/assets/images/vent/homeNew/home-new-bg.png');
-    width: 100%;
-    height: 100%;
-    color: @white;
-    position: relative;
-    background: url('@/assets/images/vent/homeNew/bg.png') no-repeat center;
-
-    .top-bg {
-      width: 100%;
-      height: 56px;
-      background: var(--image-modal-top) no-repeat center;
-      position: absolute;
-      z-index: 1;
-
-      .main-title {
-        height: 56px;
-        font-family: 'douyuFont';
-        font-size: 20px;
-        letter-spacing: 2px;
-        display: flex;
-        justify-content: center;
-        align-items: center;
-      }
+  }
+}
 
-      .top-nav {
-        position: absolute;
-        top: 0;
-        width: 880px;
-        height: 100%;
-        display: flex;
-        justify-content: flex-start;
-      }
-    }
+.company-home {
+  --image-modal-top: url('@/assets/images/vent/homeNew/home-new-bg.png');
+  width: 100%;
+  height: 100%;
+  color: @white;
+  position: relative;
+  background: url('@/assets/images/vent/homeNew/bg.png') no-repeat center;
 
-    .left-t {
-      position: absolute;
-      width: 28%;
-      height: 100%;
-      background: url('@/assets/images/vent/homeNew/leftContent.png') no-repeat center;
-      z-index: 0;
-    }
+  .top-bg {
+    width: 100%;
+    height: 56px;
+    // 横幅原图 790×74,按顶栏高度等比缩放,避免上下各被裁掉 9px
+    background: var(--image-modal-top) no-repeat center;
+    background-size: auto 100%;
+    position: absolute;
+    z-index: 1;
+  }
 
-    .right-t {
-      position: absolute;
-      width: 172%;
-      height: 100%;
-      background: url('@/assets/images/vent/homeNew/rightContent.png') no-repeat center;
-      z-index: 0;
-    }
+  .left-t {
+    position: absolute;
+    width: 28%;
+    height: 100%;
+    background: url('@/assets/images/vent/homeNew/leftContent.png') no-repeat center;
+    z-index: 0;
+  }
 
-    // .module-left {
-    //   position: absolute;
-    //   width: 450px;
-    //   height: 280px;
-    //   left: 0;
-    // }
-    // .module-right {
-    //   position: absolute;
-    //   width: 450px;
-    //   height: 280px;
-    //   right: 0;
-    // }
-    // .module-bottom {
-    //   position: absolute;
-    //   width: 1000px;
-    //   height: 280px;
-    // }
-    .module-dropdown {
-      padding: 10px;
-      background-image: @vent-configurable-dropdown;
-      border-bottom: 2px solid @vent-configurable-home-light-border;
-      color: @vent-font-color;
-      position: absolute;
-      top: 70px;
-      right: 460px;
-    }
+  .right-t {
+    position: absolute;
+    width: 172%;
+    height: 100%;
+    background: url('@/assets/images/vent/homeNew/rightContent.png') no-repeat center;
+    z-index: 0;
+  }
 
-    .module-dropdown-original {
-      padding: 10px;
-      background-image: @vent-configurable-dropdown;
-      border-bottom: 2px solid @vent-configurable-home-light-border;
-      color: @vent-font-color;
-      position: absolute;
-      top: 70px;
-      right: 460px;
-    }
+  // .module-left {
+  //   position: absolute;
+  //   width: 450px;
+  //   height: 280px;
+  //   left: 0;
+  // }
+  // .module-right {
+  //   position: absolute;
+  //   width: 450px;
+  //   height: 280px;
+  //   right: 0;
+  // }
+  // .module-bottom {
+  //   position: absolute;
+  //   width: 1000px;
+  //   height: 280px;
+  // }
+  .module-dropdown {
+    padding: 10px;
+    background-image: @vent-configurable-dropdown;
+    border-bottom: 2px solid @vent-configurable-home-light-border;
+    color: @vent-font-color;
+    position: absolute;
+    top: 70px;
+    right: 460px;
+  }
 
-    .module-trigger-button {
-      color: @vent-font-color;
-      background-image: @vent-configurable-dropdown;
-      border: none;
-      border-bottom: 2px solid @vent-configurable-home-light-border;
-    }
+  .module-dropdown-original {
+    padding: 10px;
+    background-image: @vent-configurable-dropdown;
+    border-bottom: 2px solid @vent-configurable-home-light-border;
+    color: @vent-font-color;
+    position: absolute;
+    top: 70px;
+    right: 460px;
+  }
 
-    .nav-cards {
-      position: absolute;
-      left: 50%;
-      top: 60px;
-      transform: translate(-50%, 0);
-      width: 661px;
-      height: 89px;
-    }
+  .module-trigger-button {
+    color: @vent-font-color;
+    background-image: @vent-configurable-dropdown;
+    border: none;
+    border-bottom: 2px solid @vent-configurable-home-light-border;
   }
 
-  :deep(.loading-box) {
-    position: unset;
+  .nav-cards {
+    position: absolute;
+    left: 50%;
+    top: 60px;
+    transform: translate(-50%, 0);
+    width: 661px;
+    height: 89px;
   }
+}
+
+:deep(.loading-box) {
+  position: unset;
+}
 </style>

+ 30 - 70
src/views/vent/home/configurable/blue/warnMonitor.vue

@@ -16,12 +16,12 @@
       />
     </template>
     <div class="center-area">
-      <CenterAreaWarn :echartData="paramData"></CenterAreaWarn>
+      <CenterAreaWarn :echartData="paramData" />
     </div>
   </div>
 </template>
 <script lang="ts" setup>
-import { onMounted, onUnmounted, reactive, ref } from 'vue';
+import { onMounted, onUnmounted, reactive } from 'vue';
 import { useInitConfigs, useInitPage } from '../hooks/useInit';
 import ModuleDustNew from './components/ModuleWarnMonitor.vue';
 import CenterAreaWarn from './components/center-area-warn.vue';
@@ -31,27 +31,32 @@ import { testConfigWarnMonitor } from '../configurable.data';
 import NewNav from './components/NewNavFire.vue';
 import { getTotal, getDisasterProportion } from '../configurable.api';
 const { title = '智能通风管控系统' } = useGlobSetting();
-const { configs, fetchConfigs } = useInitConfigs();
+const { configs } = useInitConfigs();
 const { data, updateData } = useInitPage(title);
 const route = useRoute();
 const paramData = reactive<any>({});
 // https获取监测数据
 let timer: null | NodeJS.Timeout = null;
+let unmounted = false;
 function getMonitor(flag = false) {
   timer = setTimeout(
     async () => {
-      getTotal({}).then(updateData);
-      getCenterList();
-      if (timer) {
-        timer = null;
+      try {
+        // 一个周期只请求一次 getTotal,返回的数据同时提供给各模块与中间区域
+        const res = await getTotal({});
+        updateData(res);
+        await getCenterList(res);
+      } catch (e) {
+        console.error('预警监测数据获取失败:', e);
       }
+      if (unmounted) return;
       getMonitor();
     },
     flag ? 0 : 20000
   );
 }
-async function getCenterList() {
-  let res = await getTotal({});
+async function getCenterList(res) {
+  if (!res || !res.info || !res.info.sysInfo) return;
   paramData.fire = res.info.sysInfo.fireS.maxLevel;
   paramData.tf = res.info.sysInfo.ventS.maxLevel;
   paramData.ws = res.info.sysInfo.gasS.maxLevel;
@@ -77,17 +82,16 @@ async function getCenterList() {
   ];
 }
 onMounted(() => {
-  fetchConfigs('fusion-warn-green').then(() => {
-    configs.value = testConfigWarnMonitor;
-    // updateEnhancedConfigs(configs.value);
-    // Promise.resolve(BDFireMock).then(updateData);
-    getMonitor(true);
-  });
+  // 页面结构与数据渲染使用本地配置,不再等待配置接口,避免接口异常导致首页空白
+  configs.value = testConfigWarnMonitor;
+  // updateEnhancedConfigs(configs.value);
+  getMonitor(true);
 });
 
 onUnmounted(() => {
+  unmounted = true;
   clearTimeout(timer);
-  timer = undefined;
+  timer = null;
 });
 </script>
 <style lang="less" scoped>
@@ -98,7 +102,14 @@ onUnmounted(() => {
   src: url('/@/assets/font/douyuFont.otf');
 }
 
+@{theme-deepblue} {
+  .company-home {
+    --image-modal-top: url('@/assets/images/home-warn/1-1.png');
+  }
+}
+
 .company-home {
+  --image-modal-top: url('@/assets/images/home-warn/1-1.png');
   width: 100%;
   height: 100%;
   color: @white;
@@ -107,62 +118,11 @@ onUnmounted(() => {
   .top-bg {
     width: 100%;
     height: 56px;
-    background: url('@/assets/images/home-warn/1-1.png') no-repeat center;
+    // 横幅原图 790×74,按顶栏高度等比缩放,避免上下各被裁掉 9px
+    background: var(--image-modal-top) no-repeat center;
+    background-size: auto 100%;
     position: absolute;
     z-index: 1;
-    .main-title {
-      height: 56px;
-      color: #fff;
-      font-family: 'douyuFont';
-      font-size: 20px;
-      letter-spacing: 2px;
-      display: flex;
-      justify-content: center;
-      align-items: center;
-    }
-    .top-nav {
-      position: absolute;
-      top: 0;
-      width: 880px;
-      height: 100%;
-      display: flex;
-      justify-content: flex-start;
-    }
-  }
-  .main-container {
-    width: 100%;
-    height: 100%;
-    .left-area {
-      position: absolute;
-      left: 0;
-      top: 0;
-      width: 420px;
-      height: 100%;
-      padding: 15px;
-      box-sizing: border-box;
-      overflow-y: auto;
-    }
-
-    .bottom-area {
-      position: absolute;
-      left: 435px;
-      bottom: 0;
-      width: calc(100% - 435px);
-      height: 290px;
-      padding: 15px;
-      box-sizing: border-box;
-    }
-
-    .right-area {
-      position: absolute;
-      right: 0px;
-      top: 0px;
-      width: 420px;
-      height: calc(100% - 305px);
-      padding: 15px 15px 0px 15px;
-      box-sizing: border-box;
-      overflow-y: auto;
-    }
   }
 
   .center-area {

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
src/views/vent/home/configurable/components/detail/CustomChart.vue


+ 4 - 1
src/views/vent/home/configurable/components/detail/CustomList-fire.vue

@@ -298,10 +298,13 @@ function getIconPath1(item) {
   margin-left: 12px;
   background-size: 100% 100%;
 }
+.list__wrapper_H {
+  margin-top: 30px;
+}
 .list-item_H {
   align-items: center;
   text-align: center;
-  margin: 10px;
+  margin: 20px 14px;
   &:nth-child(2n) {
     height: 34px;
     background-repeat: no-repeat;

+ 63 - 18
src/views/vent/home/configurable/components/preset/QHCurve.vue

@@ -16,7 +16,14 @@
   //   import { useGlobSetting } from '/@/hooks/setting';
   //   const { sysOrgCode } = useGlobSetting();
 
-  const props = defineProps<{ mainfan: Record<string, any>; /** QH */ fan1Prop: [string, string]; fan2Prop: [string, string] }>();
+  const props = defineProps<{
+    mainfan: Record<string, any>;
+    /** QH */
+    fan1Prop: [string, string];
+    fan2Prop: [string, string];
+    /** 绿色版主通风机:收紧边距、保证刻度与轴名完整显示 */
+    compact?: boolean;
+  }>();
   const selectDataObj = ref({
     dataQ: 0,
     dataH: 0,
@@ -98,6 +105,9 @@
   function getOption() {
     nextTick(() => {
       const myChart = echarts.init(main.value);
+      const compact = !!props.compact;
+      const labelSize = compact ? 11 : 14;
+      const nameSize = compact ? 11 : 12;
       let option = {
         title: {
           textStyle: {
@@ -106,19 +116,30 @@
           },
           text: '',
         },
-        grid: {
-          top: '25%',
-          left: '5%',
-          right: '19%',
-          bottom: '8%',
-          containLabel: true,
-        },
+        grid: compact
+          ? {
+              top: 28,
+              left: 6,
+              right: 52,
+              bottom: 6,
+              containLabel: true,
+            }
+          : {
+              top: '25%',
+              left: '5%',
+              right: '19%',
+              bottom: '8%',
+              containLabel: true,
+            },
         legend: {
           // x:'right',
-          top: '5%',
+          top: compact ? 2 : '5%',
+          itemWidth: compact ? 14 : 25,
+          itemHeight: compact ? 8 : 14,
+          itemGap: compact ? 12 : 10,
           textStyle: {
             color: '#ffffff',
-            fontSize: 14, // 字体颜色
+            fontSize: compact ? 12 : 14, // 字体颜色
           },
           data: ['风量', '负压'],
         },
@@ -137,14 +158,33 @@
             return res;
           },
           trigger: 'item',
+          // 紧凑模式下悬浮卡片下移,避免挡住左上角刻度与单位
+          position: compact
+            ? (point, _params, _dom, _rect, size) => {
+                const contentW = size.contentSize[0];
+                const contentH = size.contentSize[1];
+                const viewW = size.viewSize[0];
+                const viewH = size.viewSize[1];
+                let x = point[0] + 14;
+                let y = point[1] + 36;
+                if (x + contentW > viewW - 4) x = Math.max(4, point[0] - contentW - 14);
+                if (y + contentH > viewH - 4) y = Math.max(4, viewH - contentH - 4);
+                return [x, y];
+              }
+            : undefined,
         },
         color: ['#00bb00', '#ffbb00', '#ff0000', '#0000ff'],
         xAxis: [
           {
-            name: '风量\r\n(m³/min)\r\n',
+            name: compact ? '风量\n(m³/min)' : '风量\r\n(m³/min)\r\n',
+            nameGap: compact ? 4 : 15,
             nameTextStyle: {
               color: '#3df6ff',
-              fontSize: 12,
+              fontSize: nameSize,
+              lineHeight: compact ? 14 : 18,
+              // 紧凑模式下单位上移,避免与横轴刻度重叠
+              padding: compact ? [-20, 0, 0, 2] : undefined,
+              verticalAlign: compact ? 'bottom' : undefined,
             },
             axisLine: {
               lineStyle: {
@@ -162,9 +202,11 @@
             axisLabel: {
               show: true,
               position: 'bottom',
+              hideOverlap: true,
+              margin: compact ? 6 : 8,
               textStyle: {
                 color: '#b3b8cc',
-                fontSize: 14,
+                fontSize: labelSize,
               },
               formatter: function (value) {
                 return value * 60 + '';
@@ -178,7 +220,7 @@
             name: '',
             nameTextStyle: {
               color: '#3df6ff',
-              fontSize: 12,
+              fontSize: nameSize,
             },
             axisTick: {
               show: false,
@@ -201,7 +243,7 @@
               show: false,
               textStyle: {
                 color: '#b3b8cc',
-                fontSize: 14,
+                fontSize: labelSize,
               },
             },
             type: 'value',
@@ -213,10 +255,12 @@
         yAxis: [
           {
             name: '负压(Pa)',
-            splitNumber: 5,
+            splitNumber: compact ? 4 : 5,
+            nameGap: compact ? 6 : 15,
             nameTextStyle: {
               color: '#3df6ff',
-              fontSize: 12,
+              fontSize: nameSize,
+              padding: compact ? [0, 0, 0, 4] : undefined,
             },
             axisLine: {
               show: true,
@@ -234,9 +278,10 @@
             },
             axisLabel: {
               show: true,
+              margin: compact ? 4 : 8,
               textStyle: {
                 color: '#b3b8cc',
-                fontSize: 14,
+                fontSize: labelSize,
               },
             },
             type: 'value',

+ 10 - 13
src/views/vent/home/configurable/components/preset/earlyWarnMonitor.vue

@@ -34,21 +34,20 @@
 </template>
 
 <script setup lang="ts">
-import { ref, watch } from 'vue'
+import { ref, watch } from 'vue';
 
 let props = defineProps({
   warnData: {
-    type: Object
-  }
-})
-
-
+    type: Object,
+  },
+});
 </script>
 
 <style lang="less" scoped>
 @import '/@/design/theme.less';
 
-@{theme-deepblue} {}
+@{theme-deepblue} {
+}
 
 .early-warn-monitor {
   --image-warn-title: url('@/assets/images/fireNew/warn-title.png');
@@ -67,7 +66,7 @@ let props = defineProps({
     align-items: center;
     justify-content: space-between;
     width: 100%;
-    height: 48px;
+    height: 28px;
     background: var(--image-warn-title) no-repeat;
     background-size: 100% 100%;
     margin-bottom: 10px;
@@ -86,7 +85,7 @@ let props = defineProps({
     justify-content: flex-start;
     flex-wrap: wrap;
     width: 100%;
-    height: calc(100% - 58px);
+    height: calc(100% - 36px);
     background: var(--image-warn-bg) no-repeat;
     background-size: 100% 100%;
   }
@@ -101,8 +100,8 @@ let props = defineProps({
 
   .item-bg {
     position: relative;
-    width: 160px;
-    height: 62px;
+    width: 114px;
+    height: 60px;
     text-align: center;
   }
 
@@ -158,7 +157,5 @@ let props = defineProps({
   .text-red {
     color: #ff3823;
   }
-
-
 }
 </style>

+ 30 - 24
src/views/vent/home/configurable/configurable.data.ts

@@ -481,12 +481,12 @@ export const testConfigVent: Config[] = [
         direction: 'column',
         items: [
           {
-            name: 'qh_curve',
-            basis: '67%',
+            name: 'board',
+            basis: '32%',
           },
           {
-            name: 'board',
-            basis: '33%',
+            name: 'qh_curve',
+            basis: '68%',
           },
         ],
       },
@@ -527,9 +527,9 @@ export const testConfigVent: Config[] = [
       to: '/micro-vent-3dModal/dashboard/analysis?type=tunMonitor&deviceType=fanmain',
     },
     showStyle: {
-      size: 'width:390px;height:260px;',
+      size: 'width:390px;height:275px;',
       version: '原版',
-      position: 'top:15px;left:15px;',
+      position: 'top:5px;left:15px;',
     },
   },
   {
@@ -589,7 +589,7 @@ export const testConfigVent: Config[] = [
     showStyle: {
       size: 'width:390px;height:260px;',
       version: '原版',
-      position: 'top:295px;left:15px;',
+      position: 'top:305px;left:15px;',
     },
   },
   // {
@@ -725,7 +725,7 @@ export const testConfigVent: Config[] = [
     showStyle: {
       size: 'width:390px;height:260px;',
       version: '原版',
-      position: 'top:555px;left:15px;',
+      position: 'top:560px;left:15px;',
     },
   },
   {
@@ -794,7 +794,7 @@ export const testConfigVent: Config[] = [
     showStyle: {
       size: 'width:505px;height:260px;',
       version: '原版',
-      position: 'bottom:15px;left:420px;',
+      position: 'top:560px;left:420px;bottom:auto;',
     },
   },
   {
@@ -820,23 +820,23 @@ export const testConfigVent: Config[] = [
         link: '',
       },
       layout: {
-        direction: 'column',
+        direction: 'row',
         items: [
           {
             name: 'chart',
-            basis: '67%',
+            basis: '58%',
           },
           {
             name: 'board',
-            basis: '33%',
+            basis: '42%',
           },
         ],
       },
       board: [
         {
-          type: 'D',
+          type: 'B',
           readFrom: 'readData',
-          layout: 'label-top',
+          layout: 'val-top',
           items: [
             {
               label: '风量(m³/min)',
@@ -859,9 +859,15 @@ export const testConfigVent: Config[] = [
       table: [],
       chart: [
         {
-          type: 'pie_halo',
+          type: 'pie_rose',
           readFrom: '',
-          legend: { show: false, formatter: '{b}:{c}\n{d}%' },
+          legend: {
+            show: true,
+            bottom: 2,
+            left: 'center',
+            orient: 'horizontal',
+            labelShow: false,
+          },
           xAxis: [{ show: false }],
           yAxis: [{ show: false, name: '风量', position: 'left' }],
           series: [{ readFrom: 'piechart', xprop: 'label', yprop: 'valMock', label: '' }],
@@ -874,7 +880,7 @@ export const testConfigVent: Config[] = [
     showStyle: {
       size: 'width:460px;height:260px;',
       version: '原版',
-      position: 'bottom:15px;left:940px;',
+      position: 'top:560px;left:940px;bottom:auto;',
     },
   },
   {
@@ -903,12 +909,12 @@ export const testConfigVent: Config[] = [
         direction: 'column',
         items: [
           {
-            name: 'chart',
-            basis: '65%',
+            name: 'board',
+            basis: '34%',
           },
           {
-            name: 'board',
-            basis: '35%',
+            name: 'chart',
+            basis: '66%',
           },
         ],
       },
@@ -957,7 +963,7 @@ export const testConfigVent: Config[] = [
     showStyle: {
       size: 'width:460px;height:260px;',
       version: '原版',
-      position: 'bottom:15px;right:15px;',
+      position: 'top:560px;right:15px;bottom:auto;',
     },
   },
 ];
@@ -5646,7 +5652,7 @@ export const testConfigBDFire: Config[] = [
       gallery_list: [],
       table: [
         {
-          type: 'A',
+          type: 'E',
           readFrom: 'aqjkArray',
           columns: [
             {
@@ -6014,7 +6020,7 @@ export const testConfigBDDust: Config[] = [
     showStyle: {
       size: 'width:420px;height:270px;',
       version: '保德',
-      position: 'top:0px;right:10px;',
+      position: 'top:6px;right:10px;',
     },
   },
   {

+ 7 - 0
src/views/vent/home/configurable/green/components/CustomGallery-green.vue

@@ -251,6 +251,13 @@ withDefaults(
   display: block;
 }
 
+/* 智能喷雾降尘装置:与标题留出间距 */
+.gallery_E {
+  margin-top: 8px;
+  height: calc(100% - 8px);
+  box-sizing: border-box;
+}
+
 .gallery-item_center_E {
   background: var(--image-img-8) no-repeat;
   width: 200px;

+ 17 - 10
src/views/vent/home/configurable/green/components/CustomList-green.vue

@@ -480,34 +480,41 @@
     background-repeat: no-repeat;
     background-size: auto 100%;
     margin: 8px 10px;
+    box-sizing: border-box;
   }
 
   .list-item__content_I {
     position: relative;
     width: 100%;
     height: 100%;
+    // 内边距:避免 CO / 一氧化碳等文字贴边
+    padding: 6px 8px 4px 10px;
+    box-sizing: border-box;
   }
 
   .list-item__content_I > .list-item__label {
-    width: 60px;
-    height: 100%;
-    left: 0;
+    width: 52px;
+    height: calc(100% - 10px);
+    left: 10px;
+    top: 5px;
     position: absolute;
-    font-size: 16px;
-    line-height: 63px;
+    font-size: 15px;
+    line-height: 52px;
     color: #2f836e;
   }
 
   .list-item__content_I > .list-item__info {
-    height: 50%;
-    left: 70px;
+    height: calc(50% - 4px);
+    left: 72px;
+    top: 8px;
     position: absolute;
+    line-height: 1.2;
   }
 
   .list-item__content_I > .list-item__value {
-    top: 50%;
-    height: 50%;
-    left: 70px;
+    top: calc(50% + 2px);
+    height: calc(50% - 6px);
+    left: 72px;
     position: absolute;
     font-size: 18px;
   }

+ 11 - 1
src/views/vent/home/configurable/green/components/CustomTable-green.vue

@@ -83,7 +83,8 @@
         font-size: 14px;
       }
     }
-    .table__content_label_A {
+    .table__content_label_A,
+    .table__content_label_E {
       background-image: var(--image-content-label);
       background-size: 100% 100%;
       background-repeat: no-repeat;
@@ -151,6 +152,15 @@
         background-image: var(--image-content-text);
       }
     }
+    .table__content_list_E {
+      .table__content_list_row {
+        min-height: 46px;
+        background-size: 100% auto;
+        background-repeat: no-repeat;
+        background-position: center bottom;
+        background-image: var(--image-content-text);
+      }
+    }
     .table__content_list_C {
       .table__content_list_row {
         min-height: 50px;

+ 76 - 11
src/views/vent/home/configurable/green/components/MiniBoard-green.vue

@@ -167,27 +167,42 @@ defineEmits(['click']);
 }
 
 .mini-board_E {
-  width: 30%;
-  height: 180px;
-  padding: 20px 5px;
-  // background-image: var(--image-board_bg_1);
-  background-position: center bottom;
+  width: 31%;
+  height: 190px;
+  padding: 14px 8px 18px;
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-start;
+  align-items: center;
+  line-height: 1.2;
+  background-position: center top;
   background-repeat: no-repeat;
   background-size: 100% 100%;
 
   &:nth-child(1) {
     background-image: var(--image-hycd);
+
+    .mini-board__value_E {
+      color: #a1dec3;
+    }
   }
 
   &:nth-child(2) {
     background-image: var(--image-dyfl);
 
+    .mini-board__value_E {
+      color: #a1d3e0;
+    }
   }
 
   &:nth-child(3) {
     background-image: var(--image-jdjl);
-  }
 
+    .mini-board__value_E {
+      color: #a1dec3;
+    }
+  }
 }
 
 .mini-board_F {
@@ -244,12 +259,23 @@ defineEmits(['click']);
 }
 
 .mini-board_K {
+  flex: 1 1 0;
   width: 50%;
+  max-width: 50%;
+  min-width: 0;
   height: 100%;
+  box-sizing: border-box;
   display: flex;
   flex-direction: column;
   justify-content: center;
   align-items: center;
+  padding: 4px 6px;
+  overflow: hidden;
+
+  // label-top 模板先渲染 value,用 order 将文字置于平台上方
+  .mini-board__label_K {
+    order: -1;
+  }
 
   &:nth-child(1) {
     .mini-board__value_K {
@@ -318,14 +344,36 @@ defineEmits(['click']);
 }
 
 .mini-board__value_E {
-  font-size: 20px;
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  margin-top: 8px;
+  margin-bottom: 0;
+  padding: 0 2px;
+  box-sizing: border-box;
+  color: #3afde7;
+  font-size: 15px;
   font-weight: bold;
+  line-height: 1.35;
+  white-space: normal;
+  word-break: break-all;
+  text-align: center;
 }
 
 .mini-board__label_E {
-  line-height: 20px;
-  height: 90px;
-  padding-top: 30%;
+  width: 100%;
+  height: auto;
+  min-height: 86px;
+  padding: 48px 4px 0;
+  box-sizing: border-box;
+  color: #fff;
+  font-size: 12px;
+  line-height: 1.4;
+  white-space: normal;
+  word-break: break-all;
+  text-align: center;
   background-repeat: no-repeat;
   background-position: center top;
 }
@@ -373,7 +421,24 @@ defineEmits(['click']);
 }
 
 .mini-board__label_K {
-  z-index: 999;
+  position: relative;
+  z-index: 1;
+  width: 100%;
+  margin: 0 0 8px;
+  color: #fff;
+  font-size: 13px;
+  line-height: 20px;
+  height: 20px;
+  text-align: center;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  flex-shrink: 0;
+}
+
+.mini-board__value_K {
+  margin-bottom: 0;
+  flex-shrink: 0;
 }
 
 // .mini-board_H_low_risk {

+ 6 - 1
src/views/vent/home/configurable/green/components/ModuleOriginal-green.vue

@@ -11,7 +11,12 @@
   >
     <slot>
       <Header :deviceType="deviceType" :moduleData="moduleData" :data="data" @select="selectedData = $event" />
-      <Content :style="{ height: header.show ? 'calc(100% - 30px)' : '100%' }" :moduleData="moduleData" :data="selectedData" />
+      <Content
+        :style="{ height: header.show ? 'calc(100% - 30px)' : '100%' }"
+        :device-type="deviceType"
+        :moduleData="moduleData"
+        :data="selectedData"
+      />
     </slot>
   </component>
 </template>

+ 225 - 7
src/views/vent/home/configurable/green/components/content-green.vue

@@ -18,13 +18,24 @@
       Not Supportted Link Or Browser
     </video>
     <div class="flex w-full h-full" :style="{ flexDirection: layout.direction }">
-      <div v-for="config in layoutConfig" :key="config.name" :style="{ flexBasis: config.basis, overflow: config.overflow ? 'hidden' : 'hidden' }">
+      <div
+        v-for="config in layoutConfig"
+        :key="config.name"
+        :style="getLayoutItemStyle(config)"
+      >
         <!-- 告示板部分 -->
         <template v-if="config.name === 'board'">
-          <div class="content__module flex flex-justify-around flex-items-center flex-wrap">
+          <div
+            class="content__module content__board flex flex-justify-around flex-items-center"
+            :class="[
+              boardClassName,
+              config.type === 'E' ? 'content__board--dust-explode' : '',
+              config.type === 'K' ? 'content__board--fire-platforms' : '',
+            ]"
+          >
             <MiniBoard
-              v-for="item in config.items"
-              :key="item.prop"
+              v-for="(item, idx) in config.items"
+              :key="item.label || idx"
               :label="item.label"
               :value="item.value"
               :dw="item.dw"
@@ -36,12 +47,17 @@
 
         <!-- 图表部分,这部分通常需要填充,有告示板、Header等内容需要填充父级 -->
         <template v-if="config.name === 'chart'">
-          <CustomChart class="content__module" :chart-config="config.config" :chart-data="config.data" />
+          <CustomChart
+            class="content__module"
+            :class="{ 'content__chart--majorpath': deviceType === 'sys_majorpath' }"
+            :chart-config="config.config"
+            :chart-data="config.data"
+          />
         </template>
         <!-- 通常列表部分 -->
         <template v-if="config.name === 'list'">
           <template v-if="config.type === 'timeline'">
-            <TimelineList class="content__module" :list-config="config.items" />
+            <TimelineList class="content__module content__timeline-green" :list-config="config.items" />
           </template>
           <template v-else-if="config.type === 'timelineNew'">
             <TimelineListNew class="content__module" :list-config="config.items" />
@@ -73,7 +89,13 @@
           <BlastDelta class="content__module" :pos-monitor="config.data" :canvasSize="{ width: 250, height: 178 }" />
         </template>
         <template v-if="config.name === 'qh_curve'">
-          <QHCurve class="content__module" :mainfan="config.data" :fan1-prop="config.config.fan1Prop" :fan2-prop="config.config.fan2Prop" />
+          <QHCurve
+            class="content__module content__qh-curve"
+            :mainfan="config.data"
+            :fan1-prop="config.config.fan1Prop"
+            :fan2-prop="config.config.fan2Prop"
+            :compact="deviceType === 'fanmain'"
+          />
         </template>
         <template v-if="config.name === 'device_alarm'">
           <DeviceAlarm class="content__module" :devicedata="config.data" />
@@ -137,10 +159,34 @@
   const props = defineProps<{
     data: any;
     moduleData: Config['moduleData'];
+    deviceType?: string;
   }>();
 
   const { background, layout } = props.moduleData;
 
+  const boardClassName = computed(() => {
+    if (props.deviceType === 'fanmain') return 'content__board--fanmain';
+    if (props.deviceType === 'sys_majorpath') return 'content__board--majorpath';
+    if (props.deviceType === 'sys_surface_caimei') return 'content__board--surface';
+    return 'flex-wrap';
+  });
+
+  function getLayoutItemStyle(config: { name: string; basis: string; overflow?: boolean }) {
+    const style: Record<string, string> = {
+      flexBasis: config.basis,
+      overflow: 'hidden',
+    };
+    // 选项卡在上:防止被压缩后换行叠到图表上
+    if (
+      config.name === 'board' &&
+      (props.deviceType === 'fanmain' || props.deviceType === 'sys_surface_caimei')
+    ) {
+      style.flexShrink = '0';
+      style.minHeight = '56px';
+    }
+    return style;
+  }
+
   // 获取当原始配置带 items 项时的最终 items 配置
   function getItems(raw, items: CommonItem[]) {
     return items.map((i) => {
@@ -407,6 +453,178 @@
     }
   }
 
+  .content__board {
+    padding: 2px 0 4px;
+    box-sizing: border-box;
+    align-content: center;
+    column-gap: 4px;
+  }
+
+  /* 煤尘爆炸性:三列卡片铺满内容区,优化间距与可读性 */
+  .content__board--dust-explode {
+    height: 100%;
+    padding: 6px 4px 10px;
+    gap: 6px;
+    align-items: stretch;
+    justify-content: space-around;
+    overflow: hidden;
+
+    :deep(.mini-board_E) {
+      height: 100%;
+      max-height: none;
+      min-height: 0;
+    }
+  }
+
+  /* 火灾监测:内因/外因双平台并排,避免换行叠字 */
+  .content__board--fire-platforms {
+    flex-wrap: nowrap;
+    height: 100%;
+    padding: 6px 8px 4px;
+    gap: 12px;
+    align-items: center;
+    justify-content: space-around;
+    overflow: hidden;
+    box-sizing: border-box;
+
+    :deep(.mini-board_K) {
+      flex: 1 1 0;
+      width: auto;
+      max-width: none;
+    }
+  }
+
+  /* 主通风机:三张数据卡单行对齐,避免与图表图例重叠 */
+  .content__board--fanmain {
+    flex-wrap: nowrap;
+    justify-content: space-between;
+    align-items: center;
+    padding: 4px 2px 6px;
+    gap: 4px;
+    min-height: 54px;
+    height: 100%;
+    overflow: hidden;
+
+    :deep(.mini-board_D) {
+      flex: 1 1 0;
+      width: auto;
+      min-width: 0;
+      max-width: 120px;
+      height: 48px;
+      margin: 0;
+    }
+  }
+
+  /* 工作面智能管控:选项卡在上,与主通风机类似 */
+  .content__board--surface {
+    flex-wrap: nowrap;
+    justify-content: space-between;
+    align-items: center;
+    padding: 4px 4px 6px;
+    gap: 6px;
+    min-height: 54px;
+    height: 100%;
+    overflow: hidden;
+
+    :deep(.mini-board_B) {
+      flex: 1 1 0;
+      width: auto;
+      min-width: 0;
+      max-width: 140px;
+      height: 50px;
+      margin: 0;
+      padding: 2px 6px 4px;
+    }
+
+    :deep(.mini-board__value_B) {
+      font-size: 14px;
+      line-height: 20px;
+      height: 20px;
+      color: #3ecca7;
+    }
+
+    :deep(.mini-board__label_B) {
+      font-size: 12px;
+      line-height: 14px;
+      height: 14px;
+      color: #fff;
+    }
+  }
+
+  /* 关键通风路线:右侧纵向选项卡(标签在上,绿色数值框在下) */
+  .content__board--majorpath {
+    flex-direction: column;
+    flex-wrap: nowrap;
+    justify-content: space-evenly;
+    align-items: center;
+    height: 100%;
+    padding: 2px 10px 2px 4px;
+    gap: 4px;
+    overflow: hidden;
+
+    :deep(.mini-board_B) {
+      width: 100%;
+      max-width: 150px;
+      min-width: 118px;
+      height: auto;
+      margin: 0;
+      padding: 0;
+      background-image: none;
+      justify-content: center;
+      gap: 2px;
+    }
+
+    :deep(.mini-board__label_B) {
+      font-size: 12px;
+      line-height: 16px;
+      height: 16px;
+      color: #fff;
+      margin-bottom: 0;
+    }
+
+    :deep(.mini-board__value_B) {
+      width: 100%;
+      height: 34px;
+      line-height: 34px;
+      font-size: 16px;
+      color: #3ecca7;
+      font-family: 'douyuFont';
+      margin-bottom: 0;
+      background-image: url('@/assets/images/home-green/green-tag.png');
+      background-size: 100% 100%;
+      background-repeat: no-repeat;
+      background-position: center;
+    }
+  }
+
+  .content__chart--majorpath {
+    height: 100%;
+    overflow: hidden;
+  }
+
+  .content__qh-curve {
+    padding: 0 2px 2px;
+    box-sizing: border-box;
+    overflow: hidden;
+    min-height: 0;
+  }
+
+  /* 预警监测时间轴圆点/轴线:适配绿色主题,不影响其他页面 */
+  .content__timeline-green {
+    :deep(.timeline-item__dot) {
+      background-color: #3ecca7;
+      box-shadow: 0 0 6px rgba(62, 204, 167, 0.55);
+    }
+
+    :deep(.timeline-item__dot::before) {
+      border-color: #3df6ff;
+    }
+
+    :deep(.timeline-component) {
+      background-image: linear-gradient(to top, #3ecca700, #3ecca799, #3ecca700);
+    }
+  }
+
   :deep(.zxm-select:not(.zxm-select-customize-input) .zxm-select-selector) {
     /* background-color: transparent; */
     color: #fff;

+ 53 - 54
src/views/vent/home/configurable/green/components/dz-chart.vue

@@ -63,9 +63,9 @@
         },
         grid: {
           left: 10,
-          top: 10,
+          top: 22,
           right: 10,
-          bottom: 0,
+          bottom: 6,
         },
         yAxis: [
           {
@@ -86,7 +86,9 @@
         series: [
           {
             type: 'bar',
-            barWidth: 18,
+            // 类目间距加大,保证条形图与下一行文字有边距;条形高度适中
+            barWidth: 14,
+            barCategoryGap: '62%',
             silent: true,
             itemStyle: {
               normal: {
@@ -173,75 +175,72 @@
             symbolMargin: 3,
             symbol: 'rect',
             symbolClip: true,
-            symbolSize: [10, 24],
+            symbolSize: [8, 18],
             symbolPosition: 'start',
-            symbolOffset: [1, -4],
+            symbolOffset: [1, 0],
             symbolBoundingData: 10,
             data: [10, 10, 10, 10, 10],
             z: 2,
             animationEasing: 'elasticOut',
           },
           {
-            type: 'pictorialBar',
-            symbolBoundingData: 10,
+            // 透明 bar 与主条同高、同基准,保证右侧数量与左侧风险文字对齐
+            type: 'bar',
+            barWidth: 14,
+            barGap: '-100%',
             itemStyle: {
-              normal: {
-                color: 'none',
-              },
+              color: 'transparent',
             },
             label: {
-              normal: {
-                formatter: (params) => {
-                  let text;
-                  if (params.dataIndex == 0) {
-                    text = '{a| ' + params.data + '个}';
-                  } else if (params.dataIndex == 1) {
-                    text = '{b| ' + params.data + '个}';
-                  } else if (params.dataIndex == 2) {
-                    text = '{c| ' + params.data + '个}';
-                  } else if (params.dataIndex == 3) {
-                    text = '{d| ' + params.data + '个}';
-                  } else if (params.dataIndex == 4) {
-                    text = '{e| ' + params.data + '个}';
-                  } else {
-                    text = '{f| ' + params.data + '个}';
-                  }
-
-                  return text;
+              formatter: (params) => {
+                let text;
+                if (params.dataIndex == 0) {
+                  text = '{a| ' + echartY.value[params.dataIndex] + '个}';
+                } else if (params.dataIndex == 1) {
+                  text = '{b| ' + echartY.value[params.dataIndex] + '个}';
+                } else if (params.dataIndex == 2) {
+                  text = '{c| ' + echartY.value[params.dataIndex] + '个}';
+                } else if (params.dataIndex == 3) {
+                  text = '{d| ' + echartY.value[params.dataIndex] + '个}';
+                } else if (params.dataIndex == 4) {
+                  text = '{e| ' + echartY.value[params.dataIndex] + '个}';
+                } else {
+                  text = '{f| ' + echartY.value[params.dataIndex] + '个}';
+                }
+                return text;
+              },
+              rich: {
+                a: {
+                  color: '#ff0000',
                 },
-                rich: {
-                  a: {
-                    color: '#ff0000',
-                  },
-                  b: {
-                    color: '#ff7700',
-                  },
-                  c: {
-                    color: '#d8b66f',
-                  },
-                  d: {
-                    color: '#dbbf2e',
-                  },
-                  e: {
-                    color: '#61b4c5',
-                  },
-                  f: {
-                    color: '#fff',
-                  },
+                b: {
+                  color: '#ff7700',
+                },
+                c: {
+                  color: '#d8b66f',
+                },
+                d: {
+                  color: '#dbbf2e',
+                },
+                e: {
+                  color: '#61b4c5',
+                },
+                f: {
+                  color: '#fff',
                 },
-                position: [350, -12],
-                show: true,
               },
+              position: [350, -22],
+              show: true,
             },
-            data: echartY.value,
+            data: [10, 10, 10, 10, 10],
             z: 0,
           },
           {
             name: '外框',
             type: 'bar',
-            barGap: '-130%',
+            barGap: '-100%',
             data: [10, 10, 10, 10, 10],
-            barWidth: 29,
+            barWidth: 20,
             itemStyle: {
               normal: {
                 barBorderRadius: [0, 0, 0, 0],
@@ -256,8 +255,8 @@
             type: 'scatter',
             name: '条形',
             symbol: 'roundRect',
-            symbolSize: [6, 14],
-            symbolOffset: [3, -3],
+            symbolSize: [5, 11],
+            symbolOffset: [3, 0],
             symbolKeepAspect: true,
             itemStyle: {
               normal: {

+ 2 - 0
src/views/vent/home/configurable/green/components/green-nav.vue

@@ -117,6 +117,7 @@
       font-size: 20px;
       letter-spacing: 2px;
       color: #fff;
+      text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 0 6px rgba(0, 0, 0, 0.75);
     }
 
     .nav-menu {
@@ -146,6 +147,7 @@
       .nav-menu-active {
         background: var(--image-model_nav-menu-active) no-repeat;
         background-size: 100% 100%;
+        text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 0 6px rgba(0, 0, 0, 0.75);
       }
 
       .nav-menu-item {

+ 6 - 1
src/views/vent/home/configurable/green/components/moduleDual-green.vue

@@ -205,6 +205,11 @@
         color: #fff;
         font-family: 'douyuFont';
         // font-size: 12px;
+
+        .deactived {
+          cursor: pointer;
+          color: #a5a9ac;
+        }
       }
 
       .common-navR {
@@ -258,7 +263,7 @@
 
   .deactived {
     cursor: pointer;
-    color: #8087a1;
+    color: #a5a9ac;
   }
 
   :deep(.zxm-select-selector) {

+ 5 - 0
src/views/vent/home/configurable/green/dust-green.vue

@@ -171,6 +171,11 @@
       top: 96px;
       z-index: 0;
       pointer-events: auto;
+
+      :deep(.modal-bg) {
+        background: url('/@/assets/images/beltFire/bg-model-green.png') no-repeat center;
+        background-size: cover;
+      }
     }
   }
 

+ 7 - 3
src/views/vent/home/configurable/green/fire-green.vue

@@ -215,6 +215,11 @@
       top: 60px;
       z-index: 0;
       pointer-events: auto;
+
+      :deep(.modal-bg) {
+        background: url('/@/assets/images/beltFire/bg-model-green.png') no-repeat center;
+        background-size: cover;
+      }
     }
   }
 
@@ -232,15 +237,14 @@
     .tcontent-area {
       display: flex;
       position: absolute;
-      top: 50%;
+      top: 0;
       left: 0;
       box-sizing: border-box;
       align-items: center;
       justify-content: space-around;
       width: 100%;
-      height: 70px;
+      height: 100%;
       padding: 0 15px;
-      transform: translate(0, -40%);
 
       .tcontent-l {
         display: flex;

+ 6 - 0
src/views/vent/home/configurable/green/fusion-warn-green.vue

@@ -125,6 +125,12 @@
       height: 96px;
       position: absolute;
       z-index: 1;
+
+      // 标题与当前导航「灾害预警」增加黑色投影,提升可读性
+      :deep(.main-title),
+      :deep(.nav-menu-active) {
+        text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 0 6px rgba(0, 0, 0, 0.75);
+      }
     }
 
     .main-container {

+ 6 - 1
src/views/vent/home/configurable/green/vent-green.vue

@@ -148,8 +148,8 @@
     .main-container {
       position: absolute;
       top: 96px;
+      bottom: 20px;
       width: calc(100% - 30px);
-      height: calc(100% - 96px);
       margin: 0 15px;
       box-sizing: border-box;
     }
@@ -177,6 +177,11 @@
       top: 110px;
       z-index: 0;
       pointer-events: auto;
+
+      :deep(.modal-bg) {
+        background: url('/@/assets/images/beltFire/bg-model-green.png') no-repeat center;
+        background-size: cover;
+      }
     }
   }
 

+ 678 - 0
src/views/vent/home/selfIgnition/components/ManualView.vue

@@ -0,0 +1,678 @@
+<template>
+  <div class="ignition-view manual-view">
+    <!-- 左:人工录入 -->
+    <ModuleCommon
+      :show-style="panelShowStyle(23, 105, 335, 934)"
+      :module-data="EMPTY_MODULE_DATA"
+      module-name="人工录入"
+      device-type="ignition_manual_input"
+      :data="{}"
+      :visible="true"
+    >
+      <div class="manual-form">
+        <div class="group-list">
+          <div v-for="group in groups" :key="group.key" class="form-group">
+            <div class="group-collapse" @click="toggleGroup(group.key)">
+              <img class="group-icon" src="@/assets/images/vent/home/selfIgnition/form-icon.svg" alt="" />
+              <span>{{ group.title }}</span>
+              <i class="collapse-arrow" :class="{ collapsed: collapsed[group.key] }"></i>
+            </div>
+            <template v-if="!collapsed[group.key]">
+              <div class="group-title">
+                <span class="title-bars">
+                  <i v-for="n in 4" :key="n"></i>
+                </span>
+                <span>{{ group.title }}指标</span>
+                <span class="title-dots">
+                  <i v-for="n in 3" :key="n"></i>
+                  <b></b>
+                </span>
+              </div>
+              <div class="field-list">
+                <div v-for="field in group.fields" :key="field.key" class="field-row">
+                  <span class="field-label">{{ field.label }}:</span>
+                  <PanelSelect v-model="field.value" :options="field.options" />
+                </div>
+              </div>
+            </template>
+          </div>
+        </div>
+        <button class="action-button submit-button" @click="submit">提交</button>
+      </div>
+    </ModuleCommon>
+
+    <!-- 右:评价结果 -->
+    <ModuleCommon
+      :show-style="panelShowStyle(1523, 105, 337, 934)"
+      :module-data="EMPTY_MODULE_DATA"
+      module-name="评价结果"
+      device-type="ignition_evaluation_result"
+      :data="{}"
+      :visible="true"
+    >
+      <div class="result-panel">
+        <div class="result-top">
+          <div class="score-line">
+            <span class="score-label">风险分值:</span>
+            <img class="score-info" src="@/assets/images/vent/home/selfIgnition/info-icon.svg" alt="根据人工录入指标计算" />
+            <span class="score-value">{{ result.score.toFixed(1) }}</span>
+          </div>
+          <div class="gauge-line">
+            <div class="gauge">
+              <img class="gauge-segment gauge-segment-1" src="@/assets/images/vent/home/selfIgnition/risk-gauge.png" alt="" />
+              <img class="gauge-segment gauge-segment-2" src="@/assets/images/vent/home/selfIgnition/risk-gauge.png" alt="" />
+            </div>
+            <span class="risk-tag" :style="{ '--risk-color': result.color }">{{ result.levelText }}</span>
+          </div>
+        </div>
+
+        <div class="result-info">
+          <div class="info-row"
+            ><span>评价对象:</span><em>{{ result.target }}</em></div
+          >
+          <div class="info-row"
+            ><span>数据时间:</span><em>{{ result.time }}</em></div
+          >
+          <div class="info-row"
+            ><span>数据来源:</span><em>{{ result.source }}</em></div
+          >
+        </div>
+
+        <div class="level-table">
+          <div class="level-head"><span>评价等级</span><span>风险分值范围</span><span>对应颜色</span></div>
+          <div v-for="item in levels" :key="item.level" class="level-row" :class="{ highlight: item.level === result.highlightLevel }">
+            <span class="level-name">
+              <i :style="{ background: item.color, boxShadow: `0 0 8px ${item.color}` }"></i>
+              {{ item.name }}
+            </span>
+            <span>{{ item.range }}</span>
+            <span>{{ item.colorName }}</span>
+          </div>
+        </div>
+
+        <div class="suggestion-box">
+          <span class="suggestion-icon"></span>
+          <p>{{ result.suggestion }}</p>
+        </div>
+
+        <button class="action-button report-button" @click="showReport = true">查看评价报告</button>
+      </div>
+    </ModuleCommon>
+
+    <Transition name="ignition-fade">
+      <div v-if="showReport" class="report-mask" @click.self="showReport = false">
+        <div class="report-dialog">
+          <div class="report-title">采空区自燃风险评价报告</div>
+          <div class="report-body">
+            <div class="report-row"
+              ><span>评价对象</span><em>{{ result.target }}</em></div
+            >
+            <div class="report-row"
+              ><span>风险分值</span><em>{{ result.score.toFixed(1) }}</em></div
+            >
+            <div class="report-row"
+              ><span>评价等级</span><em :style="{ color: result.color }">{{ result.levelText }}</em></div
+            >
+            <div class="report-row"
+              ><span>数据时间</span><em>{{ result.time }}</em></div
+            >
+            <div class="report-row"
+              ><span>数据来源</span><em>{{ result.source }}</em></div
+            >
+            <div class="report-suggestion">
+              <span class="suggestion-icon"></span>
+              <p>{{ result.suggestion }}</p>
+            </div>
+          </div>
+          <button class="action-button report-close" @click="showReport = false">关闭</button>
+        </div>
+      </div>
+    </Transition>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { reactive, ref } from 'vue';
+  import ModuleCommon from '../../configurable/components/ModuleCommon.vue';
+  import PanelSelect from './PanelSelect.vue';
+  import { EMPTY_MODULE_DATA, panelShowStyle } from '../utils';
+  import { INITIAL_EVALUATION, RISK_LEVELS, SUGGESTIONS, calcManualScore, createManualGroups, getRiskLevel } from '../data';
+  import type { EvaluationResult, ManualGroup } from '../data';
+  import { useMessage } from '/@/hooks/web/useMessage';
+
+  const { createMessage } = useMessage();
+
+  const groups = ref<ManualGroup[]>(createManualGroups());
+  const collapsed = reactive<Record<string, boolean>>({});
+  const levels = RISK_LEVELS;
+  const result = ref<EvaluationResult>({ ...INITIAL_EVALUATION });
+  const showReport = ref(false);
+
+  function toggleGroup(key: string) {
+    collapsed[key] = !collapsed[key];
+  }
+
+  function formatDateTime(date: Date) {
+    const pad = (value: number) => `${value}`.padStart(2, '0');
+    return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
+  }
+
+  function submit() {
+    const score = calcManualScore(groups.value);
+    const risk = getRiskLevel(score);
+    result.value = {
+      score,
+      level: risk.level,
+      levelText: risk.name,
+      color: risk.color,
+      highlightLevel: risk.level,
+      target: result.value.target,
+      time: formatDateTime(new Date()),
+      source: '人工录入',
+      suggestion: SUGGESTIONS[risk.level],
+    };
+    createMessage.success('评价完成,风险分值已更新');
+  }
+</script>
+
+<style lang="less" scoped>
+  .manual-view {
+    position: absolute;
+    inset: 0;
+  }
+
+  .manual-form {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    padding: 8px 10px 12px;
+    box-sizing: border-box;
+  }
+
+  .group-list {
+    flex: 1;
+    min-height: 0;
+    padding-right: 2px;
+  }
+
+  .form-group {
+    margin-bottom: 12px;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  .group-collapse {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    height: 34px;
+    padding: 0 8px;
+    border-left: 2px solid #3df6ff;
+    background: linear-gradient(90deg, rgba(15, 77, 120, 0.9), rgba(9, 43, 70, 0.15));
+    color: #fff;
+    font-size: 15px;
+    letter-spacing: 0.5px;
+    cursor: pointer;
+    user-select: none;
+  }
+
+  .group-icon {
+    width: 13px;
+    height: 15px;
+  }
+
+  .collapse-arrow {
+    margin-left: auto;
+    width: 0;
+    height: 0;
+    border-left: 4px solid transparent;
+    border-right: 4px solid transparent;
+    border-top: 5px solid #cfefff;
+    transition: transform 0.2s;
+
+    &.collapsed {
+      transform: rotate(-90deg);
+    }
+  }
+
+  .group-title {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    height: 24px;
+    padding: 0 4px;
+    color: #a9dcf4;
+    font-size: 12px;
+    letter-spacing: 0.5px;
+  }
+
+  .title-bars {
+    display: flex;
+    align-items: flex-end;
+    gap: 2px;
+    height: 12px;
+
+    i {
+      width: 3px;
+      background: linear-gradient(180deg, #b3fff5, #17b7ff);
+
+      &:nth-child(1) {
+        height: 5px;
+      }
+
+      &:nth-child(2) {
+        height: 12px;
+      }
+
+      &:nth-child(3) {
+        height: 8px;
+      }
+
+      &:nth-child(4) {
+        height: 4px;
+      }
+    }
+  }
+
+  .title-dots {
+    display: flex;
+    align-items: center;
+    gap: 3px;
+    margin-left: auto;
+
+    i {
+      width: 2px;
+      height: 2px;
+      background: rgba(61, 246, 255, 0.65);
+    }
+
+    b {
+      width: 3px;
+      height: 3px;
+      background: #3df6ff;
+      box-shadow: 0 0 4px rgba(61, 246, 255, 0.9);
+    }
+  }
+
+  .field-list {
+    display: flex;
+    flex-direction: column;
+    gap: 7px;
+  }
+
+  .field-row {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    min-width: 0;
+
+    :deep(.ignition-select) {
+      flex: 1;
+      min-width: 0;
+      height: 38px;
+      font-size: 14px;
+    }
+  }
+
+  .field-label {
+    width: 88px;
+    flex: none;
+    text-align: right;
+    color: #fff;
+    font-size: 14px;
+    white-space: nowrap;
+  }
+
+  .result-panel {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+    height: 100%;
+    padding: 8px 12px 12px;
+    box-sizing: border-box;
+  }
+
+  .result-top {
+    flex: none;
+    height: 165px;
+    padding-top: 10px;
+    box-sizing: border-box;
+    background: url('@/assets/images/vent/home/selfIgnition/result-top-bg.png') no-repeat center / 100% 100%;
+  }
+
+  .score-line {
+    display: flex;
+    align-items: baseline;
+    padding: 0 20px;
+  }
+
+  .score-label {
+    color: #fff;
+    font-size: 20px;
+    letter-spacing: 1px;
+  }
+
+  .score-info {
+    width: 12px;
+    height: 12px;
+    margin-left: 5px;
+    align-self: center;
+    opacity: 0.85;
+  }
+
+  .score-value {
+    margin-left: 16px;
+    color: #fff;
+    font-size: 42px;
+    font-weight: 700;
+    line-height: 1;
+    letter-spacing: 1px;
+    text-shadow: 0 0 16px rgba(255, 255, 255, 0.35);
+  }
+
+  .gauge-line {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-top: 16px;
+    padding: 0 18px;
+  }
+
+  .gauge {
+    display: flex;
+    align-items: center;
+    width: 150px;
+    height: 26px;
+  }
+
+  .gauge-segment {
+    width: 80px;
+    height: 22px;
+    object-fit: fill;
+
+    & + & {
+      margin-left: -8px;
+    }
+  }
+
+  .gauge-segment-2 {
+    opacity: 0.72;
+  }
+
+  .risk-tag {
+    display: flex;
+    align-items: center;
+    width: 159px;
+    height: 34px;
+    padding-left: 22px;
+    box-sizing: border-box;
+    color: #fff;
+    font-size: 15px;
+    font-weight: 600;
+    letter-spacing: 0.5px;
+    white-space: nowrap;
+    -webkit-mask: url('@/assets/images/vent/home/selfIgnition/risk-tag.svg') no-repeat center / 100% 100%;
+    mask: url('@/assets/images/vent/home/selfIgnition/risk-tag.svg') no-repeat center / 100% 100%;
+    background: var(--risk-color, #ff9d1c);
+  }
+
+  .result-info {
+    flex: none;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    gap: 2px;
+    padding: 10px 14px;
+    border-left: 1px solid rgba(61, 246, 255, 0.16);
+    border-right: 1px solid rgba(61, 246, 255, 0.16);
+    background: rgba(7, 42, 70, 0.62);
+  }
+
+  .info-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    height: 38px;
+    color: #fff;
+    font-size: 15px;
+    letter-spacing: 0.5px;
+
+    em {
+      color: #3df6ff;
+      font-style: normal;
+      font-size: 16px;
+      text-shadow: 0 0 8px rgba(61, 246, 255, 0.35);
+    }
+  }
+
+  .level-table {
+    flex: none;
+    padding-top: 2px;
+  }
+
+  .level-head,
+  .level-row {
+    display: flex;
+    align-items: center;
+    text-align: center;
+  }
+
+  .level-head {
+    height: 34px;
+    border: 1px solid rgba(61, 246, 255, 0.4);
+    border-radius: 4px;
+    color: #3df6ff;
+    font-size: 13px;
+    background: url('@/assets/images/vent/home/selfIgnition/result-table-head.png') no-repeat center / 100% 100%;
+
+    span:nth-child(1) {
+      width: 42%;
+    }
+
+    span:nth-child(2) {
+      width: 32%;
+    }
+
+    span:nth-child(3) {
+      width: 26%;
+    }
+  }
+
+  .level-row {
+    position: relative;
+    height: 38px;
+    color: #e8f8ff;
+    font-size: 14px;
+    background: url('@/assets/images/vent/home/selfIgnition/result-row-glow.png') no-repeat center bottom / 100% 10px;
+
+    span:nth-child(1) {
+      width: 42%;
+    }
+
+    span:nth-child(2) {
+      width: 32%;
+    }
+
+    span:nth-child(3) {
+      width: 26%;
+    }
+
+    &.highlight {
+      outline: 1px solid #ff9d1c;
+      outline-offset: -1px;
+      background-color: rgba(255, 157, 28, 0.08);
+      box-shadow: inset 0 0 18px rgba(255, 157, 28, 0.16);
+    }
+  }
+
+  .level-name {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 6px;
+
+    i {
+      width: 7px;
+      height: 7px;
+      border-radius: 50%;
+    }
+  }
+
+  .suggestion-box {
+    position: relative;
+    flex: 1;
+    min-height: 0;
+    padding: 14px 16px 14px 34px;
+    box-sizing: border-box;
+    overflow-y: auto;
+    border: 1px solid rgba(61, 246, 255, 0.4);
+    background: url('@/assets/images/vent/home/selfIgnition/result-block-bg.png') no-repeat center / 100% 100%;
+
+    &::-webkit-scrollbar {
+      width: 4px;
+    }
+
+    &::-webkit-scrollbar-thumb {
+      background: rgba(61, 246, 255, 0.35);
+      border-radius: 2px;
+    }
+
+    p {
+      margin: 0;
+      color: #fff;
+      font-size: 15px;
+      line-height: 30px;
+      letter-spacing: 0.5px;
+    }
+  }
+
+  .suggestion-icon {
+    position: absolute;
+    left: 16px;
+    top: 20px;
+    width: 9px;
+    height: 9px;
+    background: #f5222d;
+    box-shadow: 0 0 9px rgba(245, 34, 45, 0.85);
+    transform: rotate(45deg);
+  }
+
+  .action-button {
+    position: relative;
+    flex: none;
+    width: 100%;
+    height: 42px;
+    border: 1px solid rgba(61, 246, 255, 0.55);
+    border-radius: 3px;
+    background:
+      url('@/assets/images/vent/home/selfIgnition/submit-bg.png') no-repeat center / 100% 100%,
+      linear-gradient(180deg, #1496e6 0%, #0a6cb5 100%);
+    color: #fff;
+    font-size: 16px;
+    letter-spacing: 3px;
+    cursor: pointer;
+    transition:
+      filter 0.2s,
+      box-shadow 0.2s;
+
+    &:hover {
+      filter: brightness(1.12);
+      box-shadow: 0 0 16px rgba(61, 246, 255, 0.35);
+    }
+
+    &::after {
+      content: '';
+      position: absolute;
+      left: 0;
+      right: 0;
+      bottom: -8px;
+      height: 16px;
+      background: url('@/assets/images/vent/home/selfIgnition/submit-glow.png') no-repeat center / 100% 100%;
+      pointer-events: none;
+    }
+  }
+
+  .submit-button {
+    margin-top: 8px;
+  }
+
+  .report-dialog {
+    width: 540px;
+    padding: 22px 26px 24px;
+    box-sizing: border-box;
+    border: 1px solid rgba(61, 246, 255, 0.75);
+    background: linear-gradient(180deg, #0b3a5e 0%, #071d33 100%);
+    box-shadow: 0 0 40px rgba(61, 246, 255, 0.35);
+  }
+
+  .report-title {
+    padding-bottom: 14px;
+    border-bottom: 1px solid rgba(61, 246, 255, 0.3);
+    color: #3df6ff;
+    font-family: 'douyuFont', sans-serif;
+    font-size: 20px;
+    letter-spacing: 2px;
+    text-align: center;
+  }
+
+  .report-body {
+    padding: 16px 0;
+  }
+
+  .report-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    height: 36px;
+    color: #cfefff;
+    font-size: 14px;
+
+    em {
+      color: #fff;
+      font-style: normal;
+      font-size: 15px;
+    }
+  }
+
+  .report-suggestion {
+    position: relative;
+    margin-top: 12px;
+    padding: 14px 16px 14px 34px;
+    border: 1px solid rgba(61, 246, 255, 0.3);
+    background: rgba(7, 42, 70, 0.7);
+
+    p {
+      margin: 0;
+      color: #fff;
+      font-size: 14px;
+      line-height: 26px;
+    }
+  }
+
+  .report-close {
+    margin-top: 4px;
+  }
+
+  .report-mask {
+    position: absolute;
+    inset: 0;
+    z-index: 100;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: rgba(2, 10, 20, 0.72);
+    backdrop-filter: blur(3px);
+  }
+
+  .ignition-fade-enter-active,
+  .ignition-fade-leave-active {
+    transition: opacity 0.2s ease;
+  }
+
+  .ignition-fade-enter-from,
+  .ignition-fade-leave-to {
+    opacity: 0;
+  }
+</style>

+ 586 - 0
src/views/vent/home/selfIgnition/components/MonitorView.vue

@@ -0,0 +1,586 @@
+<template>
+  <div class="ignition-view monitor-view">
+    <!-- 左上:设备运行状态 -->
+    <ModuleCommon
+      :show-style="panelShowStyle(23, 105, 328, 430)"
+      :module-data="EMPTY_MODULE_DATA"
+      module-name="设备运行状态"
+      device-type="ignition_device_status"
+      :data="{}"
+      :visible="true"
+    >
+      <div class="device-panel">
+        <div v-for="device in devices" :key="device.name" class="device-item">
+          <span class="device-badge"><i></i></span>
+          <div class="device-main">
+            <div class="device-line">
+              <span class="device-name">{{ device.name }}</span>
+              <span class="device-rate">{{ runningRate(device) }}%</span>
+            </div>
+            <div class="device-bar"><i :style="{ width: `${runningRate(device)}%` }"></i></div>
+            <div class="device-meta">
+              安装 <em>{{ device.installed }}</em> 套 · 运行 <em>{{ device.running }}</em> 套
+            </div>
+          </div>
+        </div>
+      </div>
+    </ModuleCommon>
+
+    <!-- 左下:实时预警信息 -->
+    <ModuleCommon
+      :show-style="panelShowStyle(23, 555, 328, 420)"
+      :module-data="EMPTY_MODULE_DATA"
+      module-name="实时预警信息"
+      device-type="ignition_realtime_alert"
+      :data="{}"
+      :visible="true"
+    >
+      <div class="alert-panel">
+        <div v-for="alert in alerts" :key="alert.id" class="alert-item" :style="{ '--alert-color': alertColor(alert.level) }">
+          <div class="alert-top">
+            <span class="alert-time">{{ alert.time }}</span>
+            <span class="alert-level">{{ alertLevelName(alert.level) }}</span>
+            <span class="alert-location">{{ alert.location }}</span>
+          </div>
+          <div class="alert-message">{{ alert.message }}</div>
+        </div>
+      </div>
+    </ModuleCommon>
+
+    <!-- 右上:束管监测实时分析 -->
+    <ModuleCommon
+      :show-style="panelShowStyle(380, 145, 1477, 390)"
+      :module-data="EMPTY_MODULE_DATA"
+      module-name="束管监测实时分析"
+      device-type="ignition_bundle_monitor"
+      :data="{}"
+      :visible="true"
+    >
+      <div class="bundle-panel">
+        <div class="bundle-head">
+          <span class="col-point">测点名称</span>
+          <span class="col-value">O₂(%)</span>
+          <span class="col-value">CO(ppm)</span>
+          <span class="col-value">CH₄(%)</span>
+          <span class="col-value">C₂H₄(ppm)</span>
+          <span class="col-value">温度(℃)</span>
+          <span class="col-status">状态</span>
+        </div>
+        <div class="bundle-body">
+          <div v-for="row in bundleRows" :key="row.point" class="bundle-row">
+            <span class="col-point">{{ row.point }}</span>
+            <span class="col-value" :class="{ warning: Number(row.o2) < 18 }">{{ row.o2 }}</span>
+            <span class="col-value" :class="{ warning: row.co >= 24 }">{{ row.co }}</span>
+            <span class="col-value">{{ row.ch4 }}</span>
+            <span class="col-value">{{ row.c2h4 }}</span>
+            <span class="col-value">{{ row.temp.toFixed(1) }}</span>
+            <span class="col-status">
+              <i class="status-tag" :class="statusClass(row.status)">{{ row.status }}</i>
+            </span>
+          </div>
+        </div>
+      </div>
+    </ModuleCommon>
+
+    <!-- 右下:光纤测温趋势分析 -->
+    <ModuleCommon
+      :show-style="panelShowStyle(380, 555, 1477, 420)"
+      :module-data="EMPTY_MODULE_DATA"
+      module-name="光纤测温趋势分析"
+      device-type="ignition_fiber_trend"
+      :data="{}"
+      :visible="true"
+    >
+      <div class="fiber-panel">
+        <div class="chart-area">
+          <div class="chart-legend">
+            <span class="legend-item"><i class="legend-max"></i>最高温度</span>
+            <span class="legend-item"><i class="legend-avg"></i>平均温度</span>
+          </div>
+          <svg class="chart-svg" viewBox="0 0 1100 300" preserveAspectRatio="none">
+            <defs>
+              <linearGradient id="ignitionFiberArea" x1="0" y1="0" x2="0" y2="1">
+                <stop offset="0" stop-color="#3df6ff" stop-opacity="0.28" />
+                <stop offset="1" stop-color="#3df6ff" stop-opacity="0" />
+              </linearGradient>
+            </defs>
+            <g>
+              <line
+                v-for="tick in yTicks"
+                :key="`grid-${tick}`"
+                :x1="chartPadding.left"
+                :x2="chartWidth - chartPadding.right"
+                :y1="yPosition(tick)"
+                :y2="yPosition(tick)"
+                stroke="rgba(61, 246, 255, 0.14)"
+                stroke-width="1"
+                vector-effect="non-scaling-stroke"
+              />
+              <text
+                v-for="tick in yTicks"
+                :key="`label-${tick}`"
+                :x="chartPadding.left - 10"
+                :y="yPosition(tick) + 4"
+                fill="#8fc7e8"
+                font-size="11"
+                text-anchor="end"
+              >
+                {{ tick }}℃
+              </text>
+            </g>
+            <path :d="maxAreaPath" fill="url(#ignitionFiberArea)" />
+            <polyline :points="maxPoints" fill="none" stroke="#3df6ff" stroke-width="2" vector-effect="non-scaling-stroke" stroke-linejoin="round" />
+            <polyline
+              :points="avgPoints"
+              fill="none"
+              stroke="#ffd15c"
+              stroke-width="1.5"
+              stroke-dasharray="5 3"
+              vector-effect="non-scaling-stroke"
+              stroke-linejoin="round"
+            />
+            <g>
+              <text v-for="item in xLabels" :key="item.text" :x="item.x" :y="chartHeight - 8" fill="#8fc7e8" font-size="11" text-anchor="middle">
+                {{ item.text }}
+              </text>
+            </g>
+          </svg>
+        </div>
+        <div class="fiber-summary">
+          <div class="summary-item">
+            <span>最高温度</span>
+            <em class="max">{{ fiber.maxValue.toFixed(1) }}℃</em>
+          </div>
+          <div class="summary-item">
+            <span>平均温度</span>
+            <em class="avg">{{ fiber.avgValue.toFixed(1) }}℃</em>
+          </div>
+          <div class="summary-item">
+            <span>测温点数</span>
+            <em>{{ fiber.pointCount }} 个</em>
+          </div>
+        </div>
+      </div>
+    </ModuleCommon>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { computed } from 'vue';
+  import ModuleCommon from '../../configurable/components/ModuleCommon.vue';
+  import { EMPTY_MODULE_DATA, panelShowStyle } from '../utils';
+  import { RISK_LEVELS } from '../data';
+  import type { AlertItem, BundleRow, DeviceInstallItem, FiberTrend } from '../data';
+
+  const props = defineProps<{
+    devices: DeviceInstallItem[];
+    alerts: AlertItem[];
+    bundleRows: BundleRow[];
+    fiber: FiberTrend;
+  }>();
+
+  const chartWidth = 1100;
+  const chartHeight = 300;
+  const chartPadding = { left: 52, right: 16, top: 16, bottom: 30 };
+  const yMin = 10;
+  const yMax = 40;
+  const yTicks = [10, 20, 30, 40];
+
+  function runningRate(device: DeviceInstallItem) {
+    if (!device.installed) return '0';
+    return ((device.running / device.installed) * 100).toFixed(1);
+  }
+
+  function alertColor(level: number) {
+    return RISK_LEVELS[Math.min(Math.max(level, 1), 5) - 1].color;
+  }
+
+  function alertLevelName(level: number) {
+    return RISK_LEVELS[Math.min(Math.max(level, 1), 5) - 1].name.replace(/^[IVX]+\s*/, '');
+  }
+
+  function statusClass(status: BundleRow['status']) {
+    if (status === '预警') return 'warning';
+    if (status === '关注') return 'attention';
+    return 'normal';
+  }
+
+  function xPosition(index: number) {
+    const plotWidth = chartWidth - chartPadding.left - chartPadding.right;
+    const step = plotWidth / Math.max(1, props.fiber.times.length - 1);
+    return chartPadding.left + index * step;
+  }
+
+  function yPosition(value: number) {
+    const plotHeight = chartHeight - chartPadding.top - chartPadding.bottom;
+    const ratio = (value - yMin) / (yMax - yMin);
+    return chartPadding.top + (1 - Math.min(Math.max(ratio, 0), 1)) * plotHeight;
+  }
+
+  function toPoints(values: number[]) {
+    return values.map((value, index) => `${xPosition(index)},${yPosition(value)}`).join(' ');
+  }
+
+  const maxPoints = computed(() => toPoints(props.fiber.maxTemp));
+  const avgPoints = computed(() => toPoints(props.fiber.avgTemp));
+
+  const maxAreaPath = computed(() => {
+    const plotBottom = chartHeight - chartPadding.bottom;
+    const firstX = xPosition(0);
+    const lastX = xPosition(props.fiber.maxTemp.length - 1);
+    return `M ${firstX},${plotBottom} L ${maxPoints.value.replace(/ /g, ' L ')} L ${lastX},${plotBottom} Z`;
+  });
+
+  const xLabels = computed(() => {
+    const indexes = [0, 4, 8, 12, 16, 20, props.fiber.times.length - 1];
+    return indexes.map((index) => ({
+      x: xPosition(index),
+      text: index === props.fiber.times.length - 1 ? '24:00' : props.fiber.times[index],
+    }));
+  });
+</script>
+
+<style lang="less" scoped>
+  .monitor-view {
+    position: absolute;
+    inset: 0;
+  }
+
+  .device-panel {
+    display: flex;
+    flex-direction: column;
+    gap: 9px;
+    height: 100%;
+    padding: 12px 12px 14px;
+    box-sizing: border-box;
+  }
+
+  .device-item {
+    display: flex;
+    align-items: center;
+    flex: 1;
+    min-height: 0;
+    gap: 10px;
+    padding: 6px 12px 6px 10px;
+    box-sizing: border-box;
+    border: 1px solid rgba(61, 246, 255, 0.16);
+    background: linear-gradient(90deg, rgba(11, 57, 90, 0.72), rgba(8, 37, 61, 0.3));
+  }
+
+  .device-badge {
+    position: relative;
+    width: 30px;
+    height: 30px;
+    flex: none;
+    border: 1px solid rgba(61, 246, 255, 0.55);
+    border-radius: 50%;
+    background: radial-gradient(circle, rgba(61, 246, 255, 0.28), rgba(5, 32, 55, 0.4));
+
+    i {
+      position: absolute;
+      left: 50%;
+      top: 50%;
+      width: 10px;
+      height: 10px;
+      margin: -5px 0 0 -5px;
+      border-radius: 2px;
+      background: #3df6ff;
+      box-shadow: 0 0 9px rgba(61, 246, 255, 0.9);
+    }
+  }
+
+  .device-main {
+    flex: 1;
+    min-width: 0;
+  }
+
+  .device-line {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+  }
+
+  .device-name {
+    color: #fff;
+    font-size: 14px;
+  }
+
+  .device-rate {
+    color: #3df6ff;
+    font-family: electronicFont, sans-serif;
+    font-size: 18px;
+  }
+
+  .device-bar {
+    position: relative;
+    height: 4px;
+    margin: 7px 0 6px;
+    overflow: hidden;
+    background: rgba(61, 246, 255, 0.12);
+
+    i {
+      display: block;
+      height: 100%;
+      background: linear-gradient(90deg, #0e7fc1, #3df6ff);
+      box-shadow: 0 0 8px rgba(61, 246, 255, 0.7);
+      transition: width 0.4s ease;
+    }
+  }
+
+  .device-meta {
+    color: #9fcde3;
+    font-size: 12px;
+
+    em {
+      color: #3df6ff;
+      font-style: normal;
+      font-size: 13px;
+    }
+  }
+
+  .alert-panel {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    height: 100%;
+    padding: 12px;
+    box-sizing: border-box;
+    overflow-y: auto;
+
+    &::-webkit-scrollbar {
+      width: 4px;
+    }
+
+    &::-webkit-scrollbar-thumb {
+      background: rgba(61, 246, 255, 0.35);
+      border-radius: 2px;
+    }
+  }
+
+  .alert-item {
+    flex: none;
+    padding: 8px 10px 8px 12px;
+    border-left: 2px solid var(--alert-color);
+    background: linear-gradient(90deg, rgba(11, 57, 90, 0.72), rgba(8, 37, 61, 0.25));
+  }
+
+  .alert-top {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    height: 20px;
+  }
+
+  .alert-time {
+    color: #8fc7e8;
+    font-size: 12px;
+    font-family: electronicFont, sans-serif;
+    letter-spacing: 1px;
+  }
+
+  .alert-level {
+    padding: 0 5px;
+    border: 1px solid var(--alert-color);
+    color: var(--alert-color);
+    font-size: 11px;
+    line-height: 16px;
+  }
+
+  .alert-location {
+    margin-left: auto;
+    color: #fff;
+    font-size: 12px;
+  }
+
+  .alert-message {
+    margin-top: 5px;
+    color: #fff;
+    font-size: 13px;
+    line-height: 20px;
+  }
+
+  .bundle-panel {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    padding: 12px 16px 14px;
+    box-sizing: border-box;
+  }
+
+  .bundle-head,
+  .bundle-row {
+    display: flex;
+    align-items: center;
+    text-align: center;
+  }
+
+  .bundle-head {
+    flex: none;
+    height: 38px;
+    border: 1px solid rgba(61, 246, 255, 0.4);
+    color: #3df6ff;
+    font-size: 14px;
+    letter-spacing: 1px;
+    background: linear-gradient(180deg, rgba(14, 70, 108, 0.85), rgba(10, 49, 79, 0.6));
+  }
+
+  .bundle-body {
+    display: flex;
+    flex: 1;
+    min-height: 0;
+    flex-direction: column;
+    border: 1px solid rgba(61, 246, 255, 0.18);
+    border-top: none;
+  }
+
+  .bundle-row {
+    flex: 1;
+    min-height: 0;
+    color: #fff;
+    font-size: 14px;
+    background: rgba(11, 51, 85, 0.55);
+
+    &:nth-child(even) {
+      background: rgba(13, 65, 103, 0.62);
+    }
+
+    & + & {
+      border-top: 1px solid rgba(61, 246, 255, 0.1);
+    }
+  }
+
+  .col-point {
+    width: 24%;
+    text-align: left;
+    padding-left: 22px;
+    box-sizing: border-box;
+  }
+
+  .col-value {
+    width: 12%;
+  }
+
+  .col-status {
+    width: 16%;
+  }
+
+  .warning {
+    color: #ffd15c;
+    text-shadow: 0 0 8px rgba(255, 209, 92, 0.45);
+  }
+
+  .status-tag {
+    display: inline-block;
+    min-width: 52px;
+    padding: 2px 10px;
+    box-sizing: border-box;
+    border: 1px solid currentColor;
+    font-style: normal;
+    font-size: 12px;
+    line-height: 18px;
+
+    &.normal {
+      color: #3df6ff;
+      background: rgba(61, 246, 255, 0.08);
+    }
+
+    &.attention {
+      color: #ffd15c;
+      background: rgba(255, 209, 92, 0.08);
+    }
+
+    &.warning {
+      color: #ff9d1c;
+      background: rgba(255, 157, 28, 0.1);
+    }
+  }
+
+  .fiber-panel {
+    display: flex;
+    gap: 16px;
+    height: 100%;
+    padding: 12px 16px 14px;
+    box-sizing: border-box;
+  }
+
+  .chart-area {
+    display: flex;
+    flex: 1;
+    min-width: 0;
+    flex-direction: column;
+  }
+
+  .chart-legend {
+    display: flex;
+    align-items: center;
+    gap: 20px;
+    height: 26px;
+    flex: none;
+    color: #a9dcf4;
+    font-size: 12px;
+  }
+
+  .legend-item {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+  }
+
+  .legend-max,
+  .legend-avg {
+    display: inline-block;
+    width: 16px;
+    height: 2px;
+  }
+
+  .legend-max {
+    background: #3df6ff;
+    box-shadow: 0 0 6px rgba(61, 246, 255, 0.8);
+  }
+
+  .legend-avg {
+    background: #ffd15c;
+  }
+
+  .chart-svg {
+    flex: 1;
+    min-height: 0;
+    width: 100%;
+  }
+
+  .fiber-summary {
+    display: flex;
+    flex: none;
+    width: 230px;
+    flex-direction: column;
+    justify-content: center;
+    gap: 18px;
+    padding-left: 22px;
+    border-left: 1px solid rgba(61, 246, 255, 0.18);
+  }
+
+  .summary-item {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    color: #a9dcf4;
+    font-size: 13px;
+
+    em {
+      color: #fff;
+      font-style: normal;
+      font-family: electronicFont, sans-serif;
+      font-size: 26px;
+      line-height: 1;
+      text-shadow: 0 0 10px rgba(255, 255, 255, 0.25);
+
+      &.max {
+        color: #3df6ff;
+      }
+
+      &.avg {
+        color: #ffd15c;
+      }
+    }
+  }
+</style>

+ 435 - 0
src/views/vent/home/selfIgnition/components/OverviewView.vue

@@ -0,0 +1,435 @@
+<template>
+  <div class="ignition-view overview-view">
+    <!-- 左:矿井采空区概览 -->
+    <ModuleCommon
+      class="overview-module"
+      :show-style="panelShowStyle(20, 105, 334, 436)"
+      :module-data="EMPTY_MODULE_DATA"
+      module-name="矿井采空区概览"
+      device-type="ignition_goaf_overview"
+      :data="{}"
+      :visible="true"
+    >
+      <div class="overview-panel">
+        <div v-for="item in overview" :key="item.label" class="goaf-item">
+          <span class="goaf-icon">
+            <img src="@/assets/images/vent/home/selfIgnition/goaf-icon.svg" alt="" />
+          </span>
+          <span class="goaf-label">{{ item.label }}:</span>
+          <span class="goaf-value">{{ item.value }}</span>
+        </div>
+      </div>
+    </ModuleCommon>
+
+    <!-- 左下:安装设备情况 -->
+    <ModuleCommon
+      class="install-module"
+      :show-style="panelShowStyle(20, 568, 334, 472)"
+      :module-data="EMPTY_MODULE_DATA"
+      module-name="安装设备情况"
+      device-type="ignition_device_install"
+      :data="{}"
+      :visible="true"
+    >
+      <div class="install-panel">
+        <div v-for="device in devices" :key="device.name" class="install-item">
+          <span class="install-square"></span>
+          <span class="install-name">{{ device.name }}</span>
+          <span class="install-metric"
+            >安装:<em>{{ device.installed }}套</em></span
+          >
+          <span class="install-metric"
+            >运行:<em>{{ device.running }}套</em></span
+          >
+        </div>
+      </div>
+    </ModuleCommon>
+
+    <!-- 中下:整体预警等级 -->
+    <ModuleCommon
+      :show-style="panelShowStyle(413, 683, 343, 357)"
+      :module-data="EMPTY_MODULE_DATA"
+      module-name="矿井采空区整体预警等级"
+      device-type="ignition_overall_warning"
+      :data="{}"
+      :visible="true"
+    >
+      <div class="overall-panel">
+        <div class="overall-level" :style="{ color: overall.color }">{{ overall.levelText }} {{ overall.levelName }}</div>
+        <div class="overall-bar">
+          <img src="@/assets/images/vent/home/selfIgnition/risk-bar.png" alt="" />
+        </div>
+        <div class="overall-label">系统采空区总预警</div>
+        <div class="overall-total" :style="{ color: overall.color }">{{ overall.total }}</div>
+      </div>
+    </ModuleCommon>
+
+    <!-- 右下:分级预警表 -->
+    <ModuleCommon
+      class="table-module"
+      :show-style="panelShowStyle(793, 683, 1105, 357)"
+      :module-data="EMPTY_MODULE_DATA"
+      module-name="矿井采空区分级预警"
+      device-type="ignition_warning_table"
+      :data="{}"
+      :visible="true"
+    >
+      <div class="warning-table-panel">
+        <div class="warning-table">
+          <div class="table-head">
+            <span class="col-index">序号</span>
+            <span class="col-name">采空区名称</span>
+            <span class="col-seam">所属煤层</span>
+            <span class="col-level">预警等级</span>
+            <span class="col-status">状态</span>
+          </div>
+          <div class="table-body">
+            <div v-for="row in pagedWarnings" :key="row.id" class="table-row">
+              <span class="col-index">{{ row.id }}</span>
+              <span class="col-name">{{ row.name }}</span>
+              <span class="col-seam">{{ row.seam }}</span>
+              <span class="col-level">{{ row.level }}</span>
+              <span class="col-status">{{ row.status }}</span>
+            </div>
+          </div>
+        </div>
+        <div class="table-pager">
+          <i v-for="pageNo in totalPages" :key="pageNo" :class="{ active: pageNo === currentPage }" @click="currentPage = pageNo"></i>
+        </div>
+      </div>
+    </ModuleCommon>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { computed, ref } from 'vue';
+  import ModuleCommon from '../../configurable/components/ModuleCommon.vue';
+  import { EMPTY_MODULE_DATA, panelShowStyle } from '../utils';
+  import type { DeviceInstallItem, GoafOverviewItem, OverallWarning, WarningRow } from '../data';
+
+  const props = defineProps<{
+    overview: GoafOverviewItem[];
+    devices: DeviceInstallItem[];
+    overall: OverallWarning;
+    warnings: WarningRow[];
+  }>();
+
+  const PAGE_SIZE = 3;
+  const currentPage = ref(1);
+
+  const totalPages = computed(() => Math.max(1, Math.ceil(props.warnings.length / PAGE_SIZE)));
+
+  const pagedWarnings = computed(() => {
+    const start = (currentPage.value - 1) * PAGE_SIZE;
+    return props.warnings.slice(start, start + PAGE_SIZE);
+  });
+</script>
+
+<style lang="less" scoped>
+  .overview-view {
+    position: absolute;
+    inset: 0;
+  }
+
+  :deep(.overview-module .box1-center),
+  :deep(.install-module .box1-center) {
+    height: calc(100% - 35px) !important;
+  }
+
+  .overview-panel {
+    display: flex;
+    flex-direction: column;
+    gap: 34px;
+    height: 100%;
+    padding: 35px 9px 0 18px;
+    box-sizing: border-box;
+  }
+
+  .goaf-item {
+    position: relative;
+    display: flex;
+    align-items: center;
+    flex: none;
+    height: 46px;
+    padding: 0 14px 0 56px;
+    box-sizing: border-box;
+    border: 1px solid rgba(61, 246, 255, 0.5);
+    border-radius: 5px;
+    background: linear-gradient(90deg, #07304f 0%, #0a3b5f 42%, #0e4e77 100%);
+    box-shadow: inset 0 0 16px rgba(61, 246, 255, 0.08);
+  }
+
+  .goaf-icon {
+    position: absolute;
+    left: 4px;
+    top: 50%;
+    width: 42px;
+    height: 42px;
+    margin-top: -21px;
+    background: url('@/assets/images/vent/home/selfIgnition/list-icon-bg.png') no-repeat center / 100% 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    img {
+      width: 21px;
+      height: 14px;
+    }
+  }
+
+  .goaf-label {
+    flex: 1;
+    color: #fff;
+    font-size: 16px;
+    font-weight: 500;
+    letter-spacing: 0.5px;
+  }
+
+  .goaf-value {
+    color: #fff;
+    font-family: electronicFont, sans-serif;
+    font-size: 26px;
+    line-height: 1;
+    text-shadow:
+      0 0 10px rgba(255, 255, 255, 0.6),
+      0 0 20px rgba(61, 246, 255, 0.6);
+  }
+
+  .install-panel {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    padding: 24px 9px 0 18px;
+    box-sizing: border-box;
+
+    &::before {
+      content: '';
+      position: absolute;
+      top: 24px;
+      bottom: 0;
+      left: 19px;
+      width: 1px;
+      background: linear-gradient(180deg, rgba(61, 246, 255, 0), rgba(61, 246, 255, 0.7), rgba(61, 246, 255, 0));
+    }
+  }
+
+  .install-item {
+    position: relative;
+    display: flex;
+    align-items: center;
+    flex: 1;
+    min-height: 0;
+    gap: 8px;
+    padding: 0 4px 0 26px;
+    box-sizing: border-box;
+    border-bottom: 1px solid rgba(61, 246, 255, 0.15);
+    background: transparent;
+  }
+
+  .install-square {
+    position: absolute;
+    left: 0;
+    top: 50%;
+    width: 10px;
+    height: 10px;
+    margin-top: -5px;
+    background: linear-gradient(135deg, #b3fff5, #17b7ff);
+    box-shadow: 0 0 9px rgba(61, 246, 255, 0.9);
+  }
+
+  .install-name {
+    width: 96px;
+    flex: none;
+    color: #fff;
+    font-size: 15px;
+  }
+
+  .install-metric {
+    flex: 1;
+    color: #d9f7ff;
+    font-size: 14px;
+    white-space: nowrap;
+
+    em {
+      color: #3df6ff;
+      font-style: normal;
+      font-size: 16px;
+      text-shadow: 0 0 8px rgba(61, 246, 255, 0.55);
+    }
+  }
+
+  .overall-panel {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    height: 100%;
+    padding: 0 14px;
+    box-sizing: border-box;
+  }
+
+  .overall-level {
+    font-size: 40px;
+    font-weight: 500;
+    line-height: 1.15;
+    letter-spacing: 4px;
+    white-space: nowrap;
+    text-shadow:
+      0 0 16px currentColor,
+      0 0 30px rgba(255, 157, 28, 0.35);
+  }
+
+  .overall-bar {
+    position: relative;
+    width: 278px;
+    height: 38px;
+    margin-top: 33px;
+    clip-path: polygon(2% 0, 98% 0, 89% 100%, 11% 100%);
+    filter: drop-shadow(0 6px 12px rgba(61, 246, 255, 0.3));
+
+    img {
+      width: 100%;
+      height: 100%;
+      object-fit: fill;
+      opacity: 0.78;
+    }
+
+    &::after {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 8%;
+      right: 8%;
+      height: 2px;
+      background: linear-gradient(90deg, transparent, #ff9d1c, #ffd15c, #ff9d1c, transparent);
+      box-shadow: 0 0 10px rgba(255, 157, 28, 0.65);
+    }
+  }
+
+  .overall-label {
+    position: relative;
+    margin-top: 27px;
+    color: #fff;
+    font-size: 17px;
+    letter-spacing: 1px;
+
+    &::before {
+      content: '';
+      position: absolute;
+      left: 50%;
+      top: -12px;
+      width: 260px;
+      height: 1px;
+      margin-left: -130px;
+      background: linear-gradient(90deg, transparent, rgba(61, 246, 255, 0.85), transparent);
+      box-shadow: 0 0 8px rgba(61, 246, 255, 0.7);
+    }
+  }
+
+  .overall-total {
+    margin-top: 20px;
+    font-family: electronicFont, sans-serif;
+    font-size: 54px;
+    line-height: 1;
+    text-shadow: 0 0 14px currentColor;
+  }
+
+  .warning-table-panel {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    padding: 4px 22px 8px;
+    box-sizing: border-box;
+  }
+
+  .warning-table {
+    flex: 1;
+    min-height: 0;
+    border: 1px solid rgba(61, 246, 255, 0.55);
+    background: rgba(6, 30, 52, 0.2);
+  }
+
+  .table-head,
+  .table-row {
+    display: flex;
+    align-items: center;
+    text-align: center;
+  }
+
+  .table-head {
+    height: 48px;
+    color: #3df6ff;
+    font-size: 15px;
+    font-weight: 500;
+    letter-spacing: 1px;
+    background: linear-gradient(180deg, #0d3b58 0%, #0a2c48 100%);
+  }
+
+  .table-row {
+    height: 54px;
+    color: #fff;
+    font-size: 15px;
+    background: #0b3355;
+    border-top: 1px solid rgba(61, 246, 255, 0.14);
+
+    &:nth-child(even) {
+      background: #0d4167;
+    }
+  }
+
+  .col-index {
+    width: 17%;
+  }
+
+  .col-name {
+    width: 24%;
+  }
+
+  .col-seam {
+    width: 16%;
+  }
+
+  .col-level {
+    width: 21%;
+  }
+
+  .col-status {
+    width: 22%;
+  }
+
+  /* 表格面板较宽,使用短边框图拉伸,保证顶部/底部装饰点居中 */
+  :deep(.table-module .box1-top) {
+    --image-box1-top: url('@/assets/images/vent/border/box1-top.png');
+    background-image: var(--image-box1-top) !important;
+  }
+
+  :deep(.table-module .box1-bottom) {
+    --image-box1-bottom: url('@/assets/images/vent/border/box1-bottom.png');
+    background-image: var(--image-box1-bottom) !important;
+  }
+
+  .table-pager {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 8px;
+    height: 24px;
+    flex: none;
+
+    i {
+      width: 6px;
+      height: 6px;
+      border-radius: 1px;
+      background: rgba(61, 246, 255, 0.35);
+      cursor: pointer;
+      transition: all 0.2s;
+
+      &.active {
+        width: 16px;
+        background: #3df6ff;
+        box-shadow: 0 0 8px rgba(61, 246, 255, 0.85);
+      }
+    }
+  }
+</style>

+ 200 - 0
src/views/vent/home/selfIgnition/components/PanelSelect.vue

@@ -0,0 +1,200 @@
+<template>
+  <div
+    ref="rootRef"
+    class="ignition-select"
+    :class="{ open, 'drop-up': dropUp }"
+    role="combobox"
+    tabindex="0"
+    :aria-expanded="open"
+    @click.stop="toggle"
+    @keydown.enter.prevent="toggle"
+    @keydown.space.prevent="toggle"
+    @keydown.esc="open = false"
+  >
+    <span class="ignition-select-value">{{ selectedLabel }}</span>
+    <i class="ignition-select-arrow"></i>
+    <Transition name="ignition-drop">
+      <div v-if="open" class="ignition-select-dropdown">
+        <div
+          v-for="option in options"
+          :key="option.value"
+          class="ignition-select-option"
+          role="option"
+          :aria-selected="option.value === modelValue"
+          :class="{ active: option.value === modelValue }"
+          @click.stop="choose(option)"
+        >
+          {{ option.label }}
+        </div>
+      </div>
+    </Transition>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
+  import type { ManualOption } from '../data';
+
+  const props = defineProps<{
+    modelValue: string;
+    options: ManualOption[];
+  }>();
+
+  const emit = defineEmits<{
+    (event: 'update:modelValue', value: string): void;
+  }>();
+
+  const rootRef = ref<HTMLElement>();
+  const open = ref(false);
+  const dropUp = ref(false);
+
+  const selectedLabel = computed(() => {
+    return props.options.find((option) => option.value === props.modelValue)?.label ?? '';
+  });
+
+  function toggle() {
+    open.value = !open.value;
+    if (open.value) {
+      nextTick(() => {
+        const rect = rootRef.value?.getBoundingClientRect();
+        if (rect) {
+          dropUp.value = window.innerHeight - rect.bottom < 220;
+        }
+      });
+    }
+  }
+
+  function choose(option: ManualOption) {
+    emit('update:modelValue', option.value);
+    open.value = false;
+  }
+
+  function handleDocumentClick(event: MouseEvent) {
+    if (!open.value) return;
+    if (rootRef.value && !rootRef.value.contains(event.target as Node)) {
+      open.value = false;
+    }
+  }
+
+  onMounted(() => {
+    document.addEventListener('click', handleDocumentClick);
+  });
+
+  onUnmounted(() => {
+    document.removeEventListener('click', handleDocumentClick);
+  });
+</script>
+
+<style lang="less" scoped>
+  .ignition-select {
+    position: relative;
+    display: flex;
+    align-items: center;
+    height: 34px;
+    padding: 0 28px 0 10px;
+    box-sizing: border-box;
+    border: 1px solid rgba(61, 246, 255, 0.42);
+    border-radius: 4px;
+    background: url('@/assets/images/vent/home/selfIgnition/select-bg.png') no-repeat center / 100% 100%;
+    color: #fff;
+    font-size: 14px;
+    cursor: pointer;
+    user-select: none;
+    transition:
+      border-color 0.2s,
+      box-shadow 0.2s;
+
+    &:hover,
+    &.open {
+      border-color: rgba(61, 246, 255, 0.9);
+      box-shadow: 0 0 10px rgba(61, 246, 255, 0.25);
+    }
+
+    &.open {
+      z-index: 30;
+    }
+  }
+
+  .ignition-select-value {
+    width: 100%;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .ignition-select-arrow {
+    position: absolute;
+    right: 11px;
+    top: 50%;
+    margin-top: -2px;
+    width: 0;
+    height: 0;
+    border-left: 4px solid transparent;
+    border-right: 4px solid transparent;
+    border-top: 5px solid #3df6ff;
+    transition: transform 0.2s;
+  }
+
+  .open .ignition-select-arrow {
+    transform: rotate(180deg);
+  }
+
+  .ignition-select-dropdown {
+    position: absolute;
+    top: calc(100% + 4px);
+    left: 0;
+    bottom: auto;
+    z-index: 50;
+    width: 100%;
+    max-height: 196px;
+    padding: 4px;
+    box-sizing: border-box;
+    overflow-y: auto;
+    border: 1px solid rgba(61, 246, 255, 0.55);
+    border-radius: 4px;
+    background: linear-gradient(180deg, #0b3355 0%, #071d33 100%);
+    box-shadow:
+      0 8px 24px rgba(0, 0, 0, 0.55),
+      inset 0 0 16px rgba(17, 110, 165, 0.28);
+  }
+
+  .drop-up .ignition-select-dropdown {
+    top: auto;
+    bottom: calc(100% + 4px);
+  }
+
+  .ignition-select-option {
+    height: 30px;
+    padding: 0 8px;
+    line-height: 30px;
+    color: #d9f7ff;
+    font-size: 13px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    cursor: pointer;
+
+    &:hover {
+      color: #fff;
+      background: rgba(61, 246, 255, 0.16);
+    }
+
+    &.active {
+      color: #3df6ff;
+      background: rgba(61, 246, 255, 0.1);
+    }
+  }
+
+  .ignition-drop-enter-active,
+  .ignition-drop-leave-active {
+    transition:
+      opacity 0.16s ease,
+      transform 0.16s ease;
+  }
+
+  .ignition-drop-enter-from,
+  .ignition-drop-leave-to {
+    opacity: 0;
+    transform: translateY(-4px);
+  }
+</style>

+ 457 - 0
src/views/vent/home/selfIgnition/data.ts

@@ -0,0 +1,457 @@
+/**
+ * 张家峁煤自燃监测预警页面数据
+ *
+ * 当前阶段后端接口尚未提供,页面先使用本地模拟数据,
+ * 后续接入接口时只需要替换本文件中的模拟常量即可。
+ */
+
+export interface GoafOverviewItem {
+  label: string;
+  value: number;
+}
+
+export interface DeviceInstallItem {
+  name: string;
+  installed: number;
+  running: number;
+}
+
+export interface OverallWarning {
+  level: number;
+  levelText: string;
+  levelName: string;
+  total: number;
+  color: string;
+}
+
+export interface WarningRow {
+  id: number;
+  name: string;
+  seam: string;
+  level: string;
+  status: string;
+}
+
+export interface AlertItem {
+  id: number;
+  time: string;
+  level: number;
+  location: string;
+  message: string;
+}
+
+export interface BundleRow {
+  point: string;
+  o2: string;
+  co: number;
+  ch4: string;
+  c2h4: string;
+  temp: number;
+  status: '正常' | '关注' | '预警';
+}
+
+export interface FiberTrend {
+  times: string[];
+  maxTemp: number[];
+  avgTemp: number[];
+  maxValue: number;
+  avgValue: number;
+  pointCount: number;
+}
+
+export interface RiskLevel {
+  level: number;
+  name: string;
+  range: string;
+  colorName: string;
+  color: string;
+}
+
+export interface ManualOption {
+  label: string;
+  value: string;
+  score: number;
+}
+
+export interface ManualField {
+  key: string;
+  label: string;
+  value: string;
+  options: ManualOption[];
+}
+
+export interface ManualGroup {
+  key: string;
+  title: string;
+  fields: ManualField[];
+}
+
+export interface EvaluationResult {
+  score: number;
+  level: number;
+  levelText: string;
+  color: string;
+  highlightLevel: number;
+  target: string;
+  time: string;
+  source: string;
+  suggestion: string;
+}
+
+export const GOAF_OVERVIEW: GoafOverviewItem[] = [
+  { label: '矿井采空区总数', value: 28 },
+  { label: '已封闭采空区数', value: 6 },
+  { label: '回撤工作面采空区数', value: 4 },
+  { label: '回采工作面采空区数', value: 12 },
+  { label: '停产工作面采空区数', value: 6 },
+];
+
+export const INSTALLED_DEVICES: DeviceInstallItem[] = [
+  { name: '束管监测', installed: 15, running: 13 },
+  { name: '光纤测温', installed: 15, running: 13 },
+  { name: '密闭监测', installed: 15, running: 13 },
+  { name: '风速传感器', installed: 15, running: 13 },
+  { name: '风量传感器', installed: 15, running: 13 },
+];
+
+export const OVERALL_WARNING: OverallWarning = {
+  level: 4,
+  levelText: 'IV',
+  levelName: '高风险',
+  total: 12,
+  color: '#ff9d1c',
+};
+
+export const WARNING_ROWS: WarningRow[] = [
+  { id: 1, name: '12采空区', seam: '5-2煤', level: '一般风险', status: '回采中' },
+  { id: 2, name: '14采空区', seam: '5-2煤', level: '一般风险', status: '回撤中' },
+  { id: 3, name: '16采空区', seam: '5-1煤', level: '一般风险', status: '停产' },
+  { id: 4, name: '18采空区', seam: '5-2煤', level: '低风险', status: '回采中' },
+  { id: 5, name: '20采空区', seam: '5-1煤', level: '中等风险', status: '回采中' },
+  { id: 6, name: '22采空区', seam: '5-2煤', level: '低风险', status: '已封闭' },
+  { id: 7, name: '24采空区', seam: '4-2煤', level: '一般风险', status: '回采中' },
+  { id: 8, name: '26采空区', seam: '4-2煤', level: '低风险', status: '已封闭' },
+  { id: 9, name: '28采空区', seam: '5-1煤', level: '中等风险', status: '回撤中' },
+  { id: 10, name: '30采空区', seam: '5-2煤', level: '一般风险', status: '停产' },
+  { id: 11, name: '32采空区', seam: '4-2煤', level: '低风险', status: '已封闭' },
+  { id: 12, name: '34采空区', seam: '5-1煤', level: '一般风险', status: '回采中' },
+];
+
+export const ALERTS: AlertItem[] = [
+  { id: 1, time: '09:28:16', level: 4, location: '12采空区', message: '束管测点 CO 浓度上升至 24ppm' },
+  { id: 2, time: '09:16:42', level: 3, location: '20采空区', message: '密闭内 O₂ 浓度低于 15%,请关注' },
+  { id: 3, time: '08:57:03', level: 2, location: '14采空区', message: '光纤测温 3# 测点温度波动 1.8℃' },
+  { id: 4, time: '08:35:27', level: 4, location: '28采空区', message: '回风顺槽 CH₄ 浓度达到 0.86%' },
+  { id: 5, time: '08:12:51', level: 2, location: '18采空区', message: '风速传感器数据短时中断,已恢复' },
+];
+
+export const BUNDLE_ROWS: BundleRow[] = [
+  { point: '12采空区-1#', o2: '18.62', co: 16, ch4: '0.42', c2h4: '0.00', temp: 26.4, status: '正常' },
+  { point: '12采空区-2#', o2: '17.85', co: 24, ch4: '0.56', c2h4: '0.02', temp: 28.1, status: '关注' },
+  { point: '14采空区-1#', o2: '19.04', co: 9, ch4: '0.35', c2h4: '0.00', temp: 25.2, status: '正常' },
+  { point: '16采空区-1#', o2: '16.92', co: 31, ch4: '0.63', c2h4: '0.04', temp: 30.6, status: '预警' },
+  { point: '20采空区-1#', o2: '18.11', co: 18, ch4: '0.48', c2h4: '0.01', temp: 27.3, status: '正常' },
+  { point: '28采空区-1#', o2: '17.36', co: 27, ch4: '0.74', c2h4: '0.03', temp: 29.4, status: '关注' },
+];
+
+export const FIBER_TREND: FiberTrend = {
+  times: [
+    '00:00',
+    '01:00',
+    '02:00',
+    '03:00',
+    '04:00',
+    '05:00',
+    '06:00',
+    '07:00',
+    '08:00',
+    '09:00',
+    '10:00',
+    '11:00',
+    '12:00',
+    '13:00',
+    '14:00',
+    '15:00',
+    '16:00',
+    '17:00',
+    '18:00',
+    '19:00',
+    '20:00',
+    '21:00',
+    '22:00',
+    '23:00',
+  ],
+  maxTemp: [
+    24.1, 23.8, 23.6, 23.4, 23.5, 24.0, 25.2, 26.8, 28.4, 30.1, 31.6, 32.6, 33.1, 32.8, 32.2, 31.5, 30.6, 29.4, 28.2, 27.3, 26.6, 26.0, 25.4, 24.8,
+  ],
+  avgTemp: [
+    22.2, 22.0, 21.9, 21.8, 21.9, 22.1, 22.6, 23.2, 23.9, 24.5, 25.1, 25.6, 25.9, 25.8, 25.6, 25.3, 24.9, 24.5, 24.1, 23.8, 23.4, 23.1, 22.8, 22.5,
+  ],
+  maxValue: 33.1,
+  avgValue: 23.9,
+  pointCount: 15,
+};
+
+export const RISK_LEVELS: RiskLevel[] = [
+  { level: 1, name: 'I 低风险', range: '0-20', colorName: '绿色', color: '#7ed321' },
+  { level: 2, name: 'II 较低风险', range: '20-40', colorName: '蓝色', color: '#3df6ff' },
+  { level: 3, name: 'III 中等风险', range: '40-60', colorName: '黄色', color: '#ffd15c' },
+  { level: 4, name: 'IV 高风险', range: '60-80', colorName: '橙色', color: '#ff9d1c' },
+  { level: 5, name: 'V 极高风险', range: '80-100', colorName: '红色', color: '#ff4d4f' },
+];
+
+export const MANUAL_GROUPS: ManualGroup[] = [
+  {
+    key: 'pre',
+    title: '采前评价',
+    fields: [
+      {
+        key: 'spontaneous',
+        label: '自燃倾向性',
+        value: '自燃',
+        options: [
+          { label: '不易自燃', value: '不易自燃', score: 3 },
+          { label: '自燃', value: '自燃', score: 10 },
+          { label: '容易自燃', value: '容易自燃', score: 18 },
+        ],
+      },
+      {
+        key: 'sealQuality',
+        label: '封闭质量',
+        value: '一般',
+        options: [
+          { label: '好', value: '好', score: 2 },
+          { label: '一般', value: '一般', score: 8 },
+          { label: '差', value: '差', score: 15 },
+        ],
+      },
+      {
+        key: 'residualCoal',
+        label: '遗煤厚度(m)',
+        value: '1.0',
+        options: [
+          { label: '0.5', value: '0.5', score: 3 },
+          { label: '1.0', value: '1.0', score: 7 },
+          { label: '1.5', value: '1.5', score: 11 },
+          { label: '2.0', value: '2.0', score: 15 },
+        ],
+      },
+      {
+        key: 'advanceSpeed',
+        label: '推进速度(m/d)',
+        value: '3.5',
+        options: [
+          { label: '4.8', value: '4.8', score: 3 },
+          { label: '3.5', value: '3.5', score: 7 },
+          { label: '2.5', value: '2.5', score: 11 },
+          { label: '1.5', value: '1.5', score: 15 },
+        ],
+      },
+      {
+        key: 'airLeakage',
+        label: '漏风强度',
+        value: '中',
+        options: [
+          { label: '低', value: '低', score: 3 },
+          { label: '中', value: '中', score: 8 },
+          { label: '高', value: '高', score: 15 },
+        ],
+      },
+    ],
+  },
+  {
+    key: 'condition',
+    title: '开采条件',
+    fields: [
+      {
+        key: 'roof',
+        label: '顶板岩性',
+        value: '泥岩',
+        options: [
+          { label: '砂岩', value: '砂岩', score: 3 },
+          { label: '泥岩', value: '泥岩', score: 8 },
+          { label: '复合顶板', value: '复合顶板', score: 12 },
+        ],
+      },
+      {
+        key: 'structure',
+        label: '地质构造',
+        value: '较复杂',
+        options: [
+          { label: '简单', value: '简单', score: 3 },
+          { label: '较复杂', value: '较复杂', score: 8 },
+          { label: '复杂', value: '复杂', score: 12 },
+        ],
+      },
+      {
+        key: 'miningMethod',
+        label: '采煤方法',
+        value: '综放',
+        options: [
+          { label: '综采', value: '综采', score: 4 },
+          { label: '综放', value: '综放', score: 10 },
+        ],
+      },
+      {
+        key: 'ventMode',
+        label: '通风方式',
+        value: 'U型',
+        options: [
+          { label: 'U型', value: 'U型', score: 4 },
+          { label: 'W型', value: 'W型', score: 8 },
+          { label: 'U+L型', value: 'U+L型', score: 10 },
+        ],
+      },
+      {
+        key: 'sealMode',
+        label: '封闭方式',
+        value: '永久密闭',
+        options: [
+          { label: '永久密闭', value: '永久密闭', score: 3 },
+          { label: '临时密闭', value: '临时密闭', score: 8 },
+          { label: '未封闭', value: '未封闭', score: 13 },
+        ],
+      },
+    ],
+  },
+  {
+    key: 'monitor',
+    title: '监测数据',
+    fields: [
+      {
+        key: 'o2',
+        label: 'O₂浓度(%)',
+        value: '15-18',
+        options: [
+          { label: '≥18', value: '≥18', score: 3 },
+          { label: '15-18', value: '15-18', score: 7 },
+          { label: '10-15', value: '10-15', score: 12 },
+          { label: '<10', value: '<10', score: 16 },
+        ],
+      },
+      {
+        key: 'co',
+        label: 'CO浓度(ppm)',
+        value: '10-24',
+        options: [
+          { label: '<10', value: '<10', score: 3 },
+          { label: '10-24', value: '10-24', score: 7 },
+          { label: '24-50', value: '24-50', score: 12 },
+          { label: '>50', value: '>50', score: 16 },
+        ],
+      },
+      {
+        key: 'ch4',
+        label: 'CH₄浓度(%)',
+        value: '0.5-1.0',
+        options: [
+          { label: '<0.5', value: '<0.5', score: 3 },
+          { label: '0.5-1.0', value: '0.5-1.0', score: 6 },
+          { label: '>1.0', value: '>1.0', score: 10 },
+        ],
+      },
+      {
+        key: 'temperature',
+        label: '温度(℃)',
+        value: '26-30',
+        options: [
+          { label: '<26', value: '<26', score: 3 },
+          { label: '26-30', value: '26-30', score: 7 },
+          { label: '30-35', value: '30-35', score: 12 },
+          { label: '>35', value: '>35', score: 16 },
+        ],
+      },
+    ],
+  },
+];
+
+export const SUGGESTIONS: Record<number, string> = {
+  1: '保持现有监测频次,按计划开展日常巡检,关注束管测点气体变化趋势。',
+  2: '关注束管测点变化趋势,定期校验传感器,保持采空区密闭完好。',
+  3: '加密束管采样频次,加强回风顺槽气体分析;对高温测点进行连续测温跟踪;必要时调整推进速度并落实注浆与注氮措施。',
+  4: '立即加密监测并核查高温测点,落实注浆、注氮等综合防灭火措施,必要时调整推进速度。',
+  5: '立即停止作业并撤出受影响区域人员,启动应急预案,实施连续监测与注浆注氮措施。',
+};
+
+const LEVEL_TEXT: Record<number, string> = {
+  1: 'I 低风险',
+  2: 'II 较低风险',
+  3: 'III 中等风险',
+  4: 'IV 高风险',
+  5: 'V 极高风险',
+};
+
+export const INITIAL_EVALUATION: EvaluationResult = {
+  score: 58.6,
+  level: 3,
+  levelText: 'III 高风险',
+  color: '#ff9d1c',
+  highlightLevel: 4,
+  target: '12采空区工作面',
+  time: '2026-10-12 09:30',
+  source: '人工录入',
+  suggestion: SUGGESTIONS[3],
+};
+
+/** 按 0-20 / 20-40 / 40-60 / 60-80 / 80-100 划分风险等级 */
+export function getRiskLevel(score: number): { level: number; name: string; color: string } {
+  if (score < 20) return { level: 1, name: LEVEL_TEXT[1], color: RISK_LEVELS[0].color };
+  if (score < 40) return { level: 2, name: LEVEL_TEXT[2], color: RISK_LEVELS[1].color };
+  if (score < 60) return { level: 3, name: LEVEL_TEXT[3], color: RISK_LEVELS[2].color };
+  if (score < 80) return { level: 4, name: LEVEL_TEXT[4], color: RISK_LEVELS[3].color };
+  return { level: 5, name: LEVEL_TEXT[5], color: RISK_LEVELS[4].color };
+}
+
+/** 根据人工录入的各项得分计算 0-100 的风险分值 */
+export function calcManualScore(groups: ManualGroup[]): number {
+  let score = 0;
+  let maxScore = 0;
+  groups.forEach((group) => {
+    group.fields.forEach((field) => {
+      const selected = field.options.find((option) => option.value === field.value);
+      score += selected?.score ?? 0;
+      maxScore += Math.max(...field.options.map((option) => option.score));
+    });
+  });
+  if (!maxScore) return 0;
+  return Math.round((score / maxScore) * 1000) / 10;
+}
+
+/** 深度复制人工录入表单,避免多个标签页共享同一份可变数据 */
+export function createManualGroups(): ManualGroup[] {
+  return MANUAL_GROUPS.map((group) => ({
+    ...group,
+    fields: group.fields.map((field) => ({
+      ...field,
+      options: field.options.map((option) => ({ ...option })),
+    })),
+  }));
+}
+
+export interface SelfIgnitionPageData {
+  overview: GoafOverviewItem[];
+  devices: DeviceInstallItem[];
+  overall: OverallWarning;
+  warnings: WarningRow[];
+  alerts: AlertItem[];
+  bundleRows: BundleRow[];
+  fiber: FiberTrend;
+}
+
+/** 生成页面数据副本;数据接口就绪后替换此函数即可 */
+export function createSelfIgnitionData(): SelfIgnitionPageData {
+  return {
+    overview: GOAF_OVERVIEW.map((item) => ({ ...item })),
+    devices: INSTALLED_DEVICES.map((item) => ({ ...item })),
+    overall: { ...OVERALL_WARNING },
+    warnings: WARNING_ROWS.map((item) => ({ ...item })),
+    alerts: ALERTS.map((item) => ({ ...item })),
+    bundleRows: BUNDLE_ROWS.map((item) => ({ ...item })),
+    fiber: {
+      ...FIBER_TREND,
+      times: [...FIBER_TREND.times],
+      maxTemp: [...FIBER_TREND.maxTemp],
+      avgTemp: [...FIBER_TREND.avgTemp],
+    },
+  };
+}

+ 241 - 0
src/views/vent/home/selfIgnition/index.vue

@@ -0,0 +1,241 @@
+<!-- eslint-disable vue/multi-word-component-names -->
+<template>
+  <div ref="rootRef" class="ignition-home">
+    <div class="ignition-stage" :style="stageStyle">
+      <!-- 顶部标题 -->
+      <div v-if="!isEmbed" class="ignition-header">
+        <div class="ignition-title">{{ pageTitle }}</div>
+      </div>
+
+      <!-- 顶部切换 -->
+      <div v-if="!isEmbed" class="ignition-tabs">
+        <div v-for="(tab, index) in tabs" :key="tab" class="ignition-tab" :class="{ active: activeTab === index }" @click="activeTab = index">
+          {{ tab }}
+        </div>
+      </div>
+
+      <!-- 系统概览 -->
+      <OverviewView v-show="activeTab === 0" :overview="data.overview" :devices="data.devices" :overall="data.overall" :warnings="data.warnings" />
+
+      <!-- 设备实时监测与分析 -->
+      <MonitorView v-show="activeTab === 1" :devices="data.devices" :alerts="data.alerts" :bundle-rows="data.bundleRows" :fiber="data.fiber" />
+
+      <!-- 人工录入与分析 -->
+      <ManualView v-show="activeTab === 2" />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue';
+  import { useRoute } from 'vue-router';
+  import OverviewView from './components/OverviewView.vue';
+  import MonitorView from './components/MonitorView.vue';
+  import ManualView from './components/ManualView.vue';
+  import { createSelfIgnitionData } from './data';
+  import { noHeadeLink } from '/@/layouts/default/layout.data';
+
+  // 与其他 configurable 首页一致:把当前路由登记到布局的“无 header”白名单。
+  // 该数组在布局 header 渲染前由本页面模块写入,不修改公共组件。
+  const PAGE_PATH = '/fire/monitor/selfIgnition';
+  if (!noHeadeLink.includes(PAGE_PATH)) {
+    noHeadeLink.push(PAGE_PATH);
+  }
+
+  const PAGE_STYLE_ID = 'self-ignition-fullscreen-style';
+
+  function mountPageStyle() {
+    if (document.getElementById(PAGE_STYLE_ID)) return;
+    const style = document.createElement('style');
+    style.id = PAGE_STYLE_ID;
+    style.textContent = `.jeecg-layout-header { display: none !important; }`;
+    document.head.appendChild(style);
+  }
+
+  function unmountPageStyle() {
+    document.getElementById(PAGE_STYLE_ID)?.remove();
+  }
+
+  const route = useRoute();
+  const rootRef = ref<HTMLElement>();
+  const scaleX = ref(1);
+  const scaleY = ref(1);
+  const activeTab = ref(0);
+  const pageTitle = '张家峁煤自燃监测预警';
+  const tabs = ['系统概览', '设备实时监测与分析', '人工录入与分析'];
+  const data = ref(createSelfIgnitionData());
+
+  let resizeObserver: ResizeObserver | undefined;
+  let dataInterval: number | undefined;
+
+  const isEmbed = computed(() => !!route.query.embed);
+
+  const stageStyle = computed(() => ({
+    transform: `translate(-50%, -50%) scale(${scaleX.value}, ${scaleY.value})`,
+  }));
+
+  /**
+   * 设计稿为 1920 × 1080。外层 AdaptiveContainer 已经把 1920 × 928 拉伸到视口,
+   * 这里按外层实际的 x / y 缩放比例做反向补偿,保证 1920 × 1080 的坐标始终精确落位。
+   */
+  function updateScale() {
+    const root = rootRef.value;
+    if (!root) return;
+    const rootWidth = root.clientWidth;
+    const rootHeight = root.clientHeight;
+    if (!rootWidth || !rootHeight) return;
+
+    const rect = root.getBoundingClientRect();
+    const outerScaleX = rect.width / rootWidth || 1;
+    const outerScaleY = rect.height / rootHeight || 1;
+    const fitScale = Math.min(rect.width / 1920, rect.height / 1080);
+
+    scaleX.value = fitScale / outerScaleX;
+    scaleY.value = fitScale / outerScaleY;
+  }
+
+  /** 数据接口就绪后,在这里替换成接口请求 */
+  function refresh() {
+    data.value = createSelfIgnitionData();
+  }
+
+  function initDataInterval() {
+    clearInterval(dataInterval);
+    dataInterval = window.setInterval(refresh, 60000);
+  }
+
+  onMounted(() => {
+    mountPageStyle();
+    initDataInterval();
+    updateScale();
+    if (typeof ResizeObserver !== 'undefined' && rootRef.value) {
+      resizeObserver = new ResizeObserver(updateScale);
+      resizeObserver.observe(rootRef.value);
+    }
+    window.addEventListener('resize', updateScale);
+  });
+
+  onActivated(() => {
+    mountPageStyle();
+  });
+
+  onDeactivated(() => {
+    unmountPageStyle();
+  });
+
+  onUnmounted(() => {
+    unmountPageStyle();
+    clearInterval(dataInterval);
+    resizeObserver?.disconnect();
+    window.removeEventListener('resize', updateScale);
+  });
+</script>
+
+<style lang="less" scoped>
+  @import '/@/design/theme.less';
+
+  .ignition-home {
+    position: relative;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+    color: #fff;
+    background: linear-gradient(180deg, #0a2037 0%, #09172c 46%, #062441 100%);
+  }
+
+  .ignition-stage {
+    position: absolute;
+    left: 50%;
+    top: 50%;
+    width: 1920px;
+    height: 1080px;
+    transform-origin: center center;
+  }
+
+  .ignition-header {
+    position: absolute;
+    left: 0;
+    top: 0;
+    z-index: 3;
+    width: 100%;
+    height: 69px;
+    background: url('@/assets/images/vent/home/selfIgnition/top-bg.png') no-repeat center / 100% 100%;
+  }
+
+  .ignition-title {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 100%;
+    padding-bottom: 2px;
+    box-sizing: border-box;
+    font-family: 'douyuFont', sans-serif;
+    font-size: 30px;
+    font-weight: 500;
+    letter-spacing: 1px;
+    text-shadow:
+      0 0 12px rgba(61, 246, 255, 0.35),
+      0 2px 4px rgba(0, 0, 0, 0.65);
+  }
+
+  .ignition-tabs {
+    position: absolute;
+    left: 50%;
+    top: 64px;
+    z-index: 4;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 6px;
+    width: 579px;
+    height: 68px;
+    padding: 13px 16px 9px;
+    box-sizing: border-box;
+    transform: translateX(-50%);
+    background: url('@/assets/images/vent/home/selfIgnition/tab-bg.png') no-repeat center / 100% 100%;
+  }
+
+  .ignition-tab {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 158px;
+    height: 40px;
+    border: 1px solid rgba(61, 246, 255, 0.14);
+    background: url('@/assets/images/vent/home/selfIgnition/tab-item-bg.png') no-repeat center / 100% 100%;
+    color: rgba(255, 255, 255, 0.86);
+    font-size: 15px;
+    letter-spacing: 0.5px;
+    white-space: nowrap;
+    cursor: pointer;
+    transition:
+      color 0.2s,
+      border-color 0.2s,
+      box-shadow 0.2s;
+
+    &:hover {
+      color: #fff;
+      border-color: rgba(61, 246, 255, 0.4);
+    }
+
+    &.active {
+      border-color: rgba(61, 246, 255, 0.55);
+      background: linear-gradient(180deg, #2a5985 0%, #1d4773 100%);
+      color: #fff;
+      box-shadow: inset 0 0 12px rgba(61, 246, 255, 0.18);
+    }
+  }
+
+  :deep(.box1-top .title) {
+    color: #fff;
+    font-size: 16px;
+    font-weight: 600;
+    letter-spacing: 1px;
+    text-shadow: 0 0 8px rgba(61, 246, 255, 0.35);
+  }
+
+  :deep(.box1-center > .box-container) {
+    background-color: #062441 !important;
+    background-image: none;
+  }
+</style>

+ 14 - 0
src/views/vent/home/selfIgnition/utils.ts

@@ -0,0 +1,14 @@
+/** 生成 ModuleCommon 需要的 showStyle,坐标基于 1920 × 1080 设计稿 */
+export function panelShowStyle(x: number, y: number, width: number, height: number) {
+  return {
+    size: `width:${width}px;height:${height}px;`,
+    position: `left:${x}px;top:${y}px;`,
+  };
+}
+
+/** 页面模块不需要配置下拉框,给 ModuleCommon 一个最小可用的 moduleData */
+export const EMPTY_MODULE_DATA = {
+  header: {
+    show: false,
+  },
+};

+ 308 - 0
src/views/vent/monitorManager/compressor/components/ceshi.json

@@ -0,0 +1,308 @@
+{
+    "success": true,
+    "message": "",
+    "code": 200,
+    "result": {
+        "cmd": "monitordata",
+        "msgTxt": [
+            {
+                "datalist": [
+                    {
+                        "msgType": null,
+                        "deviceID": "2102933971422810114",
+                        "strname": "制氮车间",
+                        "strinstallpos": "制氮车间",
+                        "fsectarea": "null",
+                        "planedVa": null,
+                        "regulation": null,
+                        "deviceRegulation": null,
+                        "stationname": "制氮车间",
+                        "stationtype": "redisother",
+                        "deviceType": "nitrogen_auto",
+                        "typeName": "制氮机",
+                        "netStatus": 1,
+                        "warnFlag": 0,
+                        "warnLevel": 0,
+                        "warnLevel_str": "正常",
+                        "syswarnLevel": null,
+                        "syswarnLevel_str": null,
+                        "syswarnLevel_des": null,
+                        "warnTime": null,
+                        "readTime": "2026-10-20 08:59:10",
+                        "warnDes": "",
+                        "frontGateOpenCtrl": null,
+                        "rearGateOpenCtrl": null,
+                        "readData": {
+                            "kyj_3h_pqwd": "19",
+                            "jz2_lgj_rkyl": "0",
+                            "jz1_dqyl": "0.00",
+                            "sign": "0",
+                            "jz3_jzkz_str": "是",
+                            "jz2_ld_wd": "12.42",
+                            "jz3_ysj_cw": "20.83",
+                            "jz2_ljll": "1227264",
+                            "kyj_2h_yx_str": "否",
+                            "jz2_yuxing": "0",
+                            "kyj_1h_pqyl": "0",
+                            "kyj_6h_pqwd": "18",
+                            "jz3_dqwd": "19.63",
+                            "jz3_yuxing": "0",
+                            "jz1_yuxing": "0",
+                            "jz1_jrqx_wd": "21.40",
+                            "jz3_zhudan_str": "否",
+                            "kyj_1h_yx": "0",
+                            "jz3_fjkz_str": "否",
+                            "jz2_lgj_ckyl": "0",
+                            "jz1_jzkz_str": "是",
+                            "jz1_jzkz": "1",
+                            "kyj_4h_pqyl": "0",
+                            "jz2_zhudan_str": "否",
+                            "kyj_2h_pqwd": "18",
+                            "jz2_yuxing_str": "否",
+                            "jz2_ysj_jw": "19.71",
+                            "jz2_zhudan": "0",
+                            "jz3_bcljll": "0",
+                            "jz2_ssll": "0.00",
+                            "jz2_jrqx_wd": "19.77",
+                            "jz2_dqyl": "0.00",
+                            "jz1_fjkz": "0",
+                            "jz1_bcljll": "0",
+                            "jz1_ysj_cw": "21.64",
+                            "kyj_2h_yx": "0",
+                            "jz2_fjkz_str": "否",
+                            "kyj_5h_pqwd": "19",
+                            "jz3_ljll": "110000",
+                            "jz1_zhudan_str": "否",
+                            "jz3_lgj_ckyl": "0",
+                            "jz1_ljyxsj": "1161",
+                            "jz3_ljyxsj": "516",
+                            "jz1_yuxing_str": "否",
+                            "kyj_3h_yx": "0",
+                            "kyj_5h_yx_str": "否",
+                            "kyj_4h_yx": "0",
+                            "jz3_bcljsj": "11",
+                            "jz2_fjkz": "0",
+                            "jz2_bcljsj": "14",
+                            "kyj_3h_yx_str": "否",
+                            "jz1_bcljsj": "17",
+                            "jz1_dqwd": "19.88",
+                            "kyj_1h_yx_str": "否",
+                            "jz1_fjkz_str": "否",
+                            "kyj_3h_pqyl": "0",
+                            "jz2_jzkz": "1",
+                            "jz3_ssll": "0.00",
+                            "kyj_6h_pqyl": "0",
+                            "jz2_jzkz_str": "是",
+                            "kyj_1h_pqwd": "19",
+                            "jz3_ld_wd": "6.26",
+                            "jz3_dqyl": "0.00",
+                            "jz3_yuxing_str": "否",
+                            "kyj_5h_yx": "0",
+                            "jz3_lgj_rkyl": "0",
+                            "jz3_hyl": "20.63",
+                            "jz1_lgj_ckyl": "0",
+                            "jz1_ljll": "1460000",
+                            "jz1_ld_wd": "18.92",
+                            "jz2_dqwd": "20.25",
+                            "jz1_zhudan": "0",
+                            "jz1_lgj_rkyl": "0",
+                            "kyj_4h_pqwd": "19",
+                            "jz3_fjkz": "0",
+                            "jz3_ysj_jw": "20.02",
+                            "kyj_6h_yx": "0",
+                            "tTime": "2026-10-20 08:59:08",
+                            "kyj_2h_pqyl": "0",
+                            "jz2_bcljll": "0",
+                            "jz2_hyl": "20.50",
+                            "jz3_jrqx_wd": "21.13",
+                            "jz2_ysj_cw": "20.93",
+                            "kyj_6h_yx_str": "否",
+                            "jz3_jzkz": "1",
+                            "jz1_ysj_jw": "20.02",
+                            "jz3_zhudan": "0",
+                            "kyj_4h_yx_str": "否",
+                            "kyj_5h_pqyl": "0",
+                            "jz2_ljyxsj": "356",
+                            "isRun": "-2",
+                            "jz1_ssll": "0.00",
+                            "jz1_hyl": "20.75"
+                        },
+                        "readDataDes": null,
+                        "limits": null,
+                        "summaryHour": [],
+                        "summaryDay": [],
+                        "history": [],
+                        "dayhistory": [],
+                        "totalInfo": null,
+                        "sign": null,
+                        "cameras": [],
+                        "links": [],
+                        "avgParam": {},
+                        "other1": null,
+                        "other2": null,
+                        "other3": null,
+                        "remarkInfo": null,
+                        "linkInfo": null,
+                        "addrIndex": null,
+                        "warnLogNotOkCount": 0,
+                        "otherInfo": null,
+                        "orderNum": 1,
+                        "testFlag": 0,
+                        "strRemark": null,
+                        "isAutosync": null,
+                        "isBusSup": null,
+                        "ventMode": null,
+                        "groupNumber": "",
+                        "isUnderground": ""
+                    }
+                ],
+                "avginfo": {
+                    "warnFlag": {
+                        "value": 0
+                    }
+                },
+                "typeName": "制氮机",
+                "type": "nitrogen_auto"
+            },
+            {
+                "subtype": "sys_nitrogen",
+                "datalist": [
+                    {
+                        "msgType": null,
+                        "deviceID": "2109541169397633025",
+                        "strname": "空压机房",
+                        "strinstallpos": "空压机房",
+                        "fsectarea": "null",
+                        "planedVa": null,
+                        "regulation": null,
+                        "deviceRegulation": null,
+                        "stationname": null,
+                        "stationtype": null,
+                        "deviceType": "sys_nitrogen",
+                        "typeName": "注氮",
+                        "netStatus": 1,
+                        "warnFlag": 0,
+                        "warnLevel": 0,
+                        "warnLevel_str": "正常",
+                        "syswarnLevel": null,
+                        "syswarnLevel_str": null,
+                        "syswarnLevel_des": null,
+                        "warnTime": null,
+                        "readTime": "2026-10-20 08:59:10",
+                        "warnDes": null,
+                        "frontGateOpenCtrl": null,
+                        "rearGateOpenCtrl": null,
+                        "readData": {
+                            "sign": "0",
+                            "tTime": "2026-10-20 08:59:05",
+                            "isRun": "-2"
+                        },
+                        "readDataDes": null,
+                        "limits": null,
+                        "summaryHour": [],
+                        "summaryDay": [],
+                        "history": [],
+                        "dayhistory": [],
+                        "totalInfo": {
+                            "unitNum": 0
+                        },
+                        "sign": null,
+                        "cameras": [],
+                        "links": [],
+                        "avgParam": {},
+                        "other1": null,
+                        "other2": null,
+                        "other3": null,
+                        "remarkInfo": null,
+                        "linkInfo": null,
+                        "addrIndex": null,
+                        "warnLogNotOkCount": 0,
+                        "otherInfo": null,
+                        "orderNum": null,
+                        "testFlag": null,
+                        "strRemark": null,
+                        "isAutosync": null,
+                        "isBusSup": null,
+                        "ventMode": null,
+                        "coalSeamThick": "",
+                        "strikelength": "",
+                        "coalThickness": "",
+                        "faceRetM3": "",
+                        "totalDrillingType": "",
+                        "faceLen": "",
+                        "linkEmptyFlag": "0",
+                        "totalDate": "",
+                        "stepOut1": "",
+                        "mainCoalseam": "",
+                        "drillingType": "",
+                        "coalSeam": "",
+                        "stepOut2": "",
+                        "pressureRationality1": "",
+                        "pressureRationality2": "",
+                        "userM3": "",
+                        "gasReserves": "",
+                        "linkEmpty": "",
+                        "beginDrainageDate": "",
+                        "goafManage": "",
+                        "dischargeGasEmission": "",
+                        "useM3": "",
+                        "ventCapacity": "",
+                        "stepMid2": "",
+                        "stepMid1": "",
+                        "zoneRationality1": "",
+                        "manageCoalMethod": "",
+                        "miningArea": "",
+                        "cuteyeLength": "",
+                        "totalGasVolume": "",
+                        "gasLevel": "",
+                        "supportNum": "",
+                        "totalComplteQuantity": "",
+                        "stepIn1": "",
+                        "stepIn2": "",
+                        "totalOutM3": "",
+                        "coalReserves": "",
+                        "faceIntM3": "",
+                        "cumulativeScalar": "",
+                        "totalIntM3": "",
+                        "totalAverageRate": "",
+                        "equalarea": "",
+                        "coalSpoCha": "",
+                        "thickness": "",
+                        "unitNum": "",
+                        "meterCount1": "",
+                        "meterCount2": "",
+                        "goafName": "",
+                        "meterCount3": "",
+                        "meterCount4": "",
+                        "meterCount5": "",
+                        "coalProduction": "",
+                        "drillinFootage": "",
+                        "totalRetM3": "",
+                        "useM3Perent": "",
+                        "ventRationality": "",
+                        "zoneRationality2": "",
+                        "minFirePeriod": "",
+                        "cumulativeFlow": "",
+                        "scalarRecognition": "",
+                        "coalSeamAngle": "",
+                        "totalPlanM3": "",
+                        "facePlanM3": "",
+                        "coalseam": "",
+                        "coalSeamThickness": "",
+                        "workingFaceLengeh": "",
+                        "flength": "",
+                        "panel": "",
+                        "leakage": "",
+                        "workingFaceZouxiangLength": "",
+                        "workingFaceQieyanLength": "",
+                        "originalGasContent": "",
+                        "workingFaceHeight": ""
+                    }
+                ],
+                "typeName": "综合监测系统",
+                "type": "sys"
+            }
+        ]
+    },
+    "timestamp": 1792457952949
+}

+ 7 - 16
src/views/vent/monitorManager/compressor/components/nitrogenHome.vue

@@ -302,23 +302,14 @@
   }
 
   async function getDataSource(systemID) {
+    if (!systemID) return;
     const res = await list({ devicetype: 'sys', systemID, type: 'all' });
-    const result = res.msgTxt;
-    if (!result || result.length < 1) return;
-    result.forEach((item) => {
-      if (item.type === 'nitrogen_auto') {
-        let netStatus = 0;
-        monitorData.value = item['datalist'].filter((data) => {
-          if (data['netStatus'] == 1) {
-            netStatus = 1;
-          }
-          const item = data.readData;
-          return Object.assign(data, item);
-        });
-        monitorNetStatus.value = netStatus;
-      }
-    });
+    // 数据与机数一次提交:没绑注氮设备的场景一并归零,不残留上一个场景的机器;
+    // 设备类型统一按 nitrogen 前缀匹配(nitrogen_auto 等)
+    const nitrogenItem = (res?.msgTxt || []).find((item) => item?.type?.startsWith('nitrogen'));
+    monitorData.value = (nitrogenItem?.['datalist'] || []).map((data) => Object.assign(data, data.readData || {}));
     monitorDataGroupNum.value = monitorData.value.length;
+    monitorNetStatus.value = monitorData.value.some((data) => data['netStatus'] == 1) ? 1 : 0;
   }
 
   function handlerDevice(data) {
@@ -345,7 +336,7 @@
 
   onMounted(async () => {
     await getMonitor(true);
-    await mountedThree().then(() => {
+    await mountedThree('nitrogen_bjhz').then(() => {
       loading.value = false;
     });
   });

+ 610 - 0
src/views/vent/monitorManager/compressor/components/nitrogenHomeNew.vue

@@ -0,0 +1,610 @@
+<template>
+  <div
+    id="nitrogenCss3D"
+    class="threejs-Object-CSS"
+    style="width: 100%; height: 100%; position: absolute; pointer-events: none; overflow: hidden; z-index: 2; top: 0px; left: 0px"
+  >
+    <a-spin :spinning="loading" />
+    <template>
+      <div
+        v-for="card in floatCards"
+        :key="card.id"
+        :id="card.domId"
+        class="modal-monitor model-float-card"
+        :style="{ visibility: moduleTypeName === 'ZDJ' ? 'visible' : 'hidden' }"
+      >
+        <fourBorderBg>
+          <div class="title">{{ card.title }}</div>
+          <div class="monitor-item" v-for="param in card.params" :key="param.code">
+            <span class="monitor-title">{{ param.unit ? `${param.label}(${param.unit})` : param.label }}:</span>
+            <span class="monitor-val"
+              ><span class="val">{{ param.value }}</span></span
+            >
+          </div>
+        </fourBorderBg>
+      </div>
+    </template>
+  </div>
+  <div id="nitrogen3D" style="width: 100%; height: 100%; position: absolute; overflow: hidden"></div>
+  <div class="nitrogen-home">
+    <div class="nitrogen-container">
+      <div class="top-box">
+        <!-- 左边监测数据 -->
+        <div class="lr-box left-box" :class="{ 'left-box-kyj': moduleTypeName === 'KYJ', 'left-box-zdj': moduleTypeName === 'ZDJ' }">
+          <div class="item item-l" v-for="card in unitCards" :key="card.id">
+            <ventBox1>
+              <template #title>
+                <div>{{ card.title }}</div>
+              </template>
+              <template #container>
+                <div class="monitor-box">
+                  <div class="parameter-title group-parameter-title"
+                    ><SvgIcon class="icon" size="38" name="device-group-paramer" /><span>机组参数</span></div
+                  >
+                  <div class="parameter-scroll">
+                    <div class="state-item" v-for="(row, index) in card.rows" :key="index">
+                      <div class="item-col" v-for="param in row" :key="param.code">
+                        <span class="state-title">{{ param.unit ? `${param.label}(${param.unit})` : param.label }} :</span>
+                        <span class="state-val">{{ param.value }}</span>
+                      </div>
+                    </div>
+                  </div>
+                </div>
+              </template>
+            </ventBox1>
+          </div>
+        </div>
+        <!-- 右边监测曲线:空压机房不展示 -->
+        <div class="lr-box right-box" v-if="moduleTypeName !== 'KYJ'">
+          <ventBox1>
+            <template #title>
+              <div>设备实时监测曲线</div>
+            </template>
+            <template #container>
+              <BarAndLineCustom xAxisPropType="readTime" :chartData="flowChartData" height="240px" :propTypeArr="['flowRate']" :option="flowChartOption" />
+            </template>
+          </ventBox1>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+<script lang="ts" setup name="nitrogenHome">
+  import { onMounted, onUnmounted, ref, watch, nextTick } from 'vue';
+  import ventBox1 from '/@/components/vent/ventBox1.vue';
+  import fourBorderBg from '../../../comment/components/fourBorderBg.vue';
+  import { mountedThree, destroy, attachMonitorCards } from '../nitrogen.threejs';
+  import { list } from '../nitrogen.api';
+  import { SvgIcon } from '/@/components/Icon';
+  import BarAndLineCustom from '/@/components/chart/BarAndLineCustom.vue';
+  import { zhudanOption } from '../nitrogen.data';
+
+  interface PointField {
+    code: string;
+    label: string;
+    unit: string;
+  }
+
+  interface UnitParam {
+    code: string;
+    label: string;
+    value: string;
+    unit: string;
+  }
+
+  interface UnitCard {
+    id: string;
+    title: string;
+    rows: UnitParam[][];
+  }
+
+  interface FlowPoint {
+    readTime: string;
+    flowRate: number;
+  }
+
+  interface FloatCard {
+    id: string;
+    domId: string;
+    title: string;
+    params: UnitParam[];
+  }
+
+  const props = defineProps({
+    deviceId: {
+      type: String,
+      require: true,
+    },
+    modalType: {
+      type: String,
+      require: true,
+    },
+    moduleTypeName: {
+      type: String,
+      default: '',
+    },
+  });
+  const loading = ref(true);
+
+  const nitrogenFields: PointField[] = [
+    { code: 'dqyl', label: '氮气压力', unit: 'MPa' },
+    { code: 'ysj_cw', label: '压缩机出温', unit: '℃' },
+    { code: 'ysj_jw', label: '压缩机进温', unit: '℃' },
+    { code: 'ld_wd', label: '露点温度', unit: '℃' },
+    { code: 'jrqx_wd', label: '加热器温度', unit: '℃' },
+    { code: 'lgj_ckyl', label: '冷干机出口压力', unit: 'MPa' },
+    { code: 'lgj_rkyl', label: '冷干机入口压力', unit: 'MPa' },
+    { code: 'ljyxsj', label: '累计运行时间', unit: 'h' },
+    { code: 'bcljsj', label: '本次累计时间', unit: 'h' },
+    { code: 'dqwd', label: '氮气温度', unit: '℃' },
+    { code: 'ljll', label: '累计流量', unit: 'm³' },
+    { code: 'bcljll', label: '本次累计流量', unit: 'm³' },
+    { code: 'hyl', label: '含氧量', unit: '%' },
+    { code: 'ssll', label: '瞬时流量', unit: 'm³/h' },
+  ];
+
+  const compressorFields: PointField[] = [
+    { code: 'pqwd', label: '排气温度', unit: '℃' },
+    { code: 'pqyl', label: '排气压力', unit: 'MPa' },
+  ];
+
+  const modelFloatFields: PointField[] = [
+    { code: 'ljyxsj', label: '累计运行时间', unit: 'h' },
+    { code: 'bcljsj', label: '本次累计时间', unit: 'h' },
+    { code: 'dqwd', label: '氮气温度', unit: '℃' },
+    { code: 'ljll', label: '累计流量', unit: 'm³' },
+    { code: 'ssll', label: '瞬时流量', unit: 'm³/h' },
+  ];
+
+  const modelFloatCodes = modelFloatFields.map((field) => field.code);
+
+  const unitCards = ref<UnitCard[]>([]);
+  const floatCards = ref<FloatCard[]>(buildFloatCards({}));
+  const flowChartData = ref<FlowPoint[]>([]);
+  const flowPointLimit = 12;
+  const flowYAxis = { ...zhudanOption.yAxis[0], min: 0 };
+  delete flowYAxis.max;
+  const flowChartOption = {
+    ...zhudanOption,
+    yAxis: [flowYAxis],
+  };
+
+  // https获取监测数据
+  let timer: null | NodeJS.Timeout = null;
+  async function getMonitor(flag?) {
+    if (Object.prototype.toString.call(timer) === '[object Null]') {
+      timer = await setTimeout(
+        async () => {
+          if (props.deviceId) {
+            await getDataSource();
+          }
+          if (timer) {
+            timer = null;
+          }
+          await getMonitor();
+        },
+        flag ? 0 : 1000
+      );
+    }
+  }
+
+  async function getReadData() {
+    const res = await list({ devicetype: 'sys', systemID: props.deviceId, type: 'all' });
+    const msgTxt = res?.msgTxt || [];
+    const nitrogenBlock = msgTxt.find((item) => item.type === 'nitrogen_auto');
+    return nitrogenBlock?.datalist?.[0]?.readData || {};
+  }
+
+  function toRows(params: UnitParam[]) {
+    const rows: UnitParam[][] = [];
+    for (let index = 0; index < params.length; index += 2) {
+      rows.push(params.slice(index, index + 2));
+    }
+    return rows;
+  }
+
+  function readValue(readData: Record<string, string>, code: string) {
+    const value = readData[code];
+    if (value === undefined || value === null || value === '') return '-';
+    return String(value);
+  }
+
+  function buildFloatCards(readData: Record<string, string>) {
+    return [1, 2, 3].map((unitNo) => ({
+      id: `float-jz${unitNo}`,
+      domId: `nitrogenMonitor${unitNo}`,
+      title: `${unitNo}号制氮机`,
+      params: modelFloatFields.map((field) => ({
+        code: `jz${unitNo}_${field.code}`,
+        label: field.label,
+        value: readValue(readData, `jz${unitNo}_${field.code}`),
+        unit: field.unit,
+      })),
+    }));
+  }
+
+  function buildNitrogenCards(readData: Record<string, string>) {
+    const unitNos = new Set<number>();
+    Object.keys(readData).forEach((key) => {
+      const matched = key.match(/^jz(\d+)_/);
+      if (matched) unitNos.add(Number(matched[1]));
+    });
+    return Array.from(unitNos)
+      .sort((prev, next) => prev - next)
+      .map((unitNo) => {
+        const params = nitrogenFields
+          .filter((field) => !modelFloatCodes.includes(field.code))
+          .map((field) => ({
+          code: `jz${unitNo}_${field.code}`,
+          label: field.label,
+          value: readValue(readData, `jz${unitNo}_${field.code}`),
+          unit: field.unit,
+        }));
+        return {
+          id: `jz${unitNo}`,
+          title: `${unitNo}号制氮机`,
+          rows: toRows(params),
+        };
+      });
+  }
+
+  function buildCompressorCards(readData: Record<string, string>) {
+    const unitNos = new Set<number>();
+    Object.keys(readData).forEach((key) => {
+      const matched = key.match(/^kyj_(\d+)h_/);
+      if (matched) unitNos.add(Number(matched[1]));
+    });
+    return Array.from(unitNos)
+      .sort((prev, next) => prev - next)
+      .map((unitNo) => {
+        const params = compressorFields.map((field) => ({
+          code: `kyj_${unitNo}h_${field.code}`,
+          label: field.label,
+          value: readValue(readData, `kyj_${unitNo}h_${field.code}`),
+          unit: field.unit,
+        }));
+        return {
+          id: `kyj${unitNo}`,
+          title: `${unitNo}号空压机`,
+          rows: toRows(params),
+        };
+      });
+  }
+
+  function formatClock(date: Date) {
+    const pad = (num: number) => String(num).padStart(2, '0');
+    return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
+  }
+
+  function appendFlowPoint(readData: Record<string, string>) {
+    const flowList = [1, 2, 3]
+      .map((unitNo) => Number(readData[`jz${unitNo}_ssll`]))
+      .filter((value) => !Number.isNaN(value));
+    if (!flowList.length) return;
+    const flowRate = Number(flowList.reduce((sum, value) => sum + value, 0).toFixed(2));
+    const readTime = formatClock(new Date());
+    const points = flowChartData.value.slice();
+    const lastPoint = points[points.length - 1];
+    if (lastPoint && lastPoint.readTime === readTime) {
+      points[points.length - 1] = { readTime, flowRate };
+      flowChartData.value = points;
+      return;
+    }
+    flowChartData.value = points.concat({ readTime, flowRate }).slice(-flowPointLimit);
+  }
+
+  async function buildUnitCards() {
+    const readData = await getReadData();
+    floatCards.value = buildFloatCards(readData);
+    if (props.moduleTypeName === 'KYJ') {
+      unitCards.value = buildCompressorCards(readData);
+    } else if (props.moduleTypeName === 'ZDJ') {
+      unitCards.value = buildNitrogenCards(readData);
+    } else {
+      unitCards.value = [];
+    }
+    return readData;
+  }
+
+  async function getDataSource() {
+    const readData = await buildUnitCards();
+    if (props.moduleTypeName !== 'KYJ') {
+      appendFlowPoint(readData);
+    }
+  }
+
+  watch(
+    () => props.moduleTypeName,
+    () => {
+      buildUnitCards();
+    },
+    { immediate: true }
+  );
+
+  onMounted(async () => {
+    await getMonitor(true);
+    await mountedThree('nitrogen_bjhz').then(() => {
+      loading.value = false;
+    });
+    await nextTick();
+    attachMonitorCards();
+  });
+
+  onUnmounted(() => {
+    destroy();
+    if (timer) {
+      clearTimeout(timer);
+      timer = undefined;
+    }
+  });
+</script>
+
+<style lang="less" scoped>
+  #nitrogenCss3D {
+    .model-float-card {
+      :deep(.win) {
+        margin: 0;
+      }
+    }
+    .modal-monitor {
+      width: 200px;
+      position: absolute;
+      left: 0px;
+      top: 0px;
+    }
+    &:deep(.win) {
+      margin: 0 !important;
+      background: #00000044;
+    }
+  }
+  .nitrogen-home {
+    width: 100%;
+    height: 100%;
+    position: fixed;
+    z-index: 9999;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    pointer-events: none;
+    top: 60px;
+    .nitrogen-container {
+      width: 100%;
+      height: calc(100% - 100px);
+      display: flex;
+      justify-content: space-between;
+      margin-bottom: 100px;
+      .top-box {
+        width: 100%;
+        padding: 10px;
+        overflow: hidden;
+        display: flex;
+        justify-content: space-between;
+
+        .lr-box {
+          height: fit-content;
+          display: flex;
+          flex-direction: column;
+          position: relative;
+          overflow: hidden;
+          z-index: 9999;
+          pointer-events: auto;
+        }
+
+        .item {
+          width: 335px;
+          height: auto;
+          position: relative;
+          border-radius: 5px;
+          margin-top: 10px;
+          margin-bottom: 0px;
+          pointer-events: auto;
+          color: #fff;
+          overflow: hidden;
+          &:first-child {
+            margin-top: 0px;
+          }
+
+          .state-item {
+            display: grid;
+            grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+            column-gap: 8px;
+            padding: 3px 2px;
+
+            .item-col {
+              width: auto;
+              min-width: 0;
+              min-height: 28px;
+              display: flex;
+              justify-content: space-between;
+              align-items: center;
+              gap: 8px;
+              margin-right: 0;
+              padding: 3px 6px;
+              background-image: linear-gradient(to right, #39a3ff14, #39a3ff05);
+
+              .state-title {
+                color: #ffffffcc;
+                flex: 1;
+                min-width: 0;
+                font-size: 12px;
+                line-height: 1.35;
+              }
+
+              .state-val {
+                flex: none;
+                color: #00eefffe;
+                text-align: right;
+                font-size: 13px;
+                line-height: 1.35;
+                white-space: nowrap;
+              }
+            }
+          }
+        }
+
+        .item-l {
+          width: 100%;
+          .monitor-box {
+            width: 100%;
+            .parameter-title {
+              position: relative;
+              width: 100%;
+              height: 14px;
+              margin-top: 10px;
+              .icon,
+              span {
+                position: absolute;
+                top: -10px;
+              }
+            }
+            .group-parameter-title {
+              background-image: linear-gradient(to right, #39a3ff50, #39a3ff00);
+
+              .icon {
+                left: -12px;
+                top: -17px;
+              }
+              span {
+                left: 18px;
+              }
+              .item-col {
+                background-image: linear-gradient(to right, #39a3ff00, #39a3ff10);
+              }
+            }
+          }
+        }
+
+        .right-box {
+          width: 330px;
+          margin-top: 0;
+        }
+
+        .left-box {
+          width: 460px;
+          margin-top: 80px;
+          max-height: calc(100vh - 250px);
+          overflow-x: hidden;
+          overflow-y: auto;
+          padding-right: 6px;
+          padding-bottom: 12px;
+
+          .item {
+            overflow: visible;
+          }
+
+          :deep(.vent-box1-bg) {
+            overflow: visible;
+          }
+
+          :deep(.box1-top),
+          :deep(.box1-bottom) {
+            background-size: 100% 100%;
+            background-repeat: no-repeat;
+          }
+
+          :deep(.box1-bottom) {
+            position: relative;
+            bottom: auto;
+            flex-shrink: 0;
+          }
+
+          :deep(.box-container) {
+            padding-bottom: 10px;
+          }
+        }
+
+        .left-box.left-box-kyj,
+        .left-box.left-box-zdj {
+          gap: 16px;
+
+          .item {
+            margin-top: 0;
+            margin-bottom: 0;
+          }
+        }
+
+        .left-box.left-box-kyj {
+          margin-top: 0;
+          max-height: 816px;
+          padding-bottom: 8px;
+        }
+
+        .left-box.left-box-zdj {
+          gap: 8px;
+          margin-top: 0;
+          max-height: none;
+          overflow: visible;
+          padding-right: 0;
+          padding-bottom: 8px;
+
+          :deep(.box-container) {
+            padding: 10px 10px 0;
+          }
+
+          .parameter-title {
+            height: 16px;
+            margin-top: 8px;
+          }
+
+          .parameter-scroll {
+            max-height: none;
+            overflow: visible;
+          }
+
+          .state-item {
+            padding: 3px 0;
+
+            .item-col {
+              min-height: 26px;
+              padding: 2px 6px;
+            }
+          }
+        }
+      }
+      &:deep(.win) {
+        width: 100%;
+        margin: 0 !important;
+      }
+    }
+  }
+
+  &:deep(.main) {
+    .title {
+      height: 34px;
+      text-align: center;
+      font-weight: 600;
+      color: #7af5ff;
+      // background-image: url('../../../assets/img/yfj/light.png');
+      background-repeat: no-repeat;
+      background-position-x: center;
+      background-position-y: 100%;
+      background-size: 80%;
+      font-size: 16px;
+    }
+    .monitor-item {
+      width: 200px;
+      display: flex;
+      flex-direction: row;
+      width: auto;
+      margin-bottom: 3px;
+
+        .monitor-val {
+        color: #ffb700;
+        display: flex;
+        width: auto;
+
+        .val {
+          width: 80px;
+          font-size: 14px;
+        }
+      }
+    }
+    .monitor-title {
+      width: 132px;
+      color: #7af5ff;
+      font-weight: 400;
+      font-size: 14px;
+    }
+  }
+</style>

+ 36 - 45
src/views/vent/monitorManager/compressor/components/nitrogenHome_bet.vue

@@ -376,52 +376,43 @@
 
   async function getDataSource(systemID, flag = false) {
     const res = await getDevice({ devicetype: 'sys', systemID, type: 'all' });
-    if (res) {
-      const result = res;
-      if (!result || result.msgTxt.length < 1) return;
-      result.msgTxt.forEach((item) => {
-        if (item.type && item.type.startsWith('nitrogen')) {
-          airCompressorState.length = 0;
-          let netStatus = 0;
-          monitorData.value = item['datalist'].filter((data) => {
-            const readData = data.readData;
-            if (data['netStatus'] == 1) {
-              netStatus = 1;
-            }
-            airCompressorState.push({
-              id: data.deviceID,
-              deviceType: data.deviceType,
-              HMIReset: readData.HMIReset,
-              HMIStartStop: readData.HMIStartStop,
-            });
-            return Object.assign(data, readData);
-          });
-          monitorNetStatus.value = netStatus;
-          // console.log(monitorData,'monitorData.value---===')
-          const airCompressor = {
-            readTime: monitorData.value[0]['readTime'] ? monitorData.value[0]['readTime'].substring(11) : dayjs().format('HH:mm:ss'),
-          };
-          const dataArr = cloneDeep(echartData.value);
-          //图表数据
-          if (dataArr.length <= 5) {
-            monitorData.value.forEach((el, index) => {
-              airCompressor[`InputFlux${index + 1}`] = el['InputFlux'] || 0;
-              airCompressor['InputFlux'] = el['InputFlux'] || 0;
-            });
-            dataArr.push(airCompressor);
-          } else {
-            dataArr.shift();
-            dataArr.push(airCompressor);
-          }
-          echartData.value = dataArr;
-        }
-      });
-      monitorDataGroupNum.value = monitorData.value.length;
-      refresh.value = true;
-      nextTick(() => {
-        refresh.value = false;
+    if (!res) return;
+
+    // 数据、机数、网络状态、控制状态一次提交:
+    // 没绑注氮设备的场景一并归零(不残留上一个场景的机器),也不会出现「机数更新了、数据没更新」的脱节
+    const nitrogenItem = (res.msgTxt || []).find((item) => item?.type?.startsWith('nitrogen'));
+    monitorData.value = (nitrogenItem?.['datalist'] || []).map((data) => Object.assign(data, data.readData || {}));
+    monitorDataGroupNum.value = monitorData.value.length;
+    monitorNetStatus.value = monitorData.value.some((data) => data['netStatus'] == 1) ? 1 : 0;
+
+    airCompressorState.length = 0;
+    monitorData.value.forEach((data) => {
+      airCompressorState.push({ id: data.deviceID, deviceType: data.deviceType, HMIReset: data.HMIReset, HMIStartStop: data.HMIStartStop });
+    });
+
+    // 图表数据:没有机数时不追加点(避免空场景把曲线拉平)
+    if (monitorData.value.length) {
+      const airCompressor = {
+        readTime: monitorData.value[0]?.['readTime'] ? String(monitorData.value[0]['readTime']).substring(11) : dayjs().format('HH:mm:ss'),
+      };
+      monitorData.value.forEach((el, index) => {
+        airCompressor[`InputFlux${index + 1}`] = el['InputFlux'] || 0;
+        airCompressor['InputFlux'] = el['InputFlux'] || 0;
       });
+      const dataArr = cloneDeep(echartData.value);
+      if (dataArr.length <= 5) {
+        dataArr.push(airCompressor);
+      } else {
+        dataArr.shift();
+        dataArr.push(airCompressor);
+      }
+      echartData.value = dataArr;
     }
+
+    refresh.value = true;
+    nextTick(() => {
+      refresh.value = false;
+    });
   }
 
   function handlerDevice(data, bol) {
@@ -511,7 +502,7 @@
     await mountedThree().then(() => {
       loading.value = false;
     });
-    await getCamera(deviceId, playerRef, renderPlayer);
+    await getCamera(props.deviceId, playerRef, renderPlayer);
   });
 
   onUnmounted(() => {

+ 2 - 4
src/views/vent/monitorManager/compressor/nitrogen.data.ts

@@ -251,10 +251,8 @@ export function getMonitorComponent() {
       nitrogenHome = defineAsyncComponent(() => import('./components/nitrogenHome_dtyj.vue'));
       return nitrogenHome;
     default:
-      // nitrogenHome = defineAsyncComponent(() => import('./components/nitrogenHome_blt.vue'));
-      // nitrogenHome = defineAsyncComponent(() => import('./components/nitrogenHome_bet.vue'));
-      nitrogenHome = defineAsyncComponent(() => import('./components/nitrogenHome.vue'));
-      // nitrogenHome = defineAsyncComponent(() => import('./components/nitrogenHome_ln.vue'));
+      //nitrogenHome = defineAsyncComponent(() => import('./components/nitrogenHome.vue'));
+      nitrogenHome = defineAsyncComponent(() => import('./components/nitrogenHomeNew.vue'));
       return nitrogenHome;
   }
 }

+ 28 - 19
src/views/vent/monitorManager/compressor/nitrogen.dishang.threejs.ts

@@ -126,31 +126,40 @@ class NitrogenOverground {
     if (this.nitrogenNum > 0) {
       for (let i = 0; i < this.nitrogenNum; i++) {
         const nitrogenModal = this.group.getObjectByName('nitrogenModal' + i) as THREE.Object3D;
-        if (nitrogenModal && !nitrogenModal.getObjectByName('monitorNitrogenText')) {
-          const element = document.getElementById('nitrogenMonitor' + (i + 1)) as HTMLElement;
-          if (element) {
-            const nitrogenMonitorCSS3D = new CSS3DSprite(element);
-            nitrogenMonitorCSS3D.name = 'monitorNitrogenText' + i;
-            nitrogenMonitorCSS3D.scale.set(0.003, 0.003, 0.003);
-            if (i == 0) nitrogenMonitorCSS3D.position.set(-0.89, 0.31, 0);
-            if (i == 1) nitrogenMonitorCSS3D.position.set(-0.89, 0.31, 0.04);
-            if (i == 2) nitrogenMonitorCSS3D.position.set(-0.89, 0.31, 0.08);
-            if (i == 3) nitrogenMonitorCSS3D.position.set(-0.89, 0.31, 0.12);
-            nitrogenModal.add(nitrogenMonitorCSS3D);
-          }
+        if (!nitrogenModal) continue;
+        const name = 'monitorNitrogenText' + i;
+        const element = document.getElementById('nitrogenMonitor' + (i + 1)) as HTMLElement;
+        let sprite = nitrogenModal.getObjectByName(name) as CSS3DSprite | undefined;
+        // 已挂过就复用(原守卫的名字少了序号,导致每次 addCssText 都重复挂一批精灵);
+        // 节点被 Vue 重建过则换成挂到当前节点,旧节点已是孤儿可安全移除
+        if (sprite && element && sprite.element !== element) {
+          sprite.removeFromParent();
+          sprite = undefined;
         }
+        if (sprite) {
+          sprite.visible = true;
+          continue;
+        }
+        if (!element) continue;
+        sprite = new CSS3DSprite(element);
+        sprite.name = name;
+        sprite.scale.set(0.003, 0.003, 0.003);
+        if (i == 0) sprite.position.set(-0.89, 0.31, 0);
+        if (i == 1) sprite.position.set(-0.89, 0.31, 0.04);
+        if (i == 2) sprite.position.set(-0.89, 0.31, 0.08);
+        if (i == 3) sprite.position.set(-0.89, 0.31, 0.12);
+        nitrogenModal.add(sprite);
       }
     }
   };
 
   clearCssText = () => {
-    if (this.nitrogenNum > 0) {
-      for (let i = 0; i < this.nitrogenNum; i++) {
-        const nitrogenModal = this.group.getObjectByName('nitrogenModal' + i) as THREE.Object3D;
-        if (nitrogenModal && nitrogenModal.getObjectByName('monitorNitrogenText')) {
-          nitrogenModal.remove(nitrogenModal.getObjectByName('monitorNitrogenText') as THREE.Object3D);
-        }
-      }
+    // 只隐藏、不移除:CSS3DObject 被移除时会把它手里的 DOM 元素一并从文档删掉,之后再 addCssText 就取不到元素
+    if (this.nitrogenNum <= 0) return;
+    for (let i = 0; i < this.nitrogenNum; i++) {
+      const nitrogenModal = this.group.getObjectByName('nitrogenModal' + i) as THREE.Object3D;
+      const sprite = nitrogenModal?.getObjectByName('monitorNitrogenText' + i);
+      if (sprite) (sprite as THREE.Object3D).visible = false;
     }
   };
 

+ 216 - 32
src/views/vent/monitorManager/compressor/nitrogen.dixia.threejs.ts

@@ -1,6 +1,6 @@
 import * as THREE from 'three';
 
-import { getTextCanvas } from '/@/utils/threejs/util';
+import { getTextCanvas, setModalCenter } from '/@/utils/threejs/util';
 import { CSS3DSprite } from 'three/examples/jsm/renderers/CSS3DRenderer.js';
 // import * as dat from 'dat.gui';
 // const gui = new dat.GUI();
@@ -8,19 +8,37 @@ import { CSS3DSprite } from 'three/examples/jsm/renderers/CSS3DRenderer.js';
 
 class NitrogenUnderground {
   model;
+  /** 场景中的视图标识,切换逻辑依赖该名称 */
   modelName = 'nitrogenUnderground';
+  /** 实际加载的 gltf 名称,默认与原地下模型一致 */
+  loadName = 'nitrogenUnderground';
   group: THREE.Object3D = new THREE.Object3D();
   animationTimer;
   isLRAnimation = true;
   direction = 1;
   nitrogenNum = 0;
 
-  constructor(model) {
+  constructor(model, loadName = 'nitrogenUnderground') {
     this.model = model;
+    this.loadName = loadName || 'nitrogenUnderground';
     this.group.name = this.modelName;
   }
 
   addLight() {
+    if (this.loadName !== 'nitrogenUnderground') {
+      const box = new THREE.Box3().setFromObject(this.group);
+      const size = box.getSize(new THREE.Vector3());
+      const center = box.getCenter(new THREE.Vector3());
+      const maxDim = Math.max(size.x, size.y, size.z) || 1;
+      const ambient = new THREE.AmbientLight(0xffffff, 0.85);
+      this.group.add(ambient);
+      const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
+      directionalLight.position.set(center.x + maxDim, center.y + maxDim * 1.2, center.z + maxDim);
+      directionalLight.target.position.copy(center);
+      this.group.add(directionalLight.target);
+      this.group.add(directionalLight);
+      return;
+    }
     const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
     directionalLight.position.set(64, 215, 500);
     directionalLight.target = this.group;
@@ -44,63 +62,229 @@ class NitrogenUnderground {
 
   // 设置模型位置
   setModalPosition() {
+    if (this.loadName !== 'nitrogenUnderground') {
+      this.group.scale.set(1, 1, 1);
+      setModalCenter(this.group);
+      return;
+    }
     this.group?.scale.set(22, 22, 22);
     this.group?.position.set(-10, 25, 20);
   }
 
+  /**
+   * 替换模型时按包围盒取景;原地下模型返回 null,继续使用写死机位。
+   */
+  getCameraConfig() {
+    if (this.loadName === 'nitrogenUnderground' || !this.group) return null;
+    const box = new THREE.Box3().setFromObject(this.group);
+    if (box.isEmpty()) return null;
+    const sphere = box.getBoundingSphere(new THREE.Sphere());
+    const center = sphere.center;
+    const radius = sphere.radius || 1;
+    const size = box.getSize(new THREE.Vector3());
+    const cam = this.model?.camera;
+    const vFov = (((cam?.fov as number) ?? 50) * Math.PI) / 180;
+    const aspect = (cam?.aspect as number) || 1.8;
+    const hFov = 2 * Math.atan(Math.tan(vFov / 2) * aspect);
+    const fill = 0.62;
+    const dByWidth = size.x / 2 / Math.tan(hFov / 2) / fill;
+    const dByHeight = size.y / 2 / Math.tan(vFov / 2) / fill;
+    const dFloor = (radius / Math.sin(Math.min(vFov, hFov) / 2)) * 0.9;
+    const distance = Math.max(dByWidth, dByHeight, dFloor, 1);
+    // 前左上方斜视,蓝色机组在画面左侧、黄色设备在右侧
+    const dir = new THREE.Vector3(-0.95, 0.82, 1.05).normalize();
+    const pos = center.clone().add(dir.clone().multiplyScalar(distance));
+    const lookDir = dir.clone().negate();
+    const screenUp = new THREE.Vector3(0, 1, 0).sub(lookDir.clone().multiplyScalar(lookDir.y)).normalize();
+    const target = center.clone().add(screenUp.multiplyScalar(-Math.max(size.x, size.z, size.y) * 0.13));
+    return {
+      camera: { x: pos.x, y: pos.y, z: pos.z },
+      target: { x: target.x, y: target.y, z: target.z },
+    };
+  }
+
   resetModel() {
     clearTimeout(this.animationTimer);
   }
 
+  /**
+   * 北京华正模型左侧三台蓝色制氮机。
+   * 网格很碎,不能按单块高度过滤,否则锚点为空、卡片不会挂上。
+   * 从画面右侧到左侧依次是 1 号、2 号、3 号,卡片锚在三台机体中心上方。
+   */
+  private findNitrogenMachineAnchors() {
+    this.group.updateMatrixWorld(true);
+    const groupBox = new THREE.Box3().setFromObject(this.group);
+    const groupSize = groupBox.getSize(new THREE.Vector3());
+    const maxDim = Math.max(groupSize.x, groupSize.y, groupSize.z) || 1;
+    const pieces: { center: THREE.Vector3; maxY: number; volume: number }[] = [];
+
+    this.group.traverse((obj) => {
+      const mesh = obj as THREE.Mesh;
+      if (!mesh.isMesh) return;
+      const box = new THREE.Box3().setFromObject(mesh);
+      if (box.isEmpty()) return;
+      const size = box.getSize(new THREE.Vector3());
+      const isFloor = size.y < groupSize.y * 0.06 && Math.max(size.x, size.z) > maxDim * 0.35;
+      const isTiny = Math.max(size.x, size.y, size.z) < maxDim * 0.02;
+      if (isFloor || isTiny) return;
+      pieces.push({
+        center: box.getCenter(new THREE.Vector3()),
+        maxY: box.max.y,
+        volume: size.x * size.y * size.z,
+      });
+    });
+
+    const threshold = maxDim * 0.1;
+    const clusters: { center: THREE.Vector3; sum: THREE.Vector3; maxY: number; volume: number; count: number }[] = [];
+    pieces.forEach((piece) => {
+      const cluster = clusters.find((current) => current.center.distanceTo(piece.center) < threshold);
+      if (!cluster) {
+        clusters.push({
+          center: piece.center.clone(),
+          sum: piece.center.clone(),
+          maxY: piece.maxY,
+          volume: piece.volume,
+          count: 1,
+        });
+        return;
+      }
+      cluster.sum.add(piece.center);
+      cluster.count += 1;
+      cluster.center.copy(cluster.sum).multiplyScalar(1 / cluster.count);
+      cluster.volume += piece.volume;
+      cluster.maxY = Math.max(cluster.maxY, piece.maxY);
+    });
+
+    const maxVolume = Math.max(...clusters.map((cluster) => cluster.volume), 0);
+    const large = clusters.filter((cluster) => cluster.volume > maxVolume * 0.08);
+    const source = large.length >= 3 ? large : clusters;
+    return this.pickMachineTrio(source);
+  }
+
+  private pickMachineTrio(clusters: { center: THREE.Vector3; maxY: number }[]) {
+    const splitByAxis = (axis: 'x' | 'z') => {
+      if (clusters.length < 2) return null;
+      const sorted = clusters.slice().sort((prev, next) => prev.center[axis] - next.center[axis]);
+      let gapIndex = 0;
+      let gap = -1;
+      for (let index = 1; index < sorted.length; index += 1) {
+        const currentGap = sorted[index].center[axis] - sorted[index - 1].center[axis];
+        if (currentGap > gap) {
+          gap = currentGap;
+          gapIndex = index;
+        }
+      }
+      const beforeGap = sorted.slice(0, gapIndex);
+      const afterGap = sorted.slice(gapIndex);
+      if (beforeGap.length >= 3) return beforeGap.slice(-3);
+      if (afterGap.length >= 3) return afterGap.slice(0, 3);
+      if (beforeGap.length === 3) return beforeGap;
+      if (afterGap.length === 3) return afterGap;
+      return null;
+    };
+
+    const trio = splitByAxis('x') || splitByAxis('z') || clusters.slice(0, 3);
+    if (trio.length < 2) return trio;
+    const spanX = Math.max(...trio.map((item) => item.center.x)) - Math.min(...trio.map((item) => item.center.x));
+    const spanZ = Math.max(...trio.map((item) => item.center.z)) - Math.min(...trio.map((item) => item.center.z));
+    const axis = spanX >= spanZ ? 'x' : 'z';
+    const ordered = trio.slice().sort((prev, next) => prev.center[axis] - next.center[axis]);
+    const frontDir = new THREE.Vector3(-0.95, 0, 1.05).normalize();
+    if (ordered[ordered.length - 1].center.dot(frontDir) > ordered[0].center.dot(frontDir)) ordered.reverse();
+    return ordered;
+  }
+
+  private addBjhzMonitorCards() {
+    if (!this.group) return;
+    const anchors = this.findNitrogenMachineAnchors();
+    if (anchors.length < 3) return;
+    const groupBox = new THREE.Box3().setFromObject(this.group);
+    const groupSize = groupBox.getSize(new THREE.Vector3());
+    const maxDim = Math.max(groupSize.x, groupSize.y, groupSize.z) || 1;
+    const scale = maxDim * 0.0011;
+
+    anchors.slice(0, 3).forEach((anchor, index) => {
+      const element = document.getElementById('nitrogenMonitor' + (index + 1)) as HTMLElement;
+      let sprite = this.group.getObjectByName('monitorNitrogenText' + index) as CSS3DSprite | undefined;
+      if (!sprite) {
+        if (!element) return;
+        element.style.pointerEvents = 'none';
+        sprite = new CSS3DSprite(element);
+        sprite.name = 'monitorNitrogenText' + index;
+        this.group.add(sprite);
+      }
+      sprite.scale.set(scale, scale, scale);
+      const localTop = this.group.worldToLocal(new THREE.Vector3(anchor.center.x, anchor.maxY, anchor.center.z));
+      sprite.position.set(localTop.x, localTop.y + Math.max(groupSize.y * 0.08, maxDim * 0.02), localTop.z);
+    });
+  }
+
   addCssText = () => {
+    if (this.loadName !== 'nitrogenUnderground') {
+      this.addBjhzMonitorCards();
+      return;
+    }
     if (this.nitrogenNum > 0) {
       for (let i = 0; i < 2; i++) {
-        if (this.group && !this.group.getObjectByName('monitorNitrogenText' + i)) {
-          const element = document.getElementById('nitrogenMonitor' + (i + 1)) as HTMLElement;
-          if (element) {
-            const nitrogenMonitorCSS3D = new CSS3DSprite(element);
-            nitrogenMonitorCSS3D.name = 'monitorNitrogenText' + i;
-            nitrogenMonitorCSS3D.scale.set(0.005, 0.005, 0.005);
-            if (i == 0) nitrogenMonitorCSS3D.position.set(-0.89, 0.31, 0);
-            if (i == 1) nitrogenMonitorCSS3D.position.set(1.44, 0.31, 0.04);
-            // if (i == 2) nitrogenMonitorCSS3D.position.set(-0.89, 0.31, 0.08);
-            // if (i == 3) nitrogenMonitorCSS3D.position.set(-0.89, 0.31, 0.12);
-            this.group.add(nitrogenMonitorCSS3D);
-          }
+        const element = document.getElementById('nitrogenMonitor' + (i + 1)) as HTMLElement;
+        let sprite = this.group?.getObjectByName('monitorNitrogenText' + i) as CSS3DSprite | undefined;
+        // 节点被 Vue 重建过(机数变化时 v-for 增删)→ 换成挂到当前节点;旧节点已是孤儿,可以安全移除
+        if (sprite && element && sprite.element !== element) {
+          sprite.removeFromParent();
+          sprite = undefined;
         }
-        // 而如若此项超出了已绑定的注氮机数量,则隐藏或销毁
-        if (this.group && i >= this.nitrogenNum && this.group.getObjectByName('monitorNitrogenText' + i)) {
-          this.group.remove(this.group.getObjectByName('monitorNitrogenText' + i) as THREE.Object3D);
+        if (!sprite && element) {
+          sprite = new CSS3DSprite(element);
+          sprite.name = 'monitorNitrogenText' + i;
+          sprite.scale.set(0.005, 0.005, 0.005);
+          if (i == 0) sprite.position.set(-0.89, 0.31, 0);
+          if (i == 1) sprite.position.set(1.44, 0.31, 0.04);
+          this.group.add(sprite);
         }
+        // 超出已绑定机数的槽位置为不可见(不能销毁:CSS3DObject 一被移除会连带把 DOM 元素删掉,之后再也挂不回来)
+        if (sprite) sprite.visible = i < this.nitrogenNum;
       }
     }
-    if (this.group && !this.group.getObjectByName('downWindMonitor')) {
+    if (this.group) {
       const element = document.getElementById('downWindMonitor') as HTMLElement;
-      if (element) {
-        const downWindMonitorCSS3D = new CSS3DSprite(element);
-        downWindMonitorCSS3D.name = 'downWindMonitor';
-        downWindMonitorCSS3D.scale.set(0.005, 0.005, 0.005);
-        downWindMonitorCSS3D.position.set(-2.95, 0.57, 0);
-        this.group.add(downWindMonitorCSS3D);
+      let downWindMonitor = this.group.getObjectByName('downWindMonitor') as CSS3DSprite | undefined;
+      // 与机位框同样处理:节点被 Vue 重建过就换成挂到当前节点(旧节点是孤儿,可安全移除)
+      if (downWindMonitor && element && downWindMonitor.element !== element) {
+        downWindMonitor.removeFromParent();
+        downWindMonitor = undefined;
+      }
+      if (!downWindMonitor && element) {
+        downWindMonitor = new CSS3DSprite(element);
+        downWindMonitor.name = 'downWindMonitor';
+        downWindMonitor.scale.set(0.005, 0.005, 0.005);
+        downWindMonitor.position.set(-2.95, 0.57, 0);
+        this.group.add(downWindMonitor);
       }
+      // 无机数时不显示下风侧框(CSS3D 渲染器会把已接管对象的 display 强制置空,隐藏要用 visible)
+      if (downWindMonitor) downWindMonitor.visible = this.nitrogenNum > 0;
     }
   };
 
   clearCssText = () => {
-    if (this.nitrogenNum > 0) {
-      for (let i = 0; i < 2; i++) {
-        const mesh = this.group.getObjectByName('monitorNitrogenText' + i);
-        if (this.group && mesh) {
-          this.group.remove(mesh);
-        }
-      }
+    // 只隐藏、不移除:CSS3DObject 被移除时会把它手里的 DOM 元素一并从文档删掉,之后再 addCssText 就取不到元素
+    if (this.nitrogenNum <= 0) return;
+    for (let i = 0; i < 2; i++) {
+      const sprite = this.group.getObjectByName('monitorNitrogenText' + i);
+      if (sprite) (sprite as THREE.Object3D).visible = false;
     }
   };
 
   mountedThree() {
     return new Promise((resolve) => {
-      this.model.setGLTFModel([this.modelName]).then((gltf) => {
+      this.model.setGLTFModel([this.loadName]).then((gltf) => {
+        if (!gltf?.[0]) {
+          console.warn(`[nitrogen] 模型 ${this.loadName} 加载失败`);
+          resolve(null);
+          return;
+        }
         this.group = gltf[0];
+        this.group.name = this.modelName;
         this.setModalPosition();
         this.addLight();
         resolve(null);

+ 20 - 12
src/views/vent/monitorManager/compressor/nitrogen.flex.threejs.ts

@@ -50,6 +50,8 @@ class NitrogenFlex {
   private cameraDir = { x: 0.18, y: 0.85, z: 1.35 };
   /** 机顶气泡再往上的高度 */
   private spriteTopOffset = 0.34;
+  /** 下风侧监测框精灵(DOM 节点被重建时会重新挂载) */
+  private downWindSprite?: CSS3DSprite;
 
   constructor(model) {
     this.model = model;
@@ -224,11 +226,9 @@ class NitrogenFlex {
     this.addCssText();
   }
 
-  /** 挂载数据气泡:三台机的监测框 + 下风侧监测框 */
+  /** 挂载数据气泡:各注氮机的详情框 + 下风侧监测框 */
   addCssText = () => {
     this.unitGroups.forEach((unit, i) => {
-      if (!unit) return;
-      if (unit.getObjectByName('monitorNitrogenText')) return;
       const element = document.getElementById('nitrogenMonitor' + (i + 1)) as HTMLElement;
       if (!element) return;
 
@@ -254,10 +254,20 @@ class NitrogenFlex {
    * 这里按基底包围盒归一化锚点定位,默认放在管路的下风侧一端。
    */
   private attachDownWindMonitor() {
-    if (this.group.getObjectByName('downWindMonitor')) return;
+    // 没有注氮机时不存在下风侧监测
+    if (this.nitrogenNum <= 0) return;
     const element = document.getElementById('downWindMonitor') as HTMLElement;
     if (!element) return;
 
+    // 同一个 DOM 节点才复用:机数为 0 时模板 v-if 会把这块节点整块重建,
+    // 复用旧精灵会挂在一个已经离开页面的节点上(新节点则裸露在左上角)
+    if (this.downWindSprite && this.downWindSprite.element === element) {
+      this.downWindSprite.visible = true;
+      this.cssSprites.push(this.downWindSprite);
+      return;
+    }
+    if (this.downWindSprite) this.downWindSprite.visible = false;
+
     const base = this.group.getObjectByName(this.baseName);
     if (!base) return;
     const box = new THREE.Box3().setFromObject(base);
@@ -274,20 +284,18 @@ class NitrogenFlex {
     // 挂在 group 上而非某台机体,避免机体变换影响该框
     this.group.add(sprite);
     this.cssSprites.push(sprite);
+    this.downWindSprite = sprite;
   }
 
   clearCssText = () => {
+    // ── 只隐藏,不把 sprite 从场景图里移除 ──
+    // CSS3DObject 被移除时会把手里的 element 从 DOM 中删掉,之后再 addCssText 就永远找不到该元素
+    // (切换场景后详情框消失的根因)。气泡随旧机体一起被丢弃即可,DOM 节点始终留在页面上。
     this.cssSprites.forEach((s) => {
-      s.parent?.remove(s);
+      s.visible = false;
+      (s.element as HTMLElement).style.display = 'none';
     });
     this.cssSprites = [];
-    // 兜底清理可能残留的精灵节点
-    this.unitGroups.forEach((u) => {
-      const old = u.getObjectByName('monitorNitrogenText');
-      if (old) u.remove(old);
-    });
-    const dw = this.group.getObjectByName('downWindMonitor');
-    if (dw) this.group.remove(dw);
   };
 
   /** 与既有类保持一致的接口(本模型无需额外处理) */

+ 19 - 9
src/views/vent/monitorManager/compressor/nitrogen.threejs.ts

@@ -32,6 +32,10 @@ const mouseEvent = (event) => {
 };
 
 /* 添加监控数据 */
+export const attachMonitorCards = () => {
+  nitrogenUndergroundObj?.addCssText?.call(nitrogenUndergroundObj);
+};
+
 export const addText = () => {
   if (modalType === 'nitrogen') {
     return nitrogenObj?.addCssText?.call(nitrogenObj);
@@ -92,7 +96,6 @@ export const setModelType = (type, nitrogenNum?) => {
       model?.scene?.add(nitrogenObj.group);
       let newCameraPosition = { x: 0, y: 0, z: 0 },
         newControlsPosition = { x: 0, y: 0, z: 0 };
-      debugger;
       if (group.children.length == 5) {
         newCameraPosition = { x: 3.4434042980363104, y: 34.06459454762187, z: 106.31900957493957 };
         newControlsPosition = { x: 3.060603700582905, y: -10.818677071155214, z: 6.036159227572685 };
@@ -121,8 +124,9 @@ export const setModelType = (type, nitrogenNum?) => {
       nitrogenUndergroundObj.addCssText();
       const oldCameraPosition = { x: -1000, y: 100, z: 500 };
       const oldControlsPosition = { x: -10, y: 10, z: 10 };
-      const newCameraPosition = { x: 21.7662617168586, y: 65.76772405405443, z: 106.19314654690366 },
-        newControlsPosition = { x: -13.223797639205223, y: 13.119121404116392, z: 0.7650646324543227 };
+      const fitted = nitrogenUndergroundObj.getCameraConfig?.();
+      const newCameraPosition = fitted?.camera || { x: 21.7662617168586, y: 65.76772405405443, z: 106.19314654690366 },
+        newControlsPosition = fitted?.target || { x: -13.223797639205223, y: 13.119121404116392, z: 0.7650646324543227 };
 
       setTimeout(async () => {
         model?.scene?.add(group);
@@ -157,7 +161,10 @@ export const setModelType = (type, nitrogenNum?) => {
   });
 };
 
-export const mountedThree = () => {
+/**
+ * @param undergroundModelName 地下三维模型名称,默认 nitrogenUnderground
+ */
+export const mountedThree = (undergroundModelName = 'nitrogenUnderground') => {
   return new Promise(async (resolve) => {
     model = new UseThree('#nitrogen3D', '#nitrogenCss3D');
     model.setEnvMap('test1.hdr');
@@ -167,16 +174,19 @@ export const mountedThree = () => {
     nitrogenObj = new NitrogenOverground(model);
     await nitrogenObj.mountedThree(4);
 
-    nitrogenUndergroundObj = new NitrogenUnderground(model);
+    nitrogenUndergroundObj = new NitrogenUnderground(model, undergroundModelName);
     await nitrogenUndergroundObj.mountedThree();
 
-    // 地下管路模型(基底 + 注氮机),作为新增视图与上面两个共存
-    nitrogenFlexObj = new NitrogenFlex(model);
-    await nitrogenFlexObj.mountedThree();
-
+    // 先展示地下主模型,避免柔性模型资源缺失/缓存异常阻塞整个监控界面
     setModelType('nitrogenUnderground');
     resolve(null);
     model.animate();
+
+    // 地下管路模型(基底 + 注氮机)按需后台加载,失败不影响主界面
+    nitrogenFlexObj = new NitrogenFlex(model);
+    nitrogenFlexObj.mountedThree().catch((error) => {
+      console.warn('[nitrogen] 柔性模型加载失败,已跳过', error);
+    });
   });
 };
 

+ 7 - 1
src/views/vent/monitorManager/nitrogen/nitrogen.threejs.ts

@@ -119,7 +119,11 @@ export const setModelType = (modalTypeName) => {
   });
 };
 
-export const mountedThree = (monitorDataGroupArr) => {
+/**
+ * @param monitorDataGroupArr 监测数据分组
+ * @param gltfModelName 三维模型名称(对应 /model/glft/ 下的 glb),默认 compressor
+ */
+export const mountedThree = (monitorDataGroupArr, gltfModelName = 'compressor') => {
   // debugger;
   return new Promise(async (resolve) => {
     model = new UseThree('#compressor3D');
@@ -131,6 +135,8 @@ export const mountedThree = (monitorDataGroupArr) => {
     for (let i = 0; i < monitorDataGroupArr.length; i++) {
       const monitorDataGroup = monitorDataGroupArr[i];
       const compressorObj = new Nitrogen(model);
+      // 先设置 gltf 模型名再加载,加载完成后再改回分组标识名供切换使用
+      compressorObj.modelName = gltfModelName;
       await compressorObj.mountedThree(monitorDataGroup, '#compressorCss3D' + (i + 1));
       compressorObj.modelName = 'compressor' + (i + 1);
       compressorObjArr.push(compressorObj);

+ 72 - 0
src/views/ventAI/manageAssistent/api/attachment.ts

@@ -0,0 +1,72 @@
+// api/attachment.ts:会话附件历史与附件下载。
+//
+// 会话附件查询用于刷新/切换会话后的历史回显:后端按「当前用户 + 会话」返回附件元数据,
+// message_id 指向上传该附件的用户消息;下载接口需要带令牌,不能直接用 <a href>。
+import { defHttp } from '/@/utils/http/axios/index';
+import { getToken } from '/@/utils/auth/index';
+import { throwStreamHttpError } from './errors';
+
+enum Api {
+  sessions = '/ventAI/api/sessions',
+  files = '/ventAI/api/files',
+}
+
+/** GET /sessions/{session_id}/attachments 的附件项 */
+export interface SessionAttachment {
+  file_id: string;
+  /** 关联的用户消息主键;null 表示上传成功但用户消息未落库 */
+  message_id: number | null;
+  filename: string;
+  size_bytes: number;
+  /** 上传时的 Content-Type,可能为空 */
+  content_type?: string;
+  created_at: string;
+  /** 下载地址(后端返回 /api/files/...,需带令牌) */
+  download_url: string;
+}
+
+export interface SessionAttachmentsResponse {
+  session_id: string;
+  attachments: SessionAttachment[];
+}
+
+/**
+ * 查询会话附件(历史回显用)。
+ *
+ * errorMessageMode: 'none':旧环境尚未上线该接口时,不希望每次切换会话都弹一条全局红错;
+ * 失败由调用方按非阻断方式处理。
+ */
+export const getSessionAttachments = (sessionId: string): Promise<SessionAttachmentsResponse> =>
+  defHttp.get<SessionAttachmentsResponse>(
+    { url: `${Api.sessions}/${sessionId}/attachments` },
+    { isTransformResponse: false, errorMessageMode: 'none' }
+  );
+
+/**
+ * 把后端返回的下载地址归一为前端代理前缀。
+ * 文档中的 download_url 是 /api/files/{id},而基座统一通过 /ventAI 前缀访问 TfAgents。
+ */
+const normalizeFileUrl = (fileId: string, downloadUrl?: string): string => {
+  const raw = (downloadUrl || '').trim();
+  if (raw.startsWith('/api/')) return `/ventAI${raw}`;
+  if (raw) return raw;
+  return `${Api.files}/${encodeURIComponent(fileId)}`;
+};
+
+/**
+ * 带令牌拉取附件二进制。下载与预览共用;非 2xx 复用统一错误解码(404 "文件不存在" 等)。
+ */
+export const fetchAttachmentBlob = async (fileId: string, downloadUrl?: string): Promise<Blob> => {
+  const token = getToken();
+  const response = await fetch(normalizeFileUrl(fileId, downloadUrl), {
+    method: 'GET',
+    headers: {
+      // 未登录时省略该头,避免 null 被序列化成字符串 "null" 后当成非法令牌
+      ...(token ? { 'X-Access-Token': token } : {}),
+    },
+  });
+  if (!response.ok) {
+    await throwStreamHttpError(response);
+  }
+  return response.blob();
+};

+ 25 - 33
src/views/ventAI/manageAssistent/api/cad.ts

@@ -1,46 +1,52 @@
 // api/cad.ts:DWG 图纸预览所需的「服务端转 SVG」接口。
 //
 // DWG 是专有二进制格式,前端没法本地解析(对比 .dxf 走 lib/dxf.ts 本地渲染),只能把原始文件传给后端转成
-// SVG 再展示。后端给了两个接口,差别只在返回什么:
-//   dwgFileToSVG     —— 只返回生成的 svg 路径,前端自己去静态目录取;
-//   dwgFileToSVGInfo —— 直接返回 svg 正文,不依赖静态目录能否被前端访问。
+// SVG 再展示。三种取图方式,这里只留两种:
+//   1) dwgFileToSVGInfo:上传文件 + 转换,回 svg 正文。本地附件(用户刚拖进来的)用这个;
+//   2) dwgServerFileToSVGInfo:按服务端已有的附件 file_id 直接取转换结果,回 svg 正文。
+//      历史消息里的附件后端早就转过一次了,用它就不必重新上传+转换;
+//   3) dwgFileToSVG 只回生成的 svg 路径,而且那条路径是服务端绝对路径(实测 /data/...),
+//      前端取不到文件还得再调一次正文接口,等于每次都转换两遍,所以不用。
 import { defHttp } from '/@/utils/http/axios/index';
-import { useGlobSetting } from '/@/hooks/setting';
 
 // 接口地址按后端给的文档写(/Vmodel/ventanalyModel/...);实测「加 /ventanaly-model 服务前缀」和「不加」
 // 两种写法都能打到同一个 handler(见 views/vent/monitorManager/mainFanMonitor/main.api.ts 也是不加前缀的写法)。
 // 请求最终由 uploadFile 拼上网关前缀 /modelreq 发出去,dev 下由 Vite proxy 转发。
 enum Api {
-  dwgToSvg = '/Vmodel/ventanalyModel/dwgFileToSVG',
   dwgToSvgInfo = '/Vmodel/ventanalyModel/dwgFileToSVGInfo',
+  dwgServerFileToSvgInfo = '/Vmodel/ventanalyModel/dwgServerFileToSVGInfo',
 }
 
 /** 转换业务目录:后端据此存放上传的 dwg 与生成的 svg(对应返回里的 temp/dwg、/temp/svg/*.svg) */
 const DWG_BIZ = 'temp/dwg';
 
-export interface DwgToSvgResult {
-  /** 生成的 svg 相对路径,如 /temp/svg/test300K.svg */
+export interface DwgToSvgInfoResult {
+  /** svg 正文(完整 xml 字符串),预览直接用这个 */
+  svgInfo: string;
+  /** 生成的 svg 路径:后端会给,但不保证前端可达,仅作参考 */
   svgFile: string;
-  /** 上传的 dwg 相对路径,如 temp/dwg/test300K.dwg */
+  /** 上传的 dwg 相对路径 */
   dwgFile: string;
 }
 
-export interface DwgToSvgInfoResult extends DwgToSvgResult {
-  /** svg 正文(完整 xml 字符串) */
-  svgInfo: string;
-}
-
 /**
- * 上传 dwg,返回转好的 svg 路径。
+ * 上传 dwg,转换并返回 svg 正文。
  * @param file - dwg 文件(文件名会带到后端,用于命名生成物,务必传带名字的 File 而不是裸 Blob)
  */
-export const dwgToSvg = (file: File): Promise<DwgToSvgResult> => postDwg<DwgToSvgResult>(Api.dwgToSvg, file);
+export const dwgToSvgInfo = (file: File): Promise<DwgToSvgInfoResult> => postDwg<DwgToSvgInfoResult>(Api.dwgToSvgInfo, file);
 
 /**
- * 上传 dwg,返回 svg 正文。
- * @param file - dwg 文件
+ * 按服务端已有附件的 file_id 直接取 dwg 转换结果(历史消息附件专用),不再上传文件。
+ * @param fileId - 会话附件接口里的 file_id
  */
-export const dwgToSvgInfo = (file: File): Promise<DwgToSvgInfoResult> => postDwg<DwgToSvgInfoResult>(Api.dwgToSvgInfo, file);
+export const dwgServerFileToSvgInfo = (fileId: string): Promise<DwgToSvgInfoResult> =>
+  defHttp
+    .post<DwgToSvgInfoResult>(
+      // JSON body,不是 multipart:这个接口是按文件 id 查,不接收文件
+      { url: Api.dwgServerFileToSvgInfo, data: { file_id: fileId }, timeout: 120000 },
+      { isTransformResponse: false }
+    )
+    .then((res) => unwrapEnvelope<DwgToSvgInfoResult>(res));
 
 function postDwg<T>(url: string, file: File): Promise<T> {
   // 走项目自带的上传方法,**不要**写成 defHttp.post({ data: formData }):
@@ -64,7 +70,7 @@ function postDwg<T>(url: string, file: File): Promise<T> {
  *
  * 后端业务失败时返回的是 `{success:false, code:500, result:null}`:此时响应拦截器不会走 reject
  * (它是按 `data.result || data.success` 进分支的,两者都为假),而是把整个信封当普通数据返回。
- * 不在这里判一次,调用方就只能看到「返回结果里没有 svgFile」,把「转换失败」误报成「缺字段」。
+ * 不在这里判一次,调用方就只能看到「返回结果里没有 svgInfo」,把「转换失败」误报成「缺字段」。
  * 成功时拦截器已经把 result 拆出来了(普通对象,没有 success 字段),这里原样放行。
  */
 function unwrapEnvelope<T>(res: unknown): T {
@@ -75,17 +81,3 @@ function unwrapEnvelope<T>(res: unknown): T {
   }
   return envelope.result as T;
 }
-
-/**
- * 把接口返回的 svg 相对路径拼成浏览器可直接访问的地址:`<网关>/sys/common/static/<路径>`。
- *
- * 后端返回的路径带前导斜杠(示例 `/temp/svg/test300K.svg`),直接拼接会拼出 `static//temp/...`,
- * 部分网关会把它当成非法路径,这里统一去掉再拼。
- * 静态目录不校验 token,所以可以直接交给 <img> 加载(图片标签没法带 X-Access-Token 头)。
- */
-export const buildStaticFileUrl = (path: string): string => {
-  const base = useGlobSetting().domainUrl || '';
-  return `${base}/sys/common/static/${String(path || '')
-    .trim()
-    .replace(/^\/+/, '')}`;
-};

+ 9 - 0
src/views/ventAI/manageAssistent/api/errors.ts

@@ -40,6 +40,15 @@ export const throwStreamHttpError = async (response: Response): Promise<never> =
     if (body?.detail) {
       if (typeof body.detail === 'string') {
         message = body.detail;
+      } else if (Array.isArray(body.detail)) {
+        // FastAPI 参数校验错误:detail 是数组(例如 message 缺失时返回 422)。
+        // 只取第一条并拼上字段位置,避免界面上只剩「HTTP错误: 422」。
+        const first = body.detail[0] as { msg?: string; loc?: unknown[] } | undefined;
+        if (first?.msg) {
+          const loc = Array.isArray(first.loc) ? first.loc.join('.') : '';
+          const reason = first.msg === 'Field required' ? '缺少必填字段' : first.msg;
+          message = `请求参数校验失败:${loc ? `${reason}(${loc})` : reason}`;
+        }
       } else {
         detail = body.detail as ApiErrorDetail;
         message = detail.message || message;

+ 1 - 0
src/views/ventAI/manageAssistent/api/index.ts

@@ -5,6 +5,7 @@ export * from './sse';
 export * from './streamPost';
 export * from './chat';
 export * from './session';
+export * from './attachment';
 export * from './user';
 export * from './usage';
 export * from './model';

+ 2 - 1
src/views/ventAI/manageAssistent/api/streamPost.ts

@@ -44,6 +44,7 @@ export const streamPost = async <E extends SseEventLike>(
   }
 
   try {
+    const token = getToken();
     const response = await fetch(url, {
       method: 'POST',
       body: payload,
@@ -51,7 +52,7 @@ export const streamPost = async <E extends SseEventLike>(
         // 令牌是「可选」语义:getToken() 为空时不放 X-Access-Token 头(空值会被序列化成字符串 "null"),
         // 仍照常发请求(后端有兜底账号)。
         // 不要改成「未登录就拦截」——那会把未登录场景直接变成不可用。
-        ...(getToken() ? { 'X-Access-Token': getToken() } : {}),
+        ...(token ? { 'X-Access-Token': token } : {}),
         ...contentType,
       },
       signal: options.signal,

+ 311 - 73
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -65,28 +65,31 @@
             @download-word="downloadWordReport"
             @export-md="handleExportMarkdown"
             @export-word="handleExportWord"
-            @file-preview="openFilePreview"
+            @file-preview="handleAttachmentPreview"
+            @download-attachment="handleAttachmentDownload"
             @agent-detail-click="handleAgentDetailClick"
             @locate-message="handleLocateMessage"
-            @open-web="openWebTab"
+            @open-web="handleOpenWebLink"
           />
 
           <!-- 附件列表面板 -->
-          <FileListPanel :visible="showFileList" :files="getCurrentTaskFiles()" @close="showFileList = false" @file-click="openFilePreview" />
+          <FileListPanel :visible="showFileList" :files="getCurrentTaskFiles()" @close="showFileList = false" @file-click="handleAttachmentPreview" />
 
           <!-- 消息列表 -->
           <ChatMessages
             ref="chatMessagesRef"
             :messages="messages"
             :history-loading="isCurrentHistoryLoading"
-            @file-preview="openFilePreview"
+            :orphan-attachments="currentTask?.orphanAttachments || []"
+            @file-preview="handleAttachmentPreview"
+            @download-attachment="handleAttachmentDownload"
             @download-word="downloadWordFile"
             @open-file-preview="handleOpenFilePreview"
             @ask-user-submit="handleAskUserSubmit"
             @ask-user-cancel="handleAskUserCancel"
             @agent-detail-click="handleAgentDetailClick"
             @open-markdown-tab="openMarkdownPreviewTab"
-            @open-web="openWebTab"
+            @open-web="handleOpenWebLink"
             @delete-message="handleDeleteMessage"
             @regenerate="handleRegenerate"
             @edit-resend="handleEditResend"
@@ -132,24 +135,33 @@
           />
         </div>
 
-        <!-- 右侧辅助面板(标签页容器,与 chat-panel 并列,带滑入滑出动画) -->
-        <Transition name="right-panel-slide" @after-leave="onPanelLeaveEnd">
-          <RightPanelTabs
-            v-if="currentRightPanels.tabs.length > 0"
-            :tabs="currentRightPanels.tabs"
-            :activeTabId="currentRightPanels.activeTabId"
-            :messages="messages"
-            @switch-tab="activateTab"
-            @close-tab="closeTab"
-            @save-success="handleSaveSuccess"
-            @schedule-deleted="handleScheduleDeleted"
-            @schedule-updated="handleScheduleUpdated"
-            @open-schedule-session="handleOpenScheduleSession"
-            @open-markdown-tab="openMarkdownPreviewTab"
-          />
-        </Transition>
       </template>
 
+      <!--
+        右侧辅助面板(标签页容器):
+        - 始终挂载,用 v-show 控制显隐;这样关闭二维标签后 iframe 仍保留,只隐藏不销毁;
+        - 处于技能/子智能体/定时任务面板时同样只隐藏;
+        - 关闭整个助手弹框时,由 Modal 的 destroyOnClose 真正销毁。
+      -->
+      <Transition name="right-panel-slide" @after-leave="onPanelLeaveEnd">
+        <RightPanelTabs
+          v-show="activePanel === 'chat' && currentRightPanels.tabs.length > 0"
+          :tabs="currentRightPanels.tabs"
+          :activeTabId="currentRightPanels.activeTabId"
+          :messages="messages"
+          :model-preview-loaded="modelPreview.loaded"
+          :model-preview-visible="modelPreviewVisible"
+          :model-preview-route="modelPreview.route"
+          @switch-tab="activateTab"
+          @close-tab="closeTab"
+          @save-success="handleSaveSuccess"
+          @schedule-deleted="handleScheduleDeleted"
+          @schedule-updated="handleScheduleUpdated"
+          @open-schedule-session="handleOpenScheduleSession"
+          @open-markdown-tab="openMarkdownPreviewTab"
+        />
+      </Transition>
+
       <!-- 技能面板:非管理员只读(面板内部按 isAdmin 隐藏/禁用写操作) -->
       <div v-if="activePanel === 'skill'" class="right-panel full">
         <SkillPanel :is-admin="isAdmin" @close="activePanel = 'chat'" />
@@ -174,7 +186,7 @@
 </template>
 
 <script setup lang="ts">
-  import { ref, nextTick, watch, computed, onBeforeUnmount } from 'vue';
+  import { ref, nextTick, watch, computed, onBeforeUnmount, reactive } from 'vue';
   import dayjs from 'dayjs';
   // [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3):引入 getMe 用于按 is_admin 隐藏管理入口
   import { getDetail, deleteSession, deleteMessage, StreamError, getMe } from '/@/views/ventAI/manageAssistent/api';
@@ -190,8 +202,10 @@
   import SchedulePanel from '/@/views/ventAI/manageAssistent/components/panels/SchedulePanel.vue';
   import RightPanelTabs from '/@/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue';
   import { getScheduleList } from '/@/views/ventAI/manageAssistent/api';
-  import type { Message, ScheduleTask, Task } from '/@/views/ventAI/manageAssistent/types/index';
+  import type { AttachedFile, Message, ScheduleTask, SubAppRoute, Task } from '/@/views/ventAI/manageAssistent/types/index';
   import { buildCapsuleFiles, buildCapsuleLinks, buildCapsuleAgents } from '/@/views/ventAI/manageAssistent/lib/capsule';
+  import { parseModalPreviewLink } from '/@/views/ventAI/manageAssistent/lib/webLink';
+  import { locateDevice, locateDeviceReport, locateTunnel } from '/@/views/ventAI/manageAssistent/lib/modalPreviewBridge';
   import { useChatStream } from '../hooks/useChatStream';
   import { useChatTasks } from '../hooks/useChatTasks';
   import { useChatPanels } from '../hooks/useChatPanels';
@@ -203,6 +217,7 @@
   import { useFileDrop } from '../hooks/useFileDrop';
   import { useChatQuota } from '../hooks/useChatQuota';
   import { useChatExport } from '../hooks/useChatExport';
+  import { useChatAttachments } from '../hooks/useChatAttachments';
 
   interface Props {
     visible: boolean;
@@ -283,6 +298,7 @@
     openMarkdownPreviewTab,
     openFilePreview,
     openWebTab,
+    openModalPreviewTab,
     handleOpenFilePreview,
     handleSaveSuccess,
     getCurrentTaskFiles,
@@ -295,6 +311,190 @@
     defaultFullscreen: !!props.defaultFullscreen,
   });
 
+  // —— 会话附件历史:列表按会话拉取,附件内容在预览/下载时按需取回 ——
+  const { loadHistoryAttachments, handleAttachmentPreview: previewAttachment, handleAttachmentDownload } = useChatAttachments();
+  const handleAttachmentPreview = (file: AttachedFile) => previewAttachment(file, openFilePreview);
+
+  // ===== 二维模型生命周期:单活模型 + 关闭隐藏 + 空闲超时销毁 =====
+  interface ModelPreviewCache {
+    loaded: boolean;
+    ownerSessionId: string;
+    modelId: string;
+    route: SubAppRoute | null;
+  }
+
+  // 标签关闭/切换到其他面板时模型只隐藏;超过该时长未再次显示才真正销毁
+  const MODEL_PREVIEW_IDLE_MS = 5 * 60 * 1000;
+  const modelPreview = reactive<ModelPreviewCache>({
+    loaded: false,
+    ownerSessionId: '',
+    modelId: '',
+    route: null,
+  });
+  let modelPreviewIdleTimer: ReturnType<typeof setTimeout> | null = null;
+  let modelPreviewTransition: Promise<void> | null = null;
+  let pendingModelPreviewRoute: SubAppRoute | null = null;
+  let modelPreviewEpoch = 0;
+  let lastModelLocateSignature = '';
+
+  // 当前会话归属:优先用后端 session_id,空白任务用前端任务 id 兜底
+  const currentSessionKey = (): string => {
+    const task = taskList.value.find((t) => t.id === currentTaskId.value);
+    return task?.sessionId || task?.id || '';
+  };
+
+  // 只有“二维标签处于激活状态 + 聊天面板可见 + 模型已加载”才算可见
+  const modelPreviewVisible = computed(() => {
+    if (activePanel.value !== 'chat' || !modelPreview.loaded) return false;
+    const state = currentRightPanels.value;
+    const active = state.tabs.find((tab) => tab.id === state.activeTabId);
+    return active?.type === 'model2d';
+  });
+
+  const clearModelPreviewIdleTimer = () => {
+    if (modelPreviewIdleTimer !== null) {
+      clearTimeout(modelPreviewIdleTimer);
+      modelPreviewIdleTimer = null;
+    }
+  };
+
+  const destroyModelPreview = () => {
+    modelPreviewEpoch++;
+    pendingModelPreviewRoute = null;
+    clearModelPreviewIdleTimer();
+    modelPreview.loaded = false;
+    modelPreview.modelId = '';
+    modelPreview.route = null;
+    modelPreview.ownerSessionId = '';
+    lastModelLocateSignature = '';
+  };
+
+  const scheduleModelPreviewIdleDestroy = () => {
+    clearModelPreviewIdleTimer();
+    if (!modelPreview.loaded) return;
+    modelPreviewIdleTimer = setTimeout(() => {
+      modelPreviewIdleTimer = null;
+      destroyModelPreview();
+    }, MODEL_PREVIEW_IDLE_MS);
+  };
+
+  /**
+   * 同一模型下只做定位,不重载 iframe:
+   * tunid → 巷道定位,locationId → 设备定位,locationName → 报表定位。
+   */
+  const applyModelLocate = (route: SubAppRoute | null) => {
+    const query = route?.query || {};
+    const modelId = query.model_id || modelPreview.modelId;
+    if (!modelId) return;
+    const signature = [modelId, query.tunid || '', query.locationId || '', query.locationName || '', query.deviceType || ''].join('|');
+    if (signature === lastModelLocateSignature) return;
+    lastModelLocateSignature = signature;
+
+    const deviceType = query.deviceType || (query.tunid ? 'tun' : 'fanlocal');
+    if (query.tunid) {
+      locateTunnel({ model_id: modelId, tunid: query.tunid, deviceType });
+    } else if (query.locationId) {
+      locateDevice({ model_id: modelId, deviceType, locationId: query.locationId });
+    } else if (query.locationName) {
+      locateDeviceReport({ model_id: modelId, deviceType, locationName: query.locationName });
+    }
+  };
+
+  /**
+   * 确保当前加载的模型与目标路由一致:
+   * - 未加载:加载;
+   * - model_id 相同:只隐藏/显示,定位参数走 bridge,不重载;
+   * - model_id 不同:先卸载旧 iframe,下一帧再挂载新模型。
+   */
+  const ensureModelPreview = async (route: SubAppRoute | null) => {
+    if (!route) return;
+    const modelId = route.query?.model_id || '';
+
+    if (modelPreview.loaded && modelPreview.modelId === modelId) {
+      modelPreview.ownerSessionId = currentSessionKey();
+      applyModelLocate(route);
+      return;
+    }
+
+    pendingModelPreviewRoute = route;
+    if (modelPreviewTransition) return;
+
+    modelPreviewTransition = (async () => {
+      while (pendingModelPreviewRoute) {
+        const target = pendingModelPreviewRoute;
+        pendingModelPreviewRoute = null;
+        const targetModelId = target.query?.model_id || '';
+
+        if (modelPreview.loaded && modelPreview.modelId === targetModelId) {
+          modelPreview.ownerSessionId = currentSessionKey();
+          applyModelLocate(target);
+          continue;
+        }
+
+        const epoch = modelPreviewEpoch;
+        clearModelPreviewIdleTimer();
+        modelPreview.loaded = false;
+        // 先让旧 iframe 真正卸载,再挂载新模型,避免同帧复用组件导致旧模型残留
+        await nextTick();
+        if (epoch !== modelPreviewEpoch) return;
+
+        modelPreview.route = target;
+        modelPreview.modelId = targetModelId;
+        modelPreview.ownerSessionId = currentSessionKey();
+        lastModelLocateSignature = '';
+        modelPreview.loaded = true;
+      }
+    })().finally(() => {
+      modelPreviewTransition = null;
+      if (pendingModelPreviewRoute) {
+        void ensureModelPreview(pendingModelPreviewRoute);
+      }
+    });
+
+    await modelPreviewTransition;
+  };
+
+  // 会话切换、标签激活/关闭、面板切换后,按当前激活标签同步模型状态
+  const syncModelPreviewForActiveTab = () => {
+    if (!props.visible || activePanel.value !== 'chat') return;
+    const state = currentRightPanels.value;
+    const active = state.tabs.find((tab) => tab.id === state.activeTabId);
+    if (active?.type !== 'model2d') return;
+    void ensureModelPreview(active.subAppRoute || { path: '/modal-preview' });
+  };
+
+  const openModelPreview = (route: SubAppRoute, title?: string) => {
+    openModalPreviewTab({ title: title || '二维模型', subAppRoute: route });
+    void ensureModelPreview(route);
+  };
+
+  // 隐藏后开始 5 分钟空闲倒计时;重新显示立即取消
+  watch(modelPreviewVisible, (visible) => {
+    if (visible) clearModelPreviewIdleTimer();
+    else scheduleModelPreviewIdleDestroy();
+  });
+
+  watch(
+    () => [currentTaskId.value, currentRightPanels.value.activeTabId, activePanel.value] as const,
+    () => syncModelPreviewForActiveTab()
+  );
+
+  /**
+   * 聊天链接分流:链接中包含 modal-preview 时打开二维模型标签,并把链接参数传给子应用;
+   * 普通 http(s) 链接仍走右侧 Web 面板。
+   */
+  const handleOpenWebLink = (payload: { url: string; title?: string }) => {
+    const route = parseModalPreviewLink(payload.url);
+    if (route) {
+      openModelPreview(route, payload.title);
+      return;
+    }
+    openWebTab(payload);
+  };
+
+  // 二维模型标签/模型可由业务入口按需打开(宿主可通过组件 ref 调用)
+  defineExpose({ openModalPreviewTab, openModelPreview });
+
   // —— 拖拽/居中职责已下沉到 useChatDrag(与面板全屏共用 lib/modalWrap) ——
   const drag = useChatDrag({
     getIsFullscreen: () => isFullscreen.value,
@@ -380,6 +580,17 @@
     if (nextTask) switchTask(nextTask.id);
   };
 
+  // 消息被删除后,附件文件仍保留在服务端(message_id 变为 null);
+  // 这里把本地附件立即转入兜底列表,避免要刷新后才重新出现。
+  const moveAttachmentsToOrphans = (task: Task | undefined, files: AttachedFile[]) => {
+    if (!task || !files.length) return;
+    const merged = [...(task.orphanAttachments || [])];
+    for (const file of files) {
+      if (!merged.some((item) => item.id === file.id)) merged.push(file);
+    }
+    task.orphanAttachments = merged;
+  };
+
   // 删除单条消息:先删数据库行,后端再清空该会话内存线程,下轮从数据库重建上下文
   const handleDeleteMessage = (msg: Message) => {
     if (!msg.id) return;
@@ -407,6 +618,7 @@
           message.success('消息已删除');
           if (ownerTask) {
             ownerTask.messages = ownerTask.messages.filter((m) => m !== msg);
+            moveAttachmentsToOrphans(ownerTask, [msg.attachedFile, ...(msg.attachedFiles || [])].filter(Boolean) as AttachedFile[]);
           }
         } catch {
           message.error('删除失败');
@@ -566,6 +778,30 @@
     return currentTask?.sessionId || '';
   };
 
+  /**
+   * 拉取单个会话的历史消息与附件。
+   * 流式中的任务不拉历史(避免覆盖在途消息);附件加载失败不阻断消息展示。
+   */
+  const loadTaskHistory = async (task: Task) => {
+    const sessionId = task.sessionId;
+    if (!sessionId || historyLoadedTasks.has(task.id) || task.isStreaming) return;
+
+    setHistoryLoading(task.id, true);
+    try {
+      const res = await getDetail(sessionId);
+      const history = Array.isArray(res?.messages) ? transformHistoryToMessages(res.messages) : [];
+      task.messages = history;
+      await loadHistoryAttachments(task, sessionId, history);
+      historyLoadedTasks.add(task.id);
+    } catch (error) {
+      // 拉不到历史时界面会是空白会话,容易被误认为「对话丢了」,必须给出提示
+      console.error('加载历史会话失败:', error);
+      message.error('加载历史会话失败,请稍后重试');
+    } finally {
+      setHistoryLoading(task.id, false);
+    }
+  };
+
   const switchTask = async (taskId: string) => {
     activePanel.value = 'chat';
 
@@ -579,26 +815,8 @@
     showFileList.value = false;
 
     if (newTask.sessionId && !historyLoadedTasks.has(taskId)) {
-      // 流式中的任务不拉历史(避免覆盖在途消息),但必须继续走完下面的初始化:
-      // currentTaskId 已经切过来了,提前 return 会让上下文用量/模式/进度条/滚动全部停在上一个任务上。
-      if (!newTask.isStreaming) {
-        // 标记加载中:消息区据 isCurrentHistoryLoading 显示加载态,而不是先闪一屏空会话
-        setHistoryLoading(taskId, true);
-        try {
-          const res = await getDetail(newTask.sessionId);
-          const messages = res?.messages;
-          if (Array.isArray(messages)) {
-            newTask.messages = transformHistoryToMessages(messages);
-          }
-          historyLoadedTasks.add(taskId);
-        } catch (error) {
-          // 拉不到历史时界面会是空白会话,容易被误认为「对话丢了」,必须给出提示
-          console.error('加载历史会话失败:', error);
-          message.error('加载历史会话失败,请稍后重试');
-        } finally {
-          setHistoryLoading(taskId, false);
-        }
-      }
+      // 流式中的任务由 loadTaskHistory 内部跳过,但下面的上下文/模式/进度条初始化必须继续执行
+      await loadTaskHistory(newTask);
     }
 
     if (newTask.sessionId) {
@@ -670,6 +888,11 @@
           if (task.sessionId) {
             await deleteSession(task.sessionId);
           }
+          // 删除的是当前已加载模型所属的会话:直接销毁模型(而不是等空闲超时)
+          const ownerKey = task.sessionId || task.id;
+          if (modelPreview.ownerSessionId && modelPreview.ownerSessionId === ownerKey) {
+            destroyModelPreview();
+          }
           const realIndex = taskList.value.findIndex((t) => t.id === task.id);
           if (realIndex !== -1) {
             taskList.value.splice(realIndex, 1);
@@ -694,6 +917,14 @@
     const hasText = inputMessage.value.trim();
     const hasFile = pendingFile.value;
 
+    // 发送前置快照:请求失败时按这份内容回填输入区(文本 + 附件)。
+    // 必须在 createNewTask 之前取:createNewTask -> switchTask 会清空输入区。
+    // 输入框清空会触发输入区内部把 # 会话 / $ 技能引用一起清掉,这里只回填用户能直接看到的两项;
+    // 引用标签需要用户重新选择(避免父组件状态与输入区内部状态不同步)。
+    const draftText = inputMessage.value;
+    const draftFile = pendingFile.value;
+
+    // 后端 message 为必填字段,纯附件不能单独发送,必须有文字
     if (!hasText) {
       message.warning('请输入消息内容');
       return;
@@ -707,7 +938,15 @@
     // 否则 push 出去的消息会落在视图之外的临时数组里,之后再也不会显示
     if (!currentTask.value) {
       await createNewTask();
-      if (!currentTask.value) return;
+      if (!currentTask.value) {
+        // 建任务失败时也把草稿还回去,避免空白任务创建异常把用户输入吞掉
+        inputMessage.value = draftText;
+        pendingFile.value = draftFile;
+        return;
+      }
+      // createNewTask -> switchTask 会清空输入区,把刚捕获的草稿放回去再组装请求
+      inputMessage.value = draftText;
+      pendingFile.value = draftFile;
     }
 
     const sessionPrefix = selectedSessions.value.length > 0 ? selectedSessions.value.map((s) => `#session:${s.id}`).join(' ') + ' ' : '';
@@ -725,8 +964,6 @@
       displayContent = `[<img src="${iconUrl}" class="session-ref-icon" /> 引用 ${selectedSessions.value.length} 个会话:${names}] ${displayContent}`;
     }
 
-    const fileToSend = pendingFile.value;
-
     inputMessage.value = '';
     pendingFile.value = null;
     selectedSessions.value = [];
@@ -741,7 +978,7 @@
       chatMessagesRef.value?.forceScrollToBottom();
 
       if (hasFile) {
-        await sendWithAttachment(fileToSend!, userInput, displayContent);
+        await sendWithAttachment(draftFile!, userInput, displayContent);
       } else {
         await sendTextOnly(userInput, displayContent);
       }
@@ -766,6 +1003,13 @@
           // 这轮请求失败了,后端没有对应记录:标记为仅前端消息,回填主键时跳过
           localOnly: true,
         });
+
+        // 失败回填:只在用户仍停留在发起任务上时恢复,避免把旧会话的草稿塞进正在看的另一个会话。
+        // 输入框/附件已被用户重新填写时不覆盖(宁可保留用户新输入,也不要把旧内容盖上去)。
+        if (currentTask.value === originTask) {
+          if (!inputMessage.value) inputMessage.value = draftText;
+          if (!pendingFile.value) pendingFile.value = draftFile;
+        }
       }
     } finally {
       // 自动滚到底只对「用户仍停在这条任务上」有意义,否则会把正在看的别的会话拽到底部。
@@ -868,22 +1112,9 @@
 
         currentTaskId.value = targetTask.id;
 
-        // 如果是历史任务,加载其消息
+        // 如果是历史任务,加载其消息与附件
         if (targetTask.sessionId && !historyLoadedTasks.has(targetTask.id)) {
-          setHistoryLoading(targetTask.id, true);
-          try {
-            const res = await getDetail(targetTask.sessionId);
-            const messages = res?.messages;
-            if (Array.isArray(messages)) {
-              targetTask.messages = transformHistoryToMessages(messages);
-            }
-            historyLoadedTasks.add(targetTask.id);
-          } catch (error) {
-            console.error('加载历史会话失败:', error);
-            message.error('加载历史会话失败,请稍后重试');
-          } finally {
-            setHistoryLoading(targetTask.id, false);
-          }
+          await loadTaskHistory(targetTask);
         }
 
         if (targetTask.sessionId) {
@@ -905,7 +1136,12 @@
 
         await nextTick(() => {
           chatMessagesRef.value?.forceScrollToBottom();
+          // 重新打开助手时,如果当前标签就是二维模型,按需重新加载
+          syncModelPreviewForActiveTab();
         });
+      } else {
+        // 关闭整个助手弹框:模型立即销毁,而不是隐藏
+        destroyModelPreview();
       }
     },
     { immediate: true }
@@ -922,6 +1158,8 @@
       activeFileReader.value.abort();
       activeFileReader.value = null;
     }
+    // 清掉二维模型的空闲销毁定时器,避免卸载后仍回调
+    clearModelPreviewIdleTimer();
   });
 </script>
 
@@ -969,15 +1207,15 @@
     top: 16px;
     left: 24px;
     z-index: 10;
-    width: 32px;
-    height: 32px;
-    background: #1a6696;
-    border-radius: 4px;
     display: flex;
     align-items: center;
     justify-content: center;
+    width: 32px;
+    height: 32px;
+    background: #1a6696;
     cursor: pointer;
     transition: background 0.3s;
+    border-radius: 4px;
 
     &:hover {
       background: #1f7ab8;
@@ -1013,34 +1251,34 @@
   }
 
   .chat-panel {
-    flex: 1;
-    display: flex;
     position: relative;
+    display: flex;
+    flex: 1;
     flex-direction: column;
+    overflow: hidden;
     background-image: var(--img-chat-panel-bg);
     background-repeat: no-repeat;
     background-size: 100% 100%;
     border-radius: 4px;
-    overflow: hidden;
     min-width: 0;
   }
 
   .right-panel {
-    width: calc(50% - 140px);
-    height: 100%;
     display: flex;
     flex-direction: column;
+    width: calc(50% - 140px);
+    height: 100%;
     background-image: var(--img-chat-panel-bg);
     background-repeat: no-repeat;
     background-size: 100% 100%;
     border-radius: 4px;
+    flex-shrink: 0;
 
     &.full {
       flex: 1;
       width: auto;
       min-width: 0;
     }
-    flex-shrink: 0;
   }
 
   /* 右侧面板滑入滑出过渡动画 */
@@ -1085,5 +1323,5 @@
 </style>
 
 <style lang="less">
-  @import '../styles/modal.less';
+  @import url('../styles/modal.less');
 </style>

+ 2 - 0
src/views/ventAI/manageAssistent/components/chat/ChatHeader.vue

@@ -40,6 +40,7 @@
       :links="capsuleLinks"
       :agents="capsuleAgents"
       @file-preview="emit('file-preview', $event)"
+      @download-attachment="emit('download-attachment', $event)"
       @agent-detail-click="emit('agent-detail-click', $event)"
       @locate-message="emit('locate-message', $event)"
       @open-web="emit('open-web', $event)"
@@ -99,6 +100,7 @@
     'export-word': [];
     // 胶囊面板中点击上传附件,交给父组件打开右侧预览
     'file-preview': [file: AttachedFile];
+    'download-attachment': [file: AttachedFile];
     // 胶囊面板中点击智能体条目,交给父组件打开右侧智能体详情
     'agent-detail-click': [data: { messageIndex: number; agent: string; agentId?: string }];
     // 胶囊面板中点击"定位到消息",交给父组件滚动消息列表

+ 157 - 53
src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue

@@ -15,8 +15,8 @@
       <div v-if="messages.length === 0 && historyLoading" class="history-loading">
         <a-spin size="large" />
       </div>
-      <!-- 新对话缺省图 -->
-      <div v-else-if="messages.length === 0" class="welcome-empty">
+      <!-- 新对话缺省图:有兜底附件时不显示,避免附件被居中的欢迎图盖住 -->
+      <div v-else-if="messages.length === 0 && !orphanAttachments?.length" class="welcome-empty">
         <img :src="getCssUrl('--img-chat-welcome-img')" class="welcome-img" />
       </div>
       <div
@@ -127,12 +127,24 @@
                 v-html="renderMarkdownCached(message.content, tableIcons)"
                 @click="handleMessageTextClick($event, index)"
               ></div>
-              <div v-if="message.attachedFile" class="attached-file-card" @click="emit('file-preview', message.attachedFile)">
-                <div class="file-icon-card" :style="{ backgroundImage: getFileIconVar(message.attachedFile.name) }"></div>
+              <div
+                v-for="file in getMessageAttachments(message)"
+                :key="file.id"
+                class="attached-file-card"
+                @click="emit('file-preview', file)"
+              >
+                <div class="file-icon-card" :style="{ backgroundImage: getFileIconVar(file.name) }"></div>
                 <div class="file-info-card">
-                  <div class="file-name-card">{{ message.attachedFile.name }}</div>
-                  <div class="file-size-card">{{ formatFileSize(message.attachedFile.size) }}</div>
+                  <div class="file-name-card">{{ file.name }}</div>
+                  <div class="file-size-card">{{ formatFileSize(file.size) }}</div>
                 </div>
+                <button
+                  type="button"
+                  class="file-download-btn"
+                  title="下载附件"
+                  aria-label="下载附件"
+                  @click.stop="emit('download-attachment', file)"
+                ></button>
               </div>
             </template>
           </div>
@@ -147,6 +159,30 @@
           />
         </div>
       </div>
+
+      <!-- 无法按 message_id 挂到具体消息的历史附件:兜底展示 -->
+      <div v-if="orphanAttachments?.length" class="orphan-attachments">
+        <div class="orphan-attachments-title">其他附件 <span>{{ orphanAttachments.length }}</span></div>
+        <div
+          v-for="file in orphanAttachments"
+          :key="file.id"
+          class="attached-file-card orphan-file-card"
+          @click="emit('file-preview', file)"
+        >
+          <div class="file-icon-card" :style="{ backgroundImage: getFileIconVar(file.name) }"></div>
+          <div class="file-info-card">
+            <div class="file-name-card">{{ file.name }}</div>
+            <div class="file-size-card">{{ formatFileSize(file.size) }}</div>
+          </div>
+          <button
+            type="button"
+            class="file-download-btn"
+            title="下载附件"
+            aria-label="下载附件"
+            @click.stop="emit('download-attachment', file)"
+          ></button>
+        </div>
+      </div>
     </div>
 
     <!-- 回到底部按钮 -->
@@ -189,10 +225,13 @@
     messages: Message[];
     /** 当前任务的历史是否正在拉取:为真且暂无消息时显示加载态,而不是空会话缺省图 */
     historyLoading?: boolean;
+    /** 历史附件中无法按 message_id 挂到具体消息的条目,在消息区底部兜底展示 */
+    orphanAttachments?: AttachedFile[];
   }>();
 
   const emit = defineEmits<{
     (e: 'file-preview', file: AttachedFile): void;
+    (e: 'download-attachment', file: AttachedFile): void;
     (e: 'download-word', url: string): void;
     (e: 'open-file-preview', file: AttachedFile): void;
     (e: 'ask-user-submit', answers: string[]): void;
@@ -209,6 +248,16 @@
   const editingKey = ref('');
   const editDraft = ref('');
 
+  // 用户消息的附件:本地刚发送的在 attachedFile,历史回显的在 attachedFiles;去重后统一渲染
+  const getMessageAttachments = (message: Message): AttachedFile[] => {
+    const files: AttachedFile[] = [];
+    if (message.attachedFile) files.push(message.attachedFile);
+    for (const file of message.attachedFiles || []) {
+      if (!files.some((f) => f.id === file.id)) files.push(file);
+    }
+    return files;
+  };
+
   // ===== 多回答折叠:重新生成产生的连续多条 AI 消息在视觉上合并为一条,可通过 prev/next 切换查看 =====
   interface AnswerGroup {
     indices: number[];
@@ -1092,53 +1141,6 @@
             }
           }
 
-          .attached-file-card {
-            margin-top: 10px;
-            background: rgba(255, 255, 255, 0.08);
-            border-radius: 6px;
-            display: flex;
-            align-items: center;
-            gap: 10px;
-            padding: 8px;
-            cursor: pointer;
-            transition: all 0.3s;
-
-            &:hover {
-              background: rgba(255, 255, 255, 0.15);
-            }
-
-            .file-icon-card {
-              width: 32px;
-              height: 32px;
-              background-image: var(--img-chat-attach-icon);
-              background-repeat: no-repeat;
-              background-size: 100% 100%;
-              flex-shrink: 0;
-            }
-
-            .file-info-card {
-              flex: 1;
-              min-width: 0;
-
-              .file-name-card {
-                font-size: 12px;
-                font-weight: 400;
-                line-height: 1.4;
-                color: #e0e6ed;
-                overflow: hidden;
-                text-overflow: ellipsis;
-                white-space: nowrap;
-                margin-bottom: 2px;
-              }
-
-              .file-size-card {
-                font-size: 11px;
-                font-weight: 400;
-                line-height: 1.3;
-                color: #8b949e;
-              }
-            }
-          }
         }
       }
 
@@ -1223,6 +1225,108 @@
     }
   }
 
+  /* 用户消息附件卡与底部兜底附件共用 */
+  .attached-file-card {
+    margin-top: 10px;
+    background: rgba(255, 255, 255, 0.08);
+    border-radius: 6px;
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 8px;
+    cursor: pointer;
+    transition: all 0.3s;
+
+    &:hover {
+      background: rgba(255, 255, 255, 0.15);
+    }
+
+    .file-icon-card {
+      width: 32px;
+      height: 32px;
+      background-image: var(--img-chat-attach-icon);
+      background-repeat: no-repeat;
+      background-size: 100% 100%;
+      flex-shrink: 0;
+    }
+
+    .file-info-card {
+      flex: 1;
+      min-width: 0;
+
+      .file-name-card {
+        font-size: 12px;
+        font-weight: 400;
+        line-height: 1.4;
+        color: #e0e6ed;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+        margin-bottom: 2px;
+      }
+
+      .file-size-card {
+        font-size: 11px;
+        font-weight: 400;
+        line-height: 1.3;
+        color: #8b949e;
+      }
+    }
+
+    .file-download-btn {
+      flex-shrink: 0;
+      width: 26px;
+      height: 26px;
+      padding: 0;
+      border: none;
+      border-radius: 4px;
+      background-color: transparent;
+      background-image: var(--img-chat-download-icon);
+      background-repeat: no-repeat;
+      background-size: 14px 14px;
+      background-position: center;
+      opacity: 0.55;
+      cursor: pointer;
+      transition:
+        opacity 0.2s,
+        background-color 0.2s;
+
+      &:hover {
+        opacity: 1;
+        background-color: rgba(255, 255, 255, 0.12);
+      }
+
+      &:focus-visible {
+        outline: 1px solid #5cb8ff;
+        outline-offset: 1px;
+      }
+    }
+  }
+
+  /* message_id 为空或未匹配到消息的历史附件,统一在消息区底部兜底展示 */
+  .orphan-attachments {
+    margin-top: 18px;
+    padding-top: 12px;
+    border-top: 1px dashed rgba(63, 80, 106, 0.35);
+
+    .orphan-attachments-title {
+      margin-bottom: 8px;
+      font-size: 12px;
+      color: #8b949e;
+
+      span {
+        margin-left: 4px;
+        color: #6a7a8a;
+      }
+    }
+
+    .orphan-file-card {
+      margin-top: 0;
+      margin-bottom: 8px;
+      background: rgba(10, 132, 255, 0.08);
+    }
+  }
+
   .message-edit-textarea {
     width: 100%;
     min-width: 500px;

+ 2 - 1
src/views/ventAI/manageAssistent/components/chat/DownloadFiles.vue

@@ -56,7 +56,8 @@
 
   const handleEditFile = async (url: string, filename: string) => {
     try {
-      const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
+      const token = getToken();
+      const resp = await fetch(url, { headers: { ...(token ? { 'X-Access-Token': token } : {}) } });
       if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
       const content = await resp.text();
       const ext = filename.split('.').pop()?.toLowerCase() || '';

+ 28 - 1
src/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue

@@ -74,6 +74,10 @@
         @open-session="handleOpenSession"
       />
       <WebPreviewPanel v-else-if="activeTab?.type === 'web'" :url="activeTab.url" />
+      <!-- 二维模型常驻宿主:标签关闭/切换只隐藏,不卸载;model_id 变化或超时销毁时由 loaded 控制卸载 -->
+      <div v-if="modelPreviewLoaded" v-show="modelPreviewVisible" class="model-preview-host">
+        <ModalPreview :sub-app-route="modelPreviewRoute" />
+      </div>
     </div>
   </div>
 </template>
@@ -84,15 +88,22 @@
   import { onClickOutside, useResizeObserver } from '@vueuse/core';
   import FilePreviewPanel from '/@/views/ventAI/manageAssistent/components/resource/FilePreviewPanel.vue';
   import WebPreviewPanel from '/@/views/ventAI/manageAssistent/components/resource/WebPreviewPanel.vue';
+  import ModalPreview from '/@/views/ventAI/manageAssistent/components/resource/ModalPreview.vue';
   import AgentDetailPanel from './AgentDetailPanel.vue';
   import ScheduleDetailPanel from './ScheduleDetailPanel.vue';
   import { getFileIconVar } from '/@/views/ventAI/manageAssistent/lib/index';
-  import type { Message, RightPanelTab } from '/@/views/ventAI/manageAssistent/types/index';
+  import type { Message, RightPanelTab, SubAppRoute } from '/@/views/ventAI/manageAssistent/types/index';
 
   const props = defineProps<{
     tabs: RightPanelTab[];
     activeTabId: string | null;
     messages: Message[];
+    /** 二维模型 iframe 是否已加载(false 时彻底卸载) */
+    modelPreviewLoaded?: boolean;
+    /** 二维模型标签是否处于可见状态(false 时仅隐藏,保留 iframe) */
+    modelPreviewVisible?: boolean;
+    /** 当前已加载模型的初始子应用路由 */
+    modelPreviewRoute?: SubAppRoute | null;
   }>();
 
   const emit = defineEmits<{
@@ -333,6 +344,10 @@
                 background-image: var(--img-chat-link-icon);
               }
 
+              .tab-icon-model2d {
+                background-image: var(--img-chat-preview-icon);
+              }
+
               .tab-list-item-title {
                 flex: 1;
                 color: #c9d1d9;
@@ -451,6 +466,10 @@
       .panel-tab-web .panel-tab-icon {
         background-image: var(--img-chat-link-icon);
       }
+
+      .panel-tab-model2d .panel-tab-icon {
+        background-image: var(--img-chat-preview-icon);
+      }
     }
 
     .panel-tab-content {
@@ -460,5 +479,13 @@
       overflow: hidden;
       min-height: 0;
     }
+
+    .model-preview-host {
+      display: flex;
+      flex: 1;
+      width: 100%;
+      min-height: 0;
+      padding: 6px;
+    }
   }
 </style>

+ 70 - 93
src/views/ventAI/manageAssistent/components/resource/DwgPreview.vue

@@ -7,8 +7,10 @@
       <span>{{ error }}</span>
       <a-button size="small" type="link" @click="load(true)">重试</a-button>
     </div>
-    <div v-else ref="stageRef" class="dwg-stage" @wheel.prevent="onWheel" @mousedown="onMouseDown" @dblclick="resetView">
-      <img class="dwg-svg" :src="svgSrc" :style="svgStyle" alt="DWG 图纸预览" draggable="false" @error="onSvgError" />
+    <!-- 没有 svg 地址时不渲染舞台:空 src 的 <img> 会先报一次 error,把刚渲染好的内容盖成失败态 -->
+    <div v-else-if="svgSrc" ref="stageRef" class="dwg-stage" @wheel.prevent="onWheel" @mousedown="onMouseDown" @dblclick="resetView">
+      <!-- 地址一变就换一个新的 img:旧元素被移除后,它那次加载失败(比如旧地址已被回收)不会再回调到新图上来 -->
+      <img :key="svgSrc" class="dwg-svg" :src="svgSrc" :style="svgStyle" alt="DWG 图纸预览" draggable="false" @error="onSvgError" />
       <!-- 工具栏不吃 mousedown:否则从按钮上按下会连带触发舞台的拖拽平移 -->
       <div class="dwg-toolbar" @mousedown.stop>
         <span class="dwg-stat">{{ zoomText }}</span>
@@ -19,40 +21,41 @@
 </template>
 
 <script lang="ts">
-  // 模块级缓存(跨面板实例复用):已转换出的 svg 静态地址按文件 id 缓存。
-  // 面板实例会随标签切换重建(RightPanelTabs 没加 :key),同一份图纸关掉再打开很常见,
-  // 每次重开都重新上传转换一次没有意义——本次会话内转换结果是稳定的。
-  // 只缓存服务端静态地址,绝不缓存 blob 地址:blob 地址由具体实例创建并随其释放,
-  // 进了缓存就是悬空 URL;实例里的 releaseSvgBlob 只会释放这种未被缓存的临时 blob 地址。
-  const SVG_URL_CACHE_LIMIT = 20;
-  const svgUrlCache = new Map<string, string>();
-  const cacheSvgUrl = (id: string, url: string) => {
+  // 转换结果(svg 正文)按文件缓存,整个页面共享一份。
+  // 注意必须放在普通 script 块里:<script setup> 的顶层代码是每个组件实例执行一遍,
+  // 写在那里等于每次打开预览都是空缓存,缓存形同虚设(实测关掉标签再打开会重新转换一次)。
+  // 每份图纸在内存里留约 1MB 文本,故设上限 20 条,超出按 FIFO 丢最旧,避免长会话无界增长。
+  const SVG_TEXT_CACHE_LIMIT = 20;
+  const svgTextCache = new Map<string, string>();
+  const cacheSvgText = (id: string, svg: string) => {
     // 已存在则先删再插,刷新它在插入序里的位置,避免热点图纸被 FIFO 误淘汰
-    if (svgUrlCache.has(id)) svgUrlCache.delete(id);
-    svgUrlCache.set(id, url);
-    if (svgUrlCache.size > SVG_URL_CACHE_LIMIT) {
-      const oldest = svgUrlCache.keys().next().value as string | undefined;
-      if (oldest !== undefined) svgUrlCache.delete(oldest);
+    if (svgTextCache.has(id)) svgTextCache.delete(id);
+    svgTextCache.set(id, svg);
+    if (svgTextCache.size > SVG_TEXT_CACHE_LIMIT) {
+      const oldest = svgTextCache.keys().next().value as string | undefined;
+      if (oldest !== undefined) svgTextCache.delete(oldest);
     }
   };
 </script>
 
 <script setup lang="ts">
   import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
-  import { getToken } from '/@/utils/auth/index';
-  import { buildStaticFileUrl, dwgToSvg, dwgToSvgInfo } from '/@/views/ventAI/manageAssistent/api';
+  import { dwgServerFileToSvgInfo, dwgToSvgInfo } from '/@/views/ventAI/manageAssistent/api';
   import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
 
   /**
    * 右侧面板的 DWG 预览。
    *
    * DWG 是专有二进制格式,前端解析不了(对比 .dxf:明文,见 CadPreview 本地解析渲染),
-   * 只能「取出原始字节 → 上传后端转 SVG → 当作图片展示」。两个接口分工:
-   *   1) dwgFileToSVG:上传 + 转换,拿到生成的 svg 路径(转换结果落在服务端,重开面板不用重转);
-   *   2) dwgFileToSVGInfo:转换 + 直接返回 svg 正文,仅在 svg 内容拿不到/不可用时兜底。
+   * 只能让后端转成 SVG 再当作图片展示。两种取图方式:
+   *   - 历史消息里的附件(带 fileId):后端早就转过一次,按 fileId 直接取转换结果,不再上传;
+   *   - 本地附件(有原始字节、没有 fileId):上传文件再转换。
    *
-   * svg 必须自己取回来造 blob 地址再交给 <img>,不能把静态地址直接塞进 img src:
-   * 静态目录的响应头是 `Content-Type: application/force-download` + `X-Content-Type-Options: nosniff`,
+   * 不用「转换后返回路径、前端再去取文件」那条路:后端给的是服务端绝对路径(实测 /data/...),
+   * 前端拼成静态地址取不到(404),结果为了一次预览把转换和约 1MB 的内容各跑两遍。
+   *
+   * svg 正文自己造成 blob 地址再交给 <img>:静态目录的响应头是
+   * `Content-Type: application/force-download` + `X-Content-Type-Options: nosniff`,
    * 浏览器据此判定"不是图片"而拒绝渲染(实测 <img> 直接 error,同样的字节换成 blob 地址就能显示)。
    *
    * 转换发生在预览面板而不是附件/卡片点击时:附件加进来、AI 给了下载链接,都不代表用户要打开图纸,
@@ -73,14 +76,12 @@
     return { transform: `translate(${tx}px, ${ty}px) scale(${k})` };
   });
 
-  // 当前文件的上传体:转 SVG(首次与兜底)都要用,留着避免重复构造
+  // 当前文件的上传体:重试时还要用,留着避免重复构造
   let uploadSource: File | null = null;
-  // 交给 <img> 的 blob 地址(svg 正文所在,临时产物,不进模块级缓存):换文件/卸载时必须释放
+  // 交给 <img> 的 blob 地址(svg 正文所在),换文件/卸载时必须释放
   let svgBlobUrl = '';
   // 请求序号:换文件或重试后,在途请求的响应回来时状态已经属于另一个文件,必须丢弃
   let loadToken = 0;
-  // svg 兜底只做一次,否则失败会在两个接口之间来回触发
-  let fallbackTried = false;
 
   const releaseSvgBlob = () => {
     if (svgBlobUrl) {
@@ -97,10 +98,14 @@
   const applySvgText = (raw: string) => {
     const svg = (raw || '').trim();
     if (!svg) throw new Error('svg 内容为空');
-    releaseSvgBlob();
+    const previous = svgBlobUrl;
     svgBlobUrl = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
     svgSrc.value = svgBlobUrl;
+    // 渲染成功就清掉之前的失败态:换文件/重试后不能让旧的 error 继续盖住新内容
+    error.value = '';
     resetView();
+    // 先切到新地址(模板按地址换了新 img)再回收旧的,避免旧地址被回收时触发一次失败回调
+    if (previous) URL.revokeObjectURL(previous);
   };
 
   /** 上传用的 File:本地上传的附件直接用原始文件(字节最保真),远端预览对象只有字节时按原文件名重建(后端用它命名生成物) */
@@ -110,29 +115,47 @@
     return null;
   };
 
+  /**
+   * 取 svg 正文:历史消息的附件(带 fileId)按服务端文件 id 直接取转换结果——它后端已经转过一次,
+   * 没必要重新上传转换;本地附件(有原始字节)才上传。服务端文件取不到且手上有字节时,退回上传转换。
+   */
+  const requestSvgText = async (file: AttachedFile): Promise<string> => {
+    if (file.fileId) {
+      try {
+        const result = await dwgServerFileToSvgInfo(file.fileId);
+        return result?.svgInfo || '';
+      } catch (err) {
+        // 服务端文件被清理/不可用时,手上有原始字节就退回上传转换,别让预览挂掉
+        if (!uploadSource) throw err;
+        console.warn('按服务端文件取 DWG 转换结果失败,退回上传转换:', err);
+      }
+    }
+    if (!uploadSource) throw new Error('未取到图纸内容,也无法按服务端文件获取');
+    const result = await dwgToSvgInfo(uploadSource);
+    return result?.svgInfo || '';
+  };
+
   const load = async (force = false) => {
     const token = ++loadToken;
     const file = props.file;
-    releaseSvgBlob();
-    fallbackTried = false;
+    // 这里不回收旧的 blob 地址:它此刻可能还挂在 <img> 上,提前回收会触发一次加载失败,
+    // 把失败的提示盖在正在渲染的内容上。旧地址由 applySvgText(换新图时)和卸载时统一释放。
     error.value = '';
     // 上一次转换可能还停在 loading(请求在途时被换文件/重试抢占),这里统一复位:
     // 命中缓存的分支不会再把它置回 false,漏了这一行就会永远卡在「正在转换图纸」。
     loading.value = false;
     resetView();
+    svgSrc.value = '';
 
-    // 缓存的是转换产物地址而非 svg 正文:正文 1MB 上下,多份图纸攒在内存里不值当,
-    // 省下的只该是「重新上传 + 重新转换」这笔真正昂贵的开销。
-    const cachedUrl = force ? '' : svgUrlCache.get(file.id);
-    if (cachedUrl) {
-      svgSrc.value = '';
-      await loadSvgFromUrl(cachedUrl, token);
+    // 重试(force)时绕开缓存重新取一遍
+    const cachedSvg = force ? '' : svgTextCache.get(file.id);
+    if (cachedSvg) {
+      applySvgText(cachedSvg);
       return;
     }
 
-    svgSrc.value = '';
     uploadSource = toUploadFile(file);
-    if (!uploadSource) {
+    if (!file.fileId && !uploadSource) {
       error.value = '未取到图纸内容,无法预览';
       return;
     }
@@ -140,72 +163,26 @@
     loading.value = true;
     loadingText.value = '正在转换图纸,请稍候…';
     try {
-      const result = await dwgToSvg(uploadSource);
+      const svg = await requestSvgText(file);
       if (token !== loadToken) return;
-      const url = result?.svgFile ? buildStaticFileUrl(result.svgFile) : '';
-      if (!url) throw new Error('转换结果缺少 svg 地址');
-      // svgUrlCache 里只放服务端静态地址(见模块级注释),与 releaseSvgBlob 释放的临时 blob 地址互不干扰
-      cacheSvgUrl(file.id, url);
-      loading.value = false;
-      await loadSvgFromUrl(url, token);
+      if (!svg.trim()) throw new Error('转换结果缺少 svg 内容');
+      cacheSvgText(file.id, svg);
+      applySvgText(svg);
     } catch (err) {
       if (token !== loadToken) return;
       console.error('DWG 转 SVG 失败:', err);
       error.value = '图纸转换失败,请稍后重试';
-      loading.value = false;
-    }
-  };
-
-  /** 按转换产物地址取回 svg 正文再渲染(取不回来就换接口兜底) */
-  const loadSvgFromUrl = async (url: string, token: number) => {
-    loading.value = true;
-    loadingText.value = '正在加载图纸,请稍候…';
-    try {
-      const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
-      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
-      const svg = await resp.text();
-      if (token !== loadToken) return;
-      applySvgText(svg);
-    } catch (err) {
-      if (token !== loadToken) return;
-      console.error('取回 DWG 转换结果失败:', err);
-      const ok = await loadSvgFromInfo(token);
-      if (token !== loadToken) return;
-      if (!ok) error.value = '图纸加载失败,请稍后重试';
     } finally {
       if (token === loadToken) loading.value = false;
     }
   };
 
-  /** 兜底:换「转换并返回 svg 正文」的接口再拿一次;成功返回 true */
-  const loadSvgFromInfo = async (token: number) => {
-    if (!uploadSource) return false;
-    try {
-      const result = await dwgToSvgInfo(uploadSource);
-      if (token !== loadToken) return false;
-      applySvgText(result?.svgInfo || '');
-      // 已经用过内容接口了,后面 <img> 再失败就别再回来重试同一条路
-      fallbackTried = true;
-      return true;
-    } catch (err) {
-      if (token === loadToken) console.error('DWG 转 SVG 内容失败:', err);
-      return false;
-    }
-  };
-
-  /** <img> 渲染失败(取回的字节其实不是有效 svg)时再兜一次 */
-  const onSvgError = async () => {
-    if (fallbackTried) {
-      error.value = '图纸加载失败,请稍后重试';
-      return;
-    }
-    const token = loadToken;
-    loading.value = true;
-    loadingText.value = '正在重新获取图纸内容,请稍候…';
-    const ok = await loadSvgFromInfo(token);
-    if (token !== loadToken) return;
-    if (!ok) error.value = '图纸加载失败,请稍后重试';
-    loading.value = false;
+  /** <img> 渲染失败:返回的内容不是有效 svg,提示重试即可(接口只有这一个,没有别的来源可换) */
+  const onSvgError = (e: Event) => {
+    const img = e.target as HTMLImageElement | null;
+    // 只认「当前这个地址」的失败:空地址初始渲染、以及已被换掉的旧地址,报错都不算数
+    if (!img || img.getAttribute('src') !== svgSrc.value) return;
+    error.value = '图纸加载失败,请稍后重试';
   };
 
   // 平移/缩放:与 DXF 预览(CadPreview)保持同一套操作——滚轮缩放、按住拖拽、双击复位

+ 373 - 0
src/views/ventAI/manageAssistent/components/resource/ModalPreview.vue

@@ -0,0 +1,373 @@
+<template>
+  <div class="modal-preview">
+    <MicroLoadError v-if="loadFailed" @retry="reload" />
+    <iframe v-else ref="iframeRef" class="modal-preview-frame" :src="iframeSrc" title="二维模型预览" @load="handleLoad" @error="handleError"></iframe>
+    <a-spin v-if="loading" class="modal-preview-loading" size="large" :spinning="loading" tip="正在加载,请稍等。。。" />
+
+    <!-- 父子通信联调测试:开发环境或 URL 带 bridgeTest 参数时显示,生产构建默认不渲染 -->
+    <div v-if="bridgeTestEnabled" class="modal-preview-test" :class="{ 'is-open': testPanelOpen }">
+      <button type="button" class="test-toggle" @click="testPanelOpen = !testPanelOpen">
+        {{ testPanelOpen ? '收起通信测试' : '通信测试' }}
+      </button>
+      <div v-if="testPanelOpen" class="test-body">
+        <label class="test-field"><span>model_id</span><input v-model="testForm.model_id" placeholder="如 8014" /></label>
+        <label class="test-field"><span>deviceType</span><input v-model="testForm.deviceType" placeholder="如 fanlocal / gate / tun" /></label>
+        <label class="test-field"><span>tunid</span><input v-model="testForm.tunid" placeholder="定位巷道用" /></label>
+        <label class="test-field"><span>locationId</span><input v-model="testForm.locationId" placeholder="定位设备用 device.deviceID" /></label>
+        <label class="test-field"
+          ><span>locationName</span><input v-model="testForm.locationName" placeholder="定位报表用 device.strInstallPos"
+        /></label>
+        <div class="test-actions">
+          <button type="button" @click="sendBridgeTest('tunnel')">定位巷道</button>
+          <button type="button" @click="sendBridgeTest('device')">定位设备</button>
+          <!-- <button type="button" @click="sendBridgeTest('report')">定位报表</button> -->
+        </div>
+        <div class="test-log">{{ testLog || '尚未发送' }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
+  import { apps } from '/@/qiankun/apps';
+  import MicroLoadError from '/@/components/vent/micro/MicroLoadError.vue';
+  import { useErrorLogStoreWithOut } from '/@/store/modules/errorLog';
+  import { useUserStore } from '/@/store/modules/user';
+  import { ErrorTypeEnum } from '/@/enums/exceptionEnum';
+  import { isDevMode } from '/@/utils/env';
+  import type { SubAppRoute } from '/@/views/ventAI/manageAssistent/types/index';
+  import {
+    getLastLocateResult,
+    isPreviewReady,
+    locateDevice,
+    locateDeviceReport,
+    locateTunnel,
+    markPreviewNotReady,
+    registerPreviewBridge,
+    unregisterPreviewBridge,
+  } from '/@/views/ventAI/manageAssistent/lib/modalPreviewBridge';
+
+  const APP_NAME = 'micro-vent-2dModal';
+  // 子应用里专为弹窗准备的独立视图(D:\mky\node\Vent2D\tube3d\src\router\route.ts 的 /modal-preview)
+  const DEFAULT_ROUTE = '/modal-preview';
+  // const DEFAULT_ROUTE = '';
+  // iframe 的 load 事件是文档级加载完成,不等待 Three.js 模型资源;60s 足够,避免加载失败时长时间转圈
+  const LOAD_TIMEOUT_MS = 60000;
+
+  const props = withDefaults(defineProps<{ subAppRoute?: SubAppRoute | null }>(), {
+    subAppRoute: () => ({ path: DEFAULT_ROUTE }),
+  });
+
+  const loading = ref(true);
+  const loadFailed = ref(false);
+  const iframeRef = ref<HTMLIFrameElement>();
+  let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
+  // iframe 是独立文档,子应用取不到基座的 token(存储键与作用域都不同);
+  // 子应用 ready 后由桥统一补发,切模型请求才不会 401→500。
+  const userStore = useUserStore();
+
+  // 与 qiankun/apps.ts 共用同一份入口解析:dev 为 //localhost:8088,prod 为 //host/tun2D/
+  const entry = apps.find((item) => item.name === APP_NAME)?.entry as string | undefined;
+
+  const joinUrl = (base: string, path: string) => `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
+
+  const iframeSrc = computed(() => {
+    if (!entry) return '';
+    const route = props.subAppRoute;
+    const path = route?.path || DEFAULT_ROUTE;
+    let url = joinUrl(entry, path);
+    const query = route?.query;
+    if (query && Object.keys(query).length) {
+      const qs = Object.entries(query)
+        .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
+        .join('&');
+      url += `${url.includes('?') ? '&' : '?'}${qs}`;
+    }
+    return url;
+  });
+
+  const clearLoadTimeout = () => {
+    if (timeoutTimer) {
+      clearTimeout(timeoutTimer);
+      timeoutTimer = undefined;
+    }
+  };
+
+  const logLoadError = (name: string, message: string, detail: string) => {
+    useErrorLogStoreWithOut().addErrorLogInfo({
+      type: ErrorTypeEnum.RESOURCE,
+      file: 'views/ventAI/manageAssistent/components/resource/ModalPreview.vue',
+      name,
+      message,
+      detail,
+      url: window.location.href,
+    });
+  };
+
+  const startLoadTimeout = () => {
+    clearLoadTimeout();
+    timeoutTimer = setTimeout(() => {
+      timeoutTimer = undefined;
+      if (!loading.value) return;
+      loading.value = false;
+      loadFailed.value = true;
+      logLoadError('MicroApp Load Timeout', `子应用 ${APP_NAME} 加载超时`, `iframe 在 ${LOAD_TIMEOUT_MS}ms 内未触发 load:${iframeSrc.value}`);
+    }, LOAD_TIMEOUT_MS);
+  };
+
+  const handleLoad = () => {
+    clearLoadTimeout();
+    loading.value = false;
+  };
+
+  // 同一标签页内切换子应用路由(iframe src 变化)时重新进入加载态;
+  // 子应用重新挂载后会再次发 ready,桥接层在 ready 后补发排队中的定位消息。
+  watch(iframeSrc, (next, prev) => {
+    if (!next || next === prev) return;
+    markPreviewNotReady();
+    loadFailed.value = false;
+    loading.value = true;
+    startLoadTimeout();
+  });
+
+  // iframe 的 error 事件只在网络层失败时触发(HTTP 404 也会触发 load),所以仍保留超时兜底
+  const handleError = () => {
+    clearLoadTimeout();
+    loading.value = false;
+    loadFailed.value = true;
+    logLoadError('MicroApp Load Error', `子应用 ${APP_NAME} 加载失败`, iframeSrc.value);
+  };
+
+  // 子应用的实际来源:entry 形如 //localhost:8088 或 //host/tun2D/,按当前页面协议补全
+  const resolveOrigin = (url?: string) => {
+    if (!url) return '';
+    try {
+      return new URL(url, window.location.href).origin;
+    } catch {
+      return '';
+    }
+  };
+
+  // ===== 父子通信联调测试 =====
+  // 开发环境默认显示;生产构建可用 URL 追加 ?bridgeTest(hash 路由则加 #...&bridgeTest)打开
+  const bridgeTestEnabled = isDevMode() || /[?&#]bridgeTest\b/.test(window.location.href);
+  const testPanelOpen = ref(false);
+  const bridgeReady = ref(false);
+  const bridgeOrigin = ref('');
+  const testLog = ref('');
+  let lastResultAt = 0;
+  let testTimer: ReturnType<typeof setInterval> | undefined;
+  const testForm = reactive({
+    model_id: '',
+    deviceType: 'fanlocal',
+    tunid: '',
+    locationId: '',
+    locationName: '',
+  });
+
+  const toPayloadValue = (value: string) => value.trim();
+
+  /** 轮询通道状态与子应用回执(桥本身不是响应式的,用 1s 轮询最省事) */
+  const refreshBridgeState = () => {
+    bridgeReady.value = isPreviewReady();
+    const result = getLastLocateResult();
+    if (result && result.at !== lastResultAt) {
+      lastResultAt = result.at;
+      const tail = result.message ? `:${result.message}` : '';
+      testLog.value = `${new Date(result.at).toLocaleTimeString()} ${result.request} → ${result.ok ? '成功' : '失败'}${tail}`;
+    }
+  };
+
+  const sendBridgeTest = (kind: 'tunnel' | 'device' | 'report') => {
+    const form = testForm;
+    if (!form.model_id.trim()) {
+      testLog.value = '请先填写 model_id(子应用用它校验当前模型)';
+      return;
+    }
+    const model_id = toPayloadValue(form.model_id);
+    const deviceType = form.deviceType.trim();
+    const time = new Date().toLocaleTimeString();
+    let accepted: boolean;
+    let summary: string;
+
+    if (kind === 'tunnel') {
+      if (!form.tunid.trim()) {
+        testLog.value = '定位巷道需要填写 tunid';
+        return;
+      }
+      const payload = { model_id, tunid: toPayloadValue(form.tunid), deviceType };
+      accepted = locateTunnel(payload);
+      summary = `locate-tunnel ${JSON.stringify(payload)}`;
+    } else if (kind === 'device') {
+      if (!form.locationId.trim()) {
+        testLog.value = '定位设备需要填写 locationId';
+        return;
+      }
+      const payload = { model_id, deviceType, locationId: toPayloadValue(form.locationId) };
+      accepted = locateDevice(payload);
+      summary = `locate-device ${JSON.stringify(payload)}`;
+    } else {
+      if (!form.locationName.trim()) {
+        testLog.value = '定位报表需要填写 locationName';
+        return;
+      }
+      const payload = { model_id, deviceType, locationName: form.locationName.trim() };
+      accepted = locateDeviceReport(payload);
+      summary = `locate-device-report ${JSON.stringify(payload)}`;
+    }
+
+    testLog.value = accepted ? `${time} 已发送:${summary}` : `${time} 已入队(等待子应用 ready):${summary}`;
+  };
+
+  const reload = () => {
+    // iframe 会被 v-if 重建,子应用会重新走一遍 main.ts 并再次回 ready
+    markPreviewNotReady();
+    loadFailed.value = false;
+    loading.value = true;
+    startLoadTimeout();
+  };
+
+  onMounted(() => {
+    if (!entry) {
+      // VITE_APP_SUB_APP 中没配该子应用:直接进错误态,不必空等超时
+      loading.value = false;
+      loadFailed.value = true;
+      logLoadError('MicroApp Not Configured', `子应用 ${APP_NAME} 未在 VITE_APP_SUB_APP 中配置`, 'apps.ts 未解析到该子应用的 entry');
+      return;
+    }
+    // 传函数而非 window 引用:iframe 会被 v-if 重建,contentWindow 会变
+    bridgeOrigin.value = resolveOrigin(entry);
+    registerPreviewBridge(
+      () => iframeRef.value?.contentWindow ?? null,
+      bridgeOrigin.value,
+      () => userStore.getToken
+    );
+    startLoadTimeout();
+
+    if (bridgeTestEnabled) {
+      refreshBridgeState();
+      testTimer = setInterval(refreshBridgeState, 1000);
+    }
+  });
+
+  onBeforeUnmount(() => {
+    if (testTimer) clearInterval(testTimer);
+    unregisterPreviewBridge();
+    clearLoadTimeout();
+  });
+</script>
+
+<style scoped lang="less">
+  .modal-preview {
+    position: relative;
+    display: flex;
+    flex: 1;
+    flex-direction: column;
+    width: 100%;
+    min-height: 0;
+
+    &-loading {
+      position: absolute;
+      top: 0;
+      left: 0;
+      z-index: 0;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 100%;
+      height: 100%;
+    }
+
+    &-frame {
+      display: block;
+      flex: 1;
+      width: 100%;
+      min-height: 0;
+      border: 0;
+      pointer-events: auto;
+    }
+
+    &-test {
+      position: absolute;
+      top: 8px;
+      left: 8px;
+      z-index: 6;
+      max-width: calc(100% - 16px);
+      font-size: 12px;
+      color: #fff;
+
+      .test-toggle {
+        padding: 4px 10px;
+        font-size: 12px;
+        border: 1px solid rgb(255 255 255 / 35%);
+        border-radius: 4px;
+        background: rgb(0 0 0 / 65%);
+        color: #fff;
+        cursor: pointer;
+      }
+
+      .test-body {
+        display: flex;
+        flex-direction: column;
+        gap: 4px;
+        width: 340px;
+        max-width: 100%;
+        padding: 8px;
+        margin-top: 6px;
+        border: 1px solid rgb(255 255 255 / 25%);
+        border-radius: 4px;
+        background: rgb(0 0 0 / 80%);
+      }
+
+      .test-status {
+        display: flex;
+        flex-direction: column;
+        color: #9be89b;
+      }
+
+      .test-field {
+        display: flex;
+        align-items: center;
+        gap: 6px;
+
+        span {
+          flex: 0 0 84px;
+          text-align: right;
+        }
+
+        input {
+          flex: 1;
+          min-width: 0;
+          padding: 2px 6px;
+          border: 1px solid rgb(255 255 255 / 25%);
+          border-radius: 3px;
+          background: rgb(255 255 255 / 10%);
+          color: #fff;
+        }
+      }
+
+      .test-actions {
+        display: flex;
+        gap: 6px;
+
+        button {
+          flex: 1;
+          padding: 4px 0;
+          border: 0;
+          border-radius: 3px;
+          background: rgb(64 120 220 / 75%);
+          color: #fff;
+          cursor: pointer;
+        }
+      }
+
+      .test-log {
+        max-height: 64px;
+        color: #ffd591;
+        overflow: auto;
+        word-break: break-all;
+      }
+    }
+  }
+</style>

+ 6 - 0
src/views/ventAI/manageAssistent/components/resource/ResourceCapsule.vue

@@ -153,6 +153,7 @@
 
   const emit = defineEmits<{
     (e: 'file-preview', file: AttachedFile): void;
+    (e: 'download-attachment', file: AttachedFile): void;
     (e: 'agent-detail-click', data: { messageIndex: number; agent: string; agentId?: string }): void;
     (e: 'locate-message', messageIndex: number): void;
     (e: 'open-web', payload: { url: string; title?: string }): void;
@@ -234,6 +235,11 @@
     // 上传附件:优先用原始文件对象下载(字节保真),否则由本地缓存内容构造 Blob
     const file = item.file;
     if (!file) return;
+    // 历史回显附件只有服务端 ID,下载需要带令牌,交给父组件统一处理
+    if (file.fileId) {
+      emit('download-attachment', file);
+      return;
+    }
     if (file.originalFile) {
       const url = URL.createObjectURL(file.originalFile);
       triggerDownload(url, file.originalFile.name || file.name);

+ 259 - 31
src/views/ventAI/manageAssistent/components/resource/WebPreviewPanel.vue

@@ -2,12 +2,35 @@
   <div class="web-preview-panel">
     <!-- 地址栏:面板里已经在浏览的真实地址(同源,可读 location),随时能复制或跳回新窗口 -->
     <div class="web-toolbar">
-      <div class="web-url" :title="url">{{ displayUrl }}</div>
-      <a-select v-model:value="sizeKey" class="web-size-select" size="small" :options="sizeOptions" :dropdown-match-select-width="false" />
-      <div class="web-actions">
-        <a-button size="small" type="link" @click="reload">刷新</a-button>
-        <a-button size="small" type="link" @click="openInBrowser">新窗口打开</a-button>
-        <a-button size="small" type="link" @click="copyUrl">复制链接</a-button>
+      <div class="web-address-row">
+        <div class="web-url" :title="url">
+          <span class="web-url-icon"></span>
+          <span class="web-url-text">{{ displayUrl }}</span>
+        </div>
+      </div>
+      <div class="web-controls-row">
+        <a-select
+          v-model:value="sizeKey"
+          class="web-size-select"
+          size="small"
+          :options="sizeOptions"
+          :dropdown-match-select-width="false"
+          dropdown-class-name="web-size-dropdown"
+        />
+        <div class="web-actions">
+          <button type="button" class="web-action-btn" title="刷新" @click="reload">
+            <span class="web-action-icon web-action-icon-refresh"></span>
+            <span>刷新</span>
+          </button>
+          <button type="button" class="web-action-btn" title="在新窗口打开" @click="openInBrowser">
+            <span class="web-action-icon web-action-icon-export"></span>
+            <span>新窗口</span>
+          </button>
+          <button type="button" class="web-action-btn" title="复制链接" @click="copyUrl">
+            <span class="web-action-icon web-action-icon-copy"></span>
+            <span>复制链接</span>
+          </button>
+        </div>
       </div>
     </div>
     <!--
@@ -16,14 +39,17 @@
       只缩不放(放大会糊),四周留白居中;选「自适应面板」时退回铺满、不缩放。
     -->
     <div ref="stageRef" class="web-stage">
-      <div v-if="loading" class="web-loading">正在加载页面…</div>
+      <div v-if="loading" class="web-loading">
+        <span class="web-loading-spinner"></span>
+        <span>正在加载页面…</span>
+      </div>
       <div class="web-viewport" :class="{ 'is-loading': loading }" :style="viewportStyle">
         <iframe
           ref="frameRef"
           class="web-frame"
           :src="url"
           frameborder="0"
-          sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"
+          sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals"
           @load="loading = false"
         ></iframe>
       </div>
@@ -41,7 +67,8 @@
    *
    * 已按团队预留的收紧口给 iframe 加了 sandbox:地址多为内网系统,故保留
    * allow-same-origin(登录态、地址可读)与 allow-scripts/forms/popups/downloads
-   * (页面功能、弹窗、下载),未放 allow-top-navigation 等越权项。
+   * (页面功能、弹窗、下载);旧内网系统常用 alert/confirm 提示和打印,另放
+   * allow-modals。未放 allow-top-navigation 等越权项(避免页面把整个基座跳走)。
    */
   const props = defineProps<{ url: string }>();
 
@@ -97,11 +124,14 @@
     resizeObserver = null;
   });
 
-  // 标签栏里显示短路径,完整地址放在 title 上
+  // 地址栏文案:同源页面显示路径,外部页面显示域名 + 路径;完整地址放在 title 上。
+  // 之前只取 pathname,外部站点根路径会显示成孤零零的 "/"。
   const displayUrl = computed(() => {
     try {
       const u = new URL(props.url, window.location.href);
-      return u.pathname + u.search || u.href;
+      const path = `${u.pathname}${u.search}${u.hash}`;
+      if (u.origin === window.location.origin) return path || '/';
+      return `${u.host}${path === '/' ? '' : path}`;
     } catch {
       return props.url;
     }
@@ -151,39 +181,183 @@
     flex-direction: column;
     height: 100%;
     min-height: 0;
+    background: linear-gradient(180deg, rgb(8 37 58 / 98%), rgb(4 22 36 / 98%));
   }
 
   .web-toolbar {
     display: flex;
-    align-items: center;
+    flex-direction: column;
     gap: 8px;
     flex-shrink: 0;
-    padding: 8px 10px;
-    border-bottom: 1px solid rgb(63 80 106 / 25%);
+    padding: 10px 12px;
+    border-bottom: 1px solid rgb(63 80 106 / 35%);
+    background: rgb(2 18 30 / 35%);
+  }
 
-    .web-url {
-      flex: 1;
+  .web-address-row {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .web-url {
+    display: flex;
+    flex: 1;
+    align-items: center;
+    gap: 8px;
+    min-width: 0;
+    height: 30px;
+    padding: 0 10px;
+    border: 1px solid rgb(63 80 106 / 50%);
+    border-radius: 6px;
+    background: rgb(1 16 27 / 72%);
+    color: #c9d1d9;
+    font-size: 12px;
+    transition:
+      border-color 0.18s,
+      background 0.18s;
+
+    &:hover {
+      border-color: rgb(10 132 255 / 40%);
+      background: rgb(1 20 34 / 90%);
+    }
+
+    .web-url-icon {
+      width: 14px;
+      height: 14px;
+      flex-shrink: 0;
+      background-image: var(--img-chat-link-icon);
+      background-repeat: no-repeat;
+      background-size: 100% 100%;
+      opacity: 0.8;
+    }
+
+    .web-url-text {
       min-width: 0;
-      font-size: 12px;
-      color: #8b949e;
       overflow: hidden;
       text-overflow: ellipsis;
       white-space: nowrap;
-      padding: 2px 8px;
-      border-radius: 4px;
-      background: rgb(255 255 255 / 5%);
+      letter-spacing: 0.2px;
     }
+  }
 
-    .web-size-select {
-      width: 116px;
-      flex-shrink: 0;
+  .web-controls-row {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex-wrap: wrap;
+    min-width: 0;
+  }
+
+  .web-size-select {
+    width: 118px;
+    height: 28px;
+    flex-shrink: 0;
+
+    :deep(.zxm-select-selector) {
+      height: 28px !important;
+      padding: 0 9px !important;
+      border-color: rgb(63 80 106 / 50%) !important;
+      border-radius: 6px !important;
+      background: rgb(1 16 27 / 72%) !important;
+      box-shadow: none !important;
+      transition:
+        border-color 0.18s,
+        background 0.18s;
     }
 
-    .web-actions {
-      display: flex;
-      align-items: center;
-      gap: 2px;
+    :deep(.zxm-select-selection-item) {
+      color: #c9d1d9 !important;
+      font-size: 12px;
+      line-height: 26px !important;
+    }
+
+    :deep(.zxm-select-arrow) {
+      color: #8b949e !important;
+    }
+
+    &:hover :deep(.zxm-select-selector) {
+      border-color: rgb(10 132 255 / 40%) !important;
+      background: rgb(1 20 34 / 90%) !important;
+    }
+
+    &.zxm-select-focused :deep(.zxm-select-selector) {
+      border-color: #5cb8ff !important;
+      box-shadow: 0 0 0 2px rgb(10 132 255 / 15%) !important;
+    }
+  }
+
+  .web-actions {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    margin-left: auto;
+    flex-shrink: 0;
+  }
+
+  .web-action-btn {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    height: 28px;
+    padding: 0 9px;
+    border: 1px solid transparent;
+    border-radius: 6px;
+    background: transparent;
+    color: #8b949e;
+    font-size: 12px;
+    line-height: 1;
+    white-space: nowrap;
+    cursor: pointer;
+    transition:
+      color 0.18s,
+      background 0.18s,
+      border-color 0.18s;
+
+    .web-action-icon {
+      width: 14px;
+      height: 14px;
       flex-shrink: 0;
+      background-repeat: no-repeat;
+      background-size: 100% 100%;
+      opacity: 0.7;
+      transition: opacity 0.18s;
+    }
+
+    .web-action-icon-refresh {
+      background-image: var(--img-chat-regenerate-icon);
+    }
+
+    .web-action-icon-export {
+      background-image: var(--img-chat-export-icon);
+    }
+
+    .web-action-icon-copy {
+      background-image: var(--img-chat-copy-icon);
+    }
+
+    &:hover {
+      border-color: rgb(10 132 255 / 22%);
+      background: rgb(10 132 255 / 12%);
+      color: #e0e6ed;
+
+      .web-action-icon {
+        opacity: 1;
+      }
+    }
+
+    &:active {
+      background: rgb(10 132 255 / 20%);
+    }
+
+    &:focus-visible {
+      outline: 1px solid #5cb8ff;
+      outline-offset: 1px;
+    }
+
+    /* 鼠标点击后不保留持续高亮,只有键盘聚焦才显示描边 */
+    &:focus:not(:focus-visible) {
+      outline: none;
     }
   }
 
@@ -191,17 +365,24 @@
     position: relative;
     flex: 1;
     min-height: 0;
+    margin: 10px 12px 12px;
     overflow: hidden;
+    border: 1px solid rgb(63 80 106 / 35%);
+    border-radius: 8px;
     // 缩放后四周的留白底色,跟面板区分开,看起来像「一块屏幕」
-    background: rgb(0 0 0 / 22%);
+    background: radial-gradient(ellipse at 50% 0%, rgb(10 132 255 / 8%), transparent 60%), #03111d;
+    box-shadow:
+      inset 0 0 0 1px rgb(255 255 255 / 2%),
+      0 8px 24px rgb(0 0 0 / 25%);
   }
 
   .web-viewport {
     position: absolute;
     transform-origin: center center;
     overflow: hidden;
+    border-radius: 4px;
     background: #fff;
-    box-shadow: 0 6px 22px rgb(0 0 0 / 40%);
+    box-shadow: 0 8px 28px rgb(0 0 0 / 42%);
 
     &.is-loading {
       visibility: hidden;
@@ -218,10 +399,57 @@
   .web-loading {
     position: absolute;
     inset: 0;
+    z-index: 1;
     display: flex;
     align-items: center;
     justify-content: center;
-    font-size: 13px;
+    gap: 8px;
+    background: rgb(3 17 29 / 40%);
     color: #8b949e;
+    font-size: 12px;
+
+    .web-loading-spinner {
+      width: 14px;
+      height: 14px;
+      border: 2px solid rgb(92 184 255 / 20%);
+      border-top-color: #5cb8ff;
+      border-radius: 50%;
+      animation: web-loading-spin 0.8s linear infinite;
+    }
+  }
+
+  @keyframes web-loading-spin {
+    to {
+      transform: rotate(360deg);
+    }
+  }
+</style>
+
+<style lang="less">
+  /* 下拉挂在 body 下,不能用 scoped;与面板深色风格保持一致 */
+  .web-size-dropdown {
+    padding: 4px;
+    border: 1px solid #1a4a6f;
+    border-radius: 6px;
+    background: #0a2a3f;
+    box-shadow: 0 6px 20px rgb(0 0 0 / 42%);
+
+    .zxm-select-item {
+      min-height: 28px;
+      padding: 4px 9px;
+      border-radius: 4px;
+      color: #c9d1d9;
+      font-size: 12px;
+    }
+
+    .zxm-select-item-option-active:not(.zxm-select-item-option-disabled) {
+      background: rgb(10 132 255 / 12%);
+    }
+
+    .zxm-select-item-option-selected:not(.zxm-select-item-option-disabled) {
+      background: rgb(10 132 255 / 18%);
+      color: #e0e6ed;
+      font-weight: 400;
+    }
   }
 </style>

+ 176 - 0
src/views/ventAI/manageAssistent/hooks/useChatAttachments.ts

@@ -0,0 +1,176 @@
+import { message } from 'ant-design-vue';
+import { fetchAttachmentBlob, getSessionAttachments } from '/@/views/ventAI/manageAssistent/api';
+import type { SessionAttachment } from '/@/views/ventAI/manageAssistent/api';
+import { isCsvFile, isDwgFile, isDxfFile, isExcelFile, isTextFile, isWordFile, getPreviewMimeType } from '/@/views/ventAI/manageAssistent/lib';
+import type { AttachedFile, Message, Task } from '/@/views/ventAI/manageAssistent/types/index';
+
+/**
+ * 会话附件历史:加载后按 message_id 挂回用户消息,无法匹配的进入兜底列表;
+ * 附件内容按需下载(预览时才取二进制),下载/预览都带令牌走附件下载接口。
+ */
+
+const toAttachedFile = (item: SessionAttachment): AttachedFile => ({
+  id: item.file_id,
+  fileId: item.file_id,
+  messageId: item.message_id,
+  name: item.filename,
+  size: item.size_bytes,
+  type: item.content_type || '',
+  uploadTime: item.created_at,
+  createdAt: item.created_at,
+  downloadUrl: item.download_url,
+});
+
+const fileKey = (file: AttachedFile): string => file.fileId || file.id;
+
+const mergeAttachedFiles = (existing: AttachedFile[], incoming: AttachedFile[]): AttachedFile[] => {
+  const map = new Map<string, AttachedFile>();
+  for (const file of [...existing, ...incoming]) {
+    map.set(fileKey(file), file);
+  }
+  return Array.from(map.values());
+};
+
+const saveBlob = (blob: Blob, filename: string) => {
+  const url = URL.createObjectURL(blob);
+  const a = document.createElement('a');
+  a.href = url;
+  a.download = filename || '附件';
+  document.body.appendChild(a);
+  a.click();
+  document.body.removeChild(a);
+  // 让浏览器先拿到下载任务再释放对象 URL
+  setTimeout(() => URL.revokeObjectURL(url), 0);
+};
+
+const hasContent = (file: AttachedFile): boolean =>
+  file.content !== undefined || file.arrayBuffer !== undefined || file.preview !== undefined || !!file.originalFile;
+
+/**
+ * 预览用的 blob 换成真实类型:附件下载接口统一回 application/octet-stream(还带 Content-Disposition: attachment),
+ * 浏览器凭这个类型认不出内容——PDF 放进 iframe 会被当成下载而不是显示。
+ */
+const withPreviewType = (file: AttachedFile, blob: Blob): Blob => {
+  const type = getPreviewMimeType(file.name, file.type || blob.type);
+  return type && type !== blob.type ? new Blob([blob], { type }) : blob;
+};
+
+/** 历史附件没有原始内容,首次预览时按文件类型取回并缓存到附件对象上 */
+const ensureAttachmentContent = async (file: AttachedFile): Promise<AttachedFile> => {
+  if (hasContent(file)) return file;
+  if (!file.fileId) throw new Error('附件缺少下载标识');
+
+  // dwg 历史附件不用把原始文件下载回来:预览面板会按 fileId 直接取后端已转换好的 svg
+  //(见 DwgPreview 的 requestSvgText),下载几百 KB 只为了再上传转换一遍属于纯浪费。
+  if (isDwgFile(file.name)) return file;
+
+  const blob = await fetchAttachmentBlob(file.fileId, file.downloadUrl);
+  if (isTextFile(file.name) || isCsvFile(file.name) || isDxfFile(file.name)) {
+    file.content = await blob.text();
+  } else if (isWordFile(file.name) || isExcelFile(file.name)) {
+    file.arrayBuffer = await blob.arrayBuffer();
+  } else {
+    file.preview = URL.createObjectURL(withPreviewType(file, blob));
+  }
+  file.originalFile = new File([blob], file.name, {
+    type: file.type || blob.type || 'application/octet-stream',
+  });
+  return file;
+};
+
+export const useChatAttachments = () => {
+  /**
+   * 拉取会话附件并挂到历史消息上。
+   * - message_id 能匹配到用户消息:挂到该消息的 attachedFiles;
+   * - message_id 为 null 或消息已合并/跳过:进入 task.orphanAttachments,由消息区底部兜底展示。
+   * 附件接口失败不阻断历史消息展示(旧环境可能还没有这个接口)。
+   */
+  const loadHistoryAttachments = async (task: Task, sessionId: string, messages: Message[]): Promise<AttachedFile[]> => {
+    try {
+      const res = await getSessionAttachments(sessionId);
+      const attachments = (res?.attachments || []).map(toAttachedFile);
+      if (!attachments.length) {
+        task.orphanAttachments = [];
+        return [];
+      }
+
+      const userMessageById = new Map<number, Message>();
+      for (const msg of messages) {
+        if (msg.type === 'user' && msg.id != null) userMessageById.set(msg.id, msg);
+      }
+
+      const orphans: AttachedFile[] = [];
+      for (const file of attachments) {
+        const target = file.messageId != null ? userMessageById.get(file.messageId) : undefined;
+        if (target) {
+          if (!target.attachedFiles) target.attachedFiles = [];
+          if (!target.attachedFiles.some((f) => fileKey(f) === fileKey(file))) {
+            target.attachedFiles.push(file);
+          }
+        } else {
+          orphans.push(file);
+        }
+      }
+
+      task.orphanAttachments = orphans;
+      task.attachedFiles = mergeAttachedFiles(task.attachedFiles, attachments);
+      return attachments;
+    } catch (e) {
+      // 历史消息是主内容,附件是增强信息:失败只记录,不打断会话打开
+      console.error('加载会话附件失败:', e);
+      return [];
+    }
+  };
+
+  const handleAttachmentPreview = async (file: AttachedFile, openPreview: (target: AttachedFile) => void) => {
+    if (hasContent(file)) {
+      openPreview(file);
+      return;
+    }
+    if (!file.fileId) {
+      message.warning('该附件暂不支持预览');
+      return;
+    }
+
+    const closeLoading = message.loading('正在加载附件…', 0);
+    try {
+      await ensureAttachmentContent(file);
+      openPreview(file);
+    } catch (e) {
+      console.error('加载附件失败:', e);
+      message.error((e as Error)?.message || '附件加载失败');
+    } finally {
+      closeLoading();
+    }
+  };
+
+  const handleAttachmentDownload = async (file: AttachedFile) => {
+    try {
+      if (file.fileId) {
+        const closeLoading = message.loading('正在下载附件…', 0);
+        try {
+          const blob = await fetchAttachmentBlob(file.fileId, file.downloadUrl);
+          saveBlob(blob, file.name);
+        } finally {
+          closeLoading();
+        }
+        return;
+      }
+      if (file.originalFile) {
+        saveBlob(file.originalFile, file.name);
+        return;
+      }
+      if (file.preview?.startsWith('blob:') || file.preview?.startsWith('data:')) {
+        const blob = await (await fetch(file.preview)).blob();
+        saveBlob(blob, file.name);
+        return;
+      }
+      message.warning('该附件暂不支持下载');
+    } catch (e) {
+      console.error('下载附件失败:', e);
+      message.error((e as Error)?.message || '附件下载失败');
+    }
+  };
+
+  return { loadHistoryAttachments, handleAttachmentPreview, handleAttachmentDownload };
+};

+ 36 - 2
src/views/ventAI/manageAssistent/hooks/useChatPanels.ts

@@ -1,7 +1,15 @@
 import { computed, nextTick, ref, watch } from 'vue';
 import { applyWrapStyles } from '../lib/modalWrap';
 import { webTabTitle } from '../lib/webLink';
-import type { AgentDetailData, AttachedFile, Message, RightPanelState, RightPanelTab, Task } from '/@/views/ventAI/manageAssistent/types/index';
+import type {
+  AgentDetailData,
+  AttachedFile,
+  Message,
+  RightPanelState,
+  RightPanelTab,
+  SubAppRoute,
+  Task,
+} from '/@/views/ventAI/manageAssistent/types/index';
 
 /**
  * 右侧面板与标签页:面板开关/折叠、标签页增删激活、文件与智能体详情标签、全屏与弹框宽度,
@@ -168,7 +176,7 @@ export const useChatPanels = (deps: ChatPanelsDeps) => {
   const openFilePreview = (file: AttachedFile) => {
     openFileTab(file, false);
 
-    const msgIndex = getMessages().findIndex((m) => m.attachedFile?.id === file.id);
+    const msgIndex = getMessages().findIndex((m) => m.attachedFile?.id === file.id || (m.attachedFiles || []).some((item) => item.id === file.id));
     if (msgIndex !== -1) {
       nextTick(() => {
         getChatMessages()?.scrollToMessage(msgIndex, 'center');
@@ -192,6 +200,31 @@ export const useChatPanels = (deps: ChatPanelsDeps) => {
     openTab({ id: nextTabId(), type: 'web', title: webTabTitle(payload.url, payload.title), url: payload.url });
   };
 
+  /**
+   * 打开二维模型预览标签:与文件/网页预览同级,仅在激活该标签时挂载 iframe。
+   * 同一任务只保留一个二维标签;再次调用会激活已有标签,并可按需更新标题/初始子应用路由。
+   */
+  const openModalPreviewTab = (payload?: { title?: string; subAppRoute?: SubAppRoute | null }) => {
+    const task = getCurrentTask();
+    if (!task) return undefined;
+    const state = getPanelState(task);
+    const existing = state.tabs.find((t) => t.type === 'model2d');
+    if (existing) {
+      if (existing.type === 'model2d' && payload?.subAppRoute !== undefined) existing.subAppRoute = payload.subAppRoute;
+      if (payload?.title) existing.title = payload.title;
+      state.activeTabId = existing.id;
+      return existing;
+    }
+    const tab: RightPanelTab = {
+      id: nextTabId(),
+      type: 'model2d',
+      title: payload?.title || '二维模型',
+      subAppRoute: payload?.subAppRoute ?? null,
+    };
+    openTab(tab);
+    return tab;
+  };
+
   const getCurrentTaskFiles = (): AttachedFile[] => {
     return getCurrentTask()?.attachedFiles || [];
   };
@@ -277,6 +310,7 @@ export const useChatPanels = (deps: ChatPanelsDeps) => {
     openMarkdownPreviewTab,
     openFilePreview,
     openWebTab,
+    openModalPreviewTab,
     handleOpenFilePreview,
     handleSaveSuccess,
     getCurrentTaskFiles,

+ 25 - 5
src/views/ventAI/manageAssistent/hooks/useChatSend.ts

@@ -240,9 +240,7 @@ export const useChatSend = (deps: ChatSendDeps) => {
   const sendWithAttachment = async (file: AttachedFile, userInput: string, displayContent?: string) => {
     const originTaskId = getCurrentTaskId();
     const currentTask = getTaskList().find((t) => t.id === originTaskId);
-    if (currentTask) {
-      currentTask.attachedFiles.push(file);
-    } else {
+    if (!currentTask) {
       throw new Error('未找到当前任务');
     }
 
@@ -262,10 +260,16 @@ export const useChatSend = (deps: ChatSendDeps) => {
     // 附件缺原始文件对象(如历史消息回填)时降级为纯文本重发
     if (!file.originalFile) {
       await streamAiAnswer(userInput);
-      return;
+    } else {
+      await streamAiAnswer(userInput, file);
     }
 
-    await streamAiAnswer(userInput, file);
+    // 附件入列放在请求成功之后:发送失败时任务资源里不应残留一个从未发出去的文件
+    //(用户消息上的附件卡不受影响,它随消息一起出现在气泡里)。
+    // 流式期间任务 id 可能已从本地临时 id 迁移为后端 session id,对象引用不变,这里按对象判断存活。
+    if (getTaskList().includes(currentTask) && !currentTask.attachedFiles.some((f) => f.id === file.id)) {
+      currentTask.attachedFiles.push(file);
+    }
   };
 
   // 补齐消息的数据库主键 id(刚流式生成的消息前端没有 id,但后端已持久化)。
@@ -456,7 +460,23 @@ export const useChatSend = (deps: ChatSendDeps) => {
       }
     }
     // 本地截断到编辑位置(原地截断,始终操作同一个任务的消息数组)
+    const removedMessages = msgs.slice(index);
     msgs.splice(index);
+
+    // 被截断消息上的附件文件仍留在服务端(message_id 变 null)。
+    // 被编辑的这条消息的本地附件会随新消息重发,不转兜底;其余附件立即转入任务兜底列表,
+    // 避免要刷新页面才重新出现。
+    const detachedFiles = removedMessages
+      .flatMap((m) => [m.attachedFile, ...(m.attachedFiles || [])])
+      .filter((file): file is AttachedFile => !!file && file.id !== attachedFile?.id);
+    if (detachedFiles.length) {
+      const orphans = [...(task.orphanAttachments || [])];
+      for (const file of detachedFiles) {
+        if (!orphans.some((item) => item.id === file.id)) orphans.push(file);
+      }
+      task.orphanAttachments = orphans;
+    }
+
     const now = dayjs();
     const userMsg: Message = {
       type: 'user',

+ 29 - 0
src/views/ventAI/manageAssistent/lib/fileIcon.ts

@@ -34,6 +34,35 @@ export const getFileExtension = (filename: string): string => {
   const match = filename.match(/\.([^.]+)$/);
   return match ? `.${match[1]}`.toUpperCase() : '';
 };
+
+// 预览类文件的 MIME(PDF / 图片)。
+// 附件下载、报告下载这类接口常统一回 application/octet-stream(还带 Content-Disposition: attachment),
+// 浏览器凭这个类型认不出内容——PDF 放进 iframe 会被当成下载、图片也可能显示不出来。
+const PREVIEW_MIME_BY_EXT: Record<string, string> = {
+  pdf: 'application/pdf',
+  png: 'image/png',
+  jpg: 'image/jpeg',
+  jpeg: 'image/jpeg',
+  gif: 'image/gif',
+  webp: 'image/webp',
+  bmp: 'image/bmp',
+  svg: 'image/svg+xml',
+  ico: 'image/x-icon',
+  tif: 'image/tiff',
+  tiff: 'image/tiff',
+};
+
+/**
+ * 取预览用的真实 MIME:优先用已知类型(接口给的 content_type),不是有效值就按扩展名猜。
+ * @param filename 文件名
+ * @param fallbackType 接口/文件对象上带的类型,可能是 application/octet-stream 或空
+ */
+export const getPreviewMimeType = (filename: string, fallbackType?: string): string => {
+  const known = (fallbackType || '').toLowerCase();
+  if (known && known !== 'application/octet-stream') return known;
+  const ext = (filename.split('.').pop() || '').toLowerCase();
+  return PREVIEW_MIME_BY_EXT[ext] || known || '';
+};
 // CSS 变量里的图片地址(图标统一走 var(--img-chat-*)):带缓存,避免每帧 getComputedStyle;
 // 主题切换时 html 的 class 变化,清空缓存重新取。模块级共享,各组件读到的都是同一份文档级取值。
 

+ 9 - 4
src/views/ventAI/manageAssistent/lib/filePreview.ts

@@ -1,10 +1,11 @@
 // lib/filePreview.ts:由 lib/utils.ts 按职责拆分(实现未改)
 import { getToken } from '/@/utils/auth/index';
 import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/resource';
-import { isImageFile, isPdfFile, isWordFile, isExcelFile, isDwgFile } from './fileIcon';
+import { isImageFile, isPdfFile, isWordFile, isExcelFile, isDwgFile, getPreviewMimeType } from './fileIcon';
 
 export const fetchPreviewFile = async (url: string, filename: string): Promise<AttachedFile | null> => {
-  const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
+  const token = getToken();
+  const resp = await fetch(url, { headers: { ...(token ? { 'X-Access-Token': token } : {}) } });
   if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
   const ext = filename.split('.').pop()?.toLowerCase() || '';
   const mimeMap: Record<string, string> = {
@@ -33,13 +34,17 @@ export const fetchPreviewFile = async (url: string, filename: string): Promise<A
   }
   if (isImageFile(filename) || isPdfFile(filename)) {
     const blob = await resp.blob();
+    // 下载接口常回 application/octet-stream,浏览器凭它认不出是 PDF/图片(PDF 放进 iframe 会被当下载),
+    // 按文件名把类型纠正过来再生成预览地址
+    const type = getPreviewMimeType(filename, blob.type);
+    const typedBlob = type && type !== blob.type ? new Blob([blob], { type }) : blob;
     const dataUrl = await new Promise<string>((resolve, reject) => {
       const reader = new FileReader();
       reader.onload = () => resolve(reader.result as string);
       reader.onerror = () => reject(reader.error);
-      reader.readAsDataURL(blob);
+      reader.readAsDataURL(typedBlob);
     });
-    return { id, name: filename, size: blob.size, type: blob.type, preview: dataUrl, uploadTime };
+    return { id, name: filename, size: typedBlob.size, type: typedBlob.type, preview: dataUrl, uploadTime };
   }
   return null;
 };

+ 193 - 0
src/views/ventAI/manageAssistent/lib/modalPreviewBridge.ts

@@ -0,0 +1,193 @@
+/**
+ * 二维模型弹窗(iframe)父子通信 。
+ *
+ * 设计要点:
+ * 1. 单例:ModalPreview 挂载时注册目标 iframe,其它页面/组件直接 import 三个 locate 函数即可发送,
+ *    不需要拿到组件实例、也不用事件总线。
+ * 2. 握手:子应用挂载完成后回 `ready`,在此之前发出的消息进队列,ready 后按顺序补发。
+ *    这样"打开弹窗后立刻点定位"不会丢消息。
+ * 3. 校验:接收端同时校验 e.source(必须是当前 iframe 的 contentWindow)与 e.origin,
+ *    发送端用具体 origin,不用 '*'(消息里可能带业务数据)。
+ *
+ */
+
+export type LocateTunnelPayload = {
+  /**
+   * 模型 id,子应用用它校验当前加载的模型。
+   * 请传字符串:模型 id 是 19 位雪花号(如 1894646048929054721),
+   * 转成 number 会丢精度,子应用会拒绝处理。
+   */
+  model_id: string | number;
+  /** 巷道 id(子应用 tunsMap 的 ntunid) */
+  tunid: string | number;
+  deviceType: string;
+};
+
+export type LocateDevicePayload = {
+  model_id: string | number;
+  deviceType: string;
+  /** 基座侧设备 deviceID,子应用按 parentid 匹配虚拟设备 */
+  locationId: string | number;
+};
+
+export type LocateDeviceReportPayload = {
+  model_id: string | number;
+  deviceType: string;
+  /** 设备安装位置(基座 record.strInstallPos) */
+  locationName: string;
+};
+
+export type SetTokenMessage = { from: 'mky-base'; type: 'set-token'; payload: { token: string } };
+export type LocateTunnelMessage = { from: 'mky-base'; type: 'locate-tunnel'; payload: LocateTunnelPayload };
+export type LocateDeviceMessage = { from: 'mky-base'; type: 'locate-device'; payload: LocateDevicePayload };
+export type LocateDeviceReportMessage = { from: 'mky-base'; type: 'locate-device-report'; payload: LocateDeviceReportPayload };
+export type BaseToPreviewMessage = SetTokenMessage | LocateTunnelMessage | LocateDeviceMessage | LocateDeviceReportMessage;
+
+/** 子应用对一次定位请求的执行回执(联调测试用) */
+export type LocateResult = {
+  request: string;
+  ok: boolean;
+  message?: string;
+  /** 父级收到回执的时间戳 */
+  at: number;
+};
+
+const SOURCE_BASE = 'mky-base';
+const SOURCE_SUB = 'micro-vent-2dModal';
+// 子应用迟迟不 ready 时,队列不能无限增长(正常也就几条)
+const MAX_PENDING = 50;
+
+let targetGetter: (() => Window | null) | null = null;
+let targetOrigin = '';
+let tokenGetter: (() => string | undefined) | null = null;
+let ready = false;
+let pending: BaseToPreviewMessage[] = [];
+let listening = false;
+let lastResult: LocateResult | null = null;
+
+const getTargetWindow = (): Window | null => {
+  try {
+    return targetGetter?.() ?? null;
+  } catch {
+    return null;
+  }
+};
+
+const post = (message: BaseToPreviewMessage) => {
+  const win = getTargetWindow();
+  if (!win || !targetOrigin) return false;
+  win.postMessage(message, targetOrigin);
+  return true;
+};
+
+const flush = () => {
+  if (!ready) return;
+  const queue = pending;
+  pending = [];
+  queue.forEach(post);
+};
+
+/**
+ * 把基座的登录 token 补给子应用。
+ * iframe 与基座是两份独立的 localStorage(键名也不同),子应用自己取不到;
+ * 而按 model_id 切模型要请求需要鉴权的 Java 接口,缺 token 会被后端转成 500。
+ */
+const postToken = () => {
+  // tokenGetter 在 register 之后才会被赋值,这里显式放宽类型,避免 TS 把模块级可变变量
+  // 按声明时的初始值 null 收窄成 never
+  const getter = tokenGetter as (() => string | undefined) | null;
+  const token = getter?.();
+  if (!token) {
+    console.warn('[modalPreviewBridge] 当前没有登录 token,子应用切换模型可能因鉴权失败而报 500');
+    return;
+  }
+  post({ from: SOURCE_BASE, type: 'set-token', payload: { token } });
+};
+
+const handleMessage = (event: MessageEvent) => {
+  if (!targetGetter) return;
+  // 只认当前 iframe 发来的消息:同一页面里可能嵌着别的 iframe
+  if (event.source !== getTargetWindow()) return;
+  if (targetOrigin && event.origin !== targetOrigin) return;
+  const data = event.data;
+  if (!data || data.from !== SOURCE_SUB) return;
+  if (data.type === 'ready') {
+    ready = true;
+    // 先补 token,再放行排队的定位消息:切模型接口需要鉴权
+    postToken();
+    flush();
+    return;
+  }
+  // 定位执行回执:只在控制台与测试面板展示,不参与业务逻辑
+  if (data.type === 'locate-result' && data.payload) {
+    lastResult = { ...data.payload, at: Date.now() };
+    // eslint-disable-next-line no-console
+    console.info('[modalPreviewBridge] 子应用定位结果', lastResult);
+  }
+};
+
+/**
+ * 由 ModalPreview 在挂载时调用。
+ * @param getTargetWindow 返回 iframe 的 contentWindow(iframe 会被 v-if 重建,所以传函数而不是 window 引用)
+ * @param origin iframe 的来源,如 http://localhost:8088
+ * @param getToken 返回基座当前登录 token;子应用 ready 后会补发过去
+ */
+export const registerPreviewBridge = (getTargetWindow: () => Window | null, origin: string, getToken?: () => string | undefined) => {
+  targetGetter = getTargetWindow;
+  targetOrigin = origin;
+  tokenGetter = getToken ?? null;
+  ready = false;
+  lastResult = null;
+  if (!origin) console.warn('[modalPreviewBridge] 未解析到子应用 origin,定位消息将无法发送');
+  if (!listening) {
+    window.addEventListener('message', handleMessage);
+    listening = true;
+  }
+};
+
+/** 由 ModalPreview 在卸载时调用。 */
+export const unregisterPreviewBridge = () => {
+  targetGetter = null;
+  targetOrigin = '';
+  tokenGetter = null;
+  ready = false;
+  pending = [];
+  lastResult = null;
+  if (listening) {
+    window.removeEventListener('message', handleMessage);
+    listening = false;
+  }
+};
+
+/**
+ * iframe 被重建(重试加载、src 变更)时调用:先置回未就绪,
+ * 子应用重新挂载后会再发一次 ready。待发队列保留,下一次 ready 时补发。
+ */
+export const markPreviewNotReady = () => {
+  ready = false;
+};
+
+export const isPreviewReady = () => ready;
+
+/** 最近一次定位请求的执行回执(联调测试用) */
+export const getLastLocateResult = () => lastResult;
+
+const send = (message: BaseToPreviewMessage) => {
+  // 弹窗没打开(或已卸载)时直接丢弃:不要把旧定位消息留给下一次打开的弹窗
+  if (!targetGetter) return false;
+  if (!ready) {
+    if (pending.length >= MAX_PENDING) pending.shift();
+    pending.push(message);
+    return false;
+  }
+  return post(message);
+};
+
+/** 定位巷道:{ model_id, tunid, deviceType } */
+export const locateTunnel = (payload: LocateTunnelPayload) => send({ from: SOURCE_BASE, type: 'locate-tunnel', payload });
+
+/** 定位设备:{ model_id, deviceType, locationId } */
+export const locateDevice = (payload: LocateDevicePayload) => send({ from: SOURCE_BASE, type: 'locate-device', payload });
+
+/** 定位设备报表(按安装位置名称):{ model_id, deviceType, locationName } */
+export const locateDeviceReport = (payload: LocateDeviceReportPayload) => send({ from: SOURCE_BASE, type: 'locate-device-report', payload });

+ 40 - 0
src/views/ventAI/manageAssistent/lib/webLink.ts

@@ -22,6 +22,46 @@ export const panelOpenableUrl = (href?: string | null): string | null => {
   }
 };
 
+/** 二维模型预览链接解析结果 */
+export interface ModalPreviewLinkRoute {
+  /** 子应用内部路由(固定为二维预览页面) */
+  path: string;
+  /** 链接中携带的查询参数(model_id / tunid 等) */
+  query: Record<string, string>;
+}
+
+/**
+ * 识别二维模型预览链接并解析参数。
+ *
+ * 规则很简单:链接里只要包含 `modal-preview` 就走二维预览,不限定它后面跟什么格式;
+ * 参数从整条链接中提取 `key=value`,兼容 `?`、`&`、`/`、`#` 等分隔形式,
+ * 也兼容 `/?modal-preview?model_id=...` 这种写法。
+ */
+export const parseModalPreviewLink = (href?: string | null): ModalPreviewLinkRoute | null => {
+  const raw = (href || '').trim();
+  if (!raw.toLowerCase().includes('modal-preview')) return null;
+
+  const query: Record<string, string> = {};
+  const decode = (value: string): string => {
+    try {
+      return decodeURIComponent(value.replace(/\+/g, ' '));
+    } catch {
+      return value;
+    }
+  };
+
+  // 不依赖 modal-preview 的位置和后续分隔符,直接从整条链接抓取 key=value
+  const paramPattern = /[?&#/]([^=&?#/]+)=([^&#]*)/g;
+  let match: RegExpExecArray | null;
+  while ((match = paramPattern.exec(raw))) {
+    const key = decode(match[1]);
+    if (!key || key.toLowerCase().includes('modal-preview')) continue;
+    query[key] = decode(match[2]);
+  }
+
+  return { path: '/modal-preview', query };
+};
+
 /** 面板标签标题:优先用链接文字,没有就用路径(去掉域名,整条 URL 会把标签栏挤爆) */
 export const webTabTitle = (href: string, text?: string): string => {
   const label = (text || '').replace(/\s+/g, ' ').trim();

+ 4 - 0
src/views/ventAI/manageAssistent/types/chat.ts

@@ -62,6 +62,8 @@ export interface Message {
   isPendingApproval?: boolean;
   pendingApprovalData?: PendingApprovalData;
   attachedFile?: AttachedFile;
+  /** 历史回显的附件(同一消息可挂多个;本地刚发送的附件仍走 attachedFile) */
+  attachedFiles?: AttachedFile[];
   agentAnswers?: AgentAnswer[];
   thinkingSteps?: ThinkingStep[];
   sessionId?: string;
@@ -98,6 +100,8 @@ export interface Task {
   isStreaming?: boolean;
   // 流式期间从会话列表归并得到的后端会话 id(流结束前 sessionId 尚未返回)
   pendingSessionId?: string;
+  // 历史附件中无法按 message_id 挂到具体消息的条目(message_id 为 null 或消息未能匹配)
+  orphanAttachments?: AttachedFile[];
   // 右侧辅助面板标签页状态(按任务维度保存)
   rightPanels?: RightPanelState;
 }

+ 9 - 1
src/views/ventAI/manageAssistent/types/panel.ts

@@ -9,12 +9,20 @@ export interface AgentDetailData {
   agentId?: string;
 }
 
+/** 二维子应用内部路由(ModalPreview iframe 的初始页面) */
+export interface SubAppRoute {
+  path: string;
+  query?: Record<string, any>;
+}
+
 export type RightPanelTab =
   | { id: string; type: 'file'; title: string; file: AttachedFile; editable: boolean }
   | { id: string; type: 'agent'; title: string; messageIndex: number; agent: string; agentId?: string }
   | { id: string; type: 'schedule'; title: string; task: ScheduleTask }
   // 站内页面:url 一定是同源绝对地址(判定见 lib/webLink.ts),内容区用 iframe 嵌
-  | { id: string; type: 'web'; title: string; url: string };
+  | { id: string; type: 'web'; title: string; url: string }
+  // 二维通风模型:与文件/网页预览同级,仅激活该标签时挂载 ModalPreview
+  | { id: string; type: 'model2d'; title: string; subAppRoute?: SubAppRoute | null };
 
 export interface RightPanelState {
   tabs: RightPanelTab[];

+ 8 - 1
src/views/ventAI/manageAssistent/types/resource.ts

@@ -8,7 +8,14 @@ export interface AttachedFile {
   content?: string;
   arrayBuffer?: ArrayBuffer;
   uploadTime: string;
-  messageId?: string;
+  /** 服务端附件 ID(历史回显时用于预览/下载) */
+  fileId?: string;
+  /** 关联的用户消息主键;null 表示附件未关联到已落库的消息 */
+  messageId?: number | null;
+  /** 服务端返回的下载地址(/api/files/...) */
+  downloadUrl?: string;
+  /** 服务端记录的上传时间 */
+  createdAt?: string;
   originalFile?: File;
 }
 

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff