Browse Source

Merge branch 'master' of http://39.97.59.228:8013/hrx/mky-vent-base

lxh 1 month ago
parent
commit
07f1c6d521
94 changed files with 7986 additions and 10516 deletions
  1. 4 0
      build/vite/plugin/index.ts
  2. 2 19
      package.json
  3. 1581 8164
      pnpm-lock.yaml
  4. BIN
      public/texture/earth.jpg
  5. BIN
      public/texture/earth3.png
  6. 11 0
      src/api/sys/modelFile.ts
  7. BIN
      src/assets/images/company/home-pageBg.png
  8. BIN
      src/assets/images/gif/fanlocal.gif
  9. BIN
      src/assets/images/home-container/configurable/minehome/bg.png
  10. BIN
      src/assets/images/vent/homeNew/bg.png
  11. BIN
      src/assets/images/vent/loginDataCenter/dataCenterBg.png
  12. BIN
      src/assets/images/vent/loginNeiye/bg.png
  13. BIN
      src/assets/images/ventAI/chatModalBlue/1-1.png
  14. 10 0
      src/assets/images/ventAI/chatModalBlue/1-10.svg
  15. 10 0
      src/assets/images/ventAI/chatModalBlue/1-11.svg
  16. 9 0
      src/assets/images/ventAI/chatModalBlue/1-12.svg
  17. 9 0
      src/assets/images/ventAI/chatModalBlue/1-13.svg
  18. 10 0
      src/assets/images/ventAI/chatModalBlue/1-14.svg
  19. 10 0
      src/assets/images/ventAI/chatModalBlue/1-15.svg
  20. BIN
      src/assets/images/ventAI/chatModalBlue/1-2.png
  21. BIN
      src/assets/images/ventAI/chatModalBlue/1-3.png
  22. BIN
      src/assets/images/ventAI/chatModalBlue/1-4.png
  23. BIN
      src/assets/images/ventAI/chatModalBlue/1-5.png
  24. BIN
      src/assets/images/ventAI/chatModalBlue/1-6.png
  25. BIN
      src/assets/images/ventAI/chatModalBlue/1-7.png
  26. 3 0
      src/assets/images/ventAI/chatModalBlue/2-10.svg
  27. 3 0
      src/assets/images/ventAI/chatModalBlue/2-11.svg
  28. BIN
      src/assets/images/ventAI/chatModalBlue/2-13.png
  29. BIN
      src/assets/images/ventAI/chatModalBlue/2-15.png
  30. BIN
      src/assets/images/ventAI/chatModalBlue/2-6.png
  31. BIN
      src/assets/images/ventAI/chatModalBlue/2-7.png
  32. BIN
      src/assets/images/ventAI/chatModalBlue/2-8.png
  33. BIN
      src/assets/images/ventAI/chatModalBlue/2-9.png
  34. BIN
      src/assets/images/ventAI/chatModalBlue/3-1.png
  35. BIN
      src/assets/images/ventAI/chatModalBlue/3-2.png
  36. BIN
      src/assets/images/ventAI/chatModalBlue/3-5.png
  37. BIN
      src/assets/images/ventAI/chatModalBlue/3-6.png
  38. 7 0
      src/assets/images/ventAI/chatModalBlue/3-7.svg
  39. BIN
      src/assets/images/ventAI/chatModalBlue/3-8.png
  40. BIN
      src/assets/images/ventAI/chatModalBlue/4-1.png
  41. 1 0
      src/assets/images/ventAI/chatModalBlue/4-2.svg
  42. 1 0
      src/assets/images/ventAI/chatModalBlue/4-3.svg
  43. 1 0
      src/assets/images/ventAI/chatModalBlue/4-4.svg
  44. BIN
      src/assets/images/zd-model.png
  45. 1 1
      src/components/AIChat/MiniChat.vue
  46. 2 0
      src/components/Form/src/componentMap.ts
  47. 133 0
      src/components/Form/src/jeecg/components/ModelSelect.vue
  48. 1 0
      src/components/Form/src/types/index.ts
  49. 2 4
      src/components/Markdown/src/MarkdownViewer.vue
  50. 5 4
      src/components/Table/src/hooks/useCustomSelection.tsx
  51. 34 0
      src/design/themify/deepblue.less
  52. 39 0
      src/design/themify/default.less
  53. 121 1
      src/layouts/default/index.vue
  54. 5 0
      src/router/guard/index.ts
  55. 38 0
      src/store/modules/modelFile.ts
  56. 4 2
      src/utils/encryption/signMd5Utils.js
  57. 1 1
      src/views/monitor/trace/trace.data.ts
  58. 128 136
      src/views/vent/deviceManager/comment/DeviceReportInfo.vue
  59. 10 3
      src/views/vent/deviceManager/comment/NormalTable.vue
  60. 44 54
      src/views/vent/deviceManager/deviceTable/index.vue
  61. 24 28
      src/views/vent/deviceManager/workingFace/index.vue
  62. 152 152
      src/views/vent/home/configurable/blue/fireNew.vue
  63. 11 48
      src/views/vent/home/configurable/components/preset/SysWindCard.vue
  64. 4 4
      src/views/vent/monitorManager/alarmMonitor/common/predictionCurve.vue
  65. 45 54
      src/views/vent/monitorManager/comment/components/DeviceBaseInfo.vue
  66. 57 19
      src/views/vent/monitorManager/deviceMonitor/components/device/modal/atomizing.modal.vue
  67. 17 7
      src/views/vent/monitorManager/fanLocalMonitor/fanLocal.threejs.ts
  68. 700 750
      src/views/vent/monitorManager/fanLocalMonitor/index.vue
  69. 0 2
      src/views/vent/monitorManager/fireDoorMonitor/fireDoor.threejs.ymh.ts
  70. 6 3
      src/views/vent/monitorManager/gateMonitor/gate.threejs.ts
  71. 521 521
      src/views/vent/monitorManager/gateMonitor/index.vue
  72. 11 0
      src/views/vent/monitorManager/mainFanMonitor/index.vue
  73. 0 9
      src/views/vent/monitorManager/windowMonitor/components/windowDualAdjustSVG.vue
  74. 2 2
      src/views/ventAI/dataPicker/api.ts
  75. 0 60
      src/views/ventAI/dataPicker/components/DataInterpretationBtn.vue
  76. 150 122
      src/views/ventAI/dataPicker/components/DataPickerModal.vue
  77. 3 6
      src/views/ventAI/dataPicker/index.ts
  78. 0 20
      src/views/ventAI/dataPicker/types.ts
  79. 0 185
      src/views/ventAI/dataPicker/useDataInterpretation.ts
  80. 0 124
      src/views/ventAI/dataPicker/useDataInterpretationTrigger.ts
  81. 409 0
      src/views/ventAI/dataPicker/useDataPicker.ts
  82. 105 0
      src/views/ventAI/manageAssistent/api.ts
  83. 1119 0
      src/views/ventAI/manageAssistent/components/AiAssistantModal.vue
  84. 224 0
      src/views/ventAI/manageAssistent/components/chatModal/ChatInputArea.vue
  85. 1050 0
      src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue
  86. 133 0
      src/views/ventAI/manageAssistent/components/chatModal/FileListPanel.vue
  87. 141 0
      src/views/ventAI/manageAssistent/components/chatModal/FilePreviewPanel.vue
  88. 290 0
      src/views/ventAI/manageAssistent/components/chatModal/TaskListPanel.vue
  89. 397 0
      src/views/ventAI/manageAssistent/components/chatModal/ThinkingPanel.vue
  90. 64 0
      src/views/ventAI/manageAssistent/components/chatModal/types.ts
  91. 90 0
      src/views/ventAI/manageAssistent/components/chatModal/utils.ts
  92. 1 0
      types/config.d.ts
  93. 9 0
      types/global.d.ts
  94. 11 11
      vite.config.ts

+ 4 - 0
build/vite/plugin/index.ts

@@ -16,6 +16,7 @@ import { configVisualizerConfig } from './visualizer';
 import { configThemePlugin } from './theme';
 import { configSvgIconsPlugin } from './svgSprite';
 import { configServerPlugin } from './serveStatic';
+import { configImageminPlugin } from './imagemin';
 // //预编译加载插件(不支持vite3作废)
 // import OptimizationPersist from 'vite-plugin-optimize-persist';
 import topLevelAwait from 'vite-plugin-top-level-await';
@@ -72,6 +73,9 @@ export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean) {
   if (isBuild) {
     // rollup-plugin-gzip
     vitePlugins.push(configCompressPlugin(VITE_BUILD_COMPRESS, VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE));
+
+    // vite-plugin-imagemin: 图片压缩 (仅生产环境)
+    vitePlugins.push(configImageminPlugin());
   }
 
   // //vite-plugin-theme【预编译加载插件,解决vite首次打开界面加载慢问题】

+ 2 - 19
package.json

@@ -30,7 +30,6 @@
     "@logicflow/core": "^1.2.12",
     "@logicflow/extension": "^1.2.13",
     "@qiaoqiaoyun/drag-free": "^1.1.4",
-    "@types/markdown-it": "^14.1.2",
     "@vue-office/docx": "^1.6.3",
     "@vue-office/excel": "^1.7.14",
     "@vue-office/pdf": "^2.0.10",
@@ -54,40 +53,29 @@
     "dom-align": "^1.12.4",
     "echarts": "^6.0.0",
     "emoji-mart-vue-fast": "^15.0.0",
-    "enquire.js": "^2.1.6",
     "gsap": "^3.11.3",
     "highlight.js": "^11.11.1",
     "hls.js": "^1.6.15",
     "html2canvas": "^1.4.1",
-    "intro.js": "^7.2.0",
     "katex": "^0.16.25",
     "lodash-es": "^4.17.21",
-    "lodash.get": "^4.4.2",
-    "markdown-it": "^14.3.0",
     "marked": "^17.0.1",
-    "md5": "^2.3.0",
     "mky-svg": "^1.0.2",
-    "moment": "^2.29.4",
     "nprogress": "^0.2.0",
     "path-to-regexp": "^6.2.1",
     "pinia": "2.1.6",
     "plyr": "^3.7.8",
-    "pnpm": "^10.32.1",
     "print-js": "^1.6.0",
     "qiankun": "^2.10.16",
     "qrcode": "^1.5.3",
-    "qrcodejs2": "0.0.2",
     "qs": "^6.11.2",
     "resize-observer-polyfill": "^1.5.1",
-    "showdown": "^2.1.0",
     "sortablejs": "^1.15.0",
     "three": "^0.162.0",
     "three.path": "^1.0.1",
     "tinymce": "^5.10.3",
     "vditor": "^3.9.5",
     "vue": "^3.2.0",
-    "vue-cropper": "^0.6.2",
-    "vue-cropperjs": "^5.0.0",
     "vue-draggable-resizable": "^3.0.0",
     "vue-i18n": "9.2.2",
     "vue-infinite-scroll": "^2.0.2",
@@ -117,7 +105,6 @@
     "@types/crypto-js": "^4.1.1",
     "@types/fs-extra": "^11.0.1",
     "@types/inquirer": "^9.0.3",
-    "@types/intro.js": "^5.1.1",
     "@types/jest": "^29.5.4",
     "@types/lodash-es": "^4.17.8",
     "@types/mockjs": "^1.0.7",
@@ -125,16 +112,12 @@
     "@types/nprogress": "^0.2.0",
     "@types/qrcode": "^1.5.1",
     "@types/qs": "^6.9.7",
-    "@types/showdown": "^2.0.1",
     "@types/sortablejs": "^1.15.1",
     "@types/three": "^0.162.0",
     "@typescript-eslint/eslint-plugin": "^6.4.1",
     "@typescript-eslint/parser": "^6.5.0",
-    "@vitejs/plugin-legacy": "^2.0.0",
     "@vitejs/plugin-vue": "^4.3.3",
     "@vitejs/plugin-vue-jsx": "^3.0.2",
-    "@vue-office/docx": "^1.6.3",
-    "@vue-office/pdf": "^2.0.10",
     "@vue/compiler-sfc": "^3.3.4",
     "@vue/test-utils": "^2.4.1",
     "autoprefixer": "^10.4.16",
@@ -182,16 +165,16 @@
     "typescript": "^4.9.5",
     "unocss": "^0.55.3",
     "video.js": "^8.23.4",
-    "videojs-flvjs-es6": "^1.0.1",
     "vite": "^4.4.9",
     "vite-plugin-compression": "^0.5.1",
     "vite-plugin-html": "^3.2.0",
+    "vite-plugin-imagemin": "^0.6.1",
     "vite-plugin-mkcert": "^1.16.0",
     "vite-plugin-mock": "^2.9.6",
     "vite-plugin-purge-icons": "^0.9.2",
     "vite-plugin-style-import": "^2.0.0",
     "vite-plugin-svg-icons": "^2.0.1",
-    "vite-plugin-top-level-await": "^1.3.1",
+    "vite-plugin-top-level-await": "^1.6.0",
     "vite-plugin-vue-setup-extend-plus": "^0.1.0",
     "vue-eslint-parser": "^9.3.1",
     "vue-tsc": "^1.8.8"

File diff suppressed because it is too large
+ 1581 - 8164
pnpm-lock.yaml


BIN
public/texture/earth.jpg


BIN
public/texture/earth3.png


+ 11 - 0
src/api/sys/modelFile.ts

@@ -0,0 +1,11 @@
+import { defHttp } from '/@/utils/http/axios';
+
+enum Api {
+  getAllModelFileJson = '/ventanaly-device/safety/modelAlarmInfo/getAllModelFileJson',
+}
+
+export const getAllModelFileJsonApi = () => {
+  return defHttp.get<Record<string, { fileName: string; filePath: string }[]>>({
+    url: Api.getAllModelFileJson,
+  });
+};

BIN
src/assets/images/company/home-pageBg.png


BIN
src/assets/images/gif/fanlocal.gif


BIN
src/assets/images/home-container/configurable/minehome/bg.png


BIN
src/assets/images/vent/homeNew/bg.png


BIN
src/assets/images/vent/loginDataCenter/dataCenterBg.png


BIN
src/assets/images/vent/loginNeiye/bg.png


BIN
src/assets/images/ventAI/chatModalBlue/1-1.png


+ 10 - 0
src/assets/images/ventAI/chatModalBlue/1-10.svg

@@ -0,0 +1,10 @@
+<svg id="组_16756" data-name="组 16756" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="12.442" height="12.482" viewBox="0 0 12.442 12.482">
+  <defs>
+    <linearGradient id="linear-gradient" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox">
+      <stop offset="0" stop-color="#659ebc"/>
+      <stop offset="1" stop-color="#207aab"/>
+    </linearGradient>
+  </defs>
+  <path id="路径_57050" data-name="路径 57050" d="M6.37,0A4.74,4.74,0,1,1,1.63,4.74,4.741,4.741,0,0,1,6.37,0Zm0,1.435a3.305,3.305,0,1,0,3.3,3.305,3.305,3.305,0,0,0-3.3-3.305ZM9.653,8.888c-.113.082-.14.11-.054.222.666.871,1.386,1.8,2.049,2.665,1.4,1.827,3.541-.293,1.73-1.71-.86-.673-1.777-1.4-2.64-2.08-.111-.087-.138-.06-.222.052a3.921,3.921,0,0,1-.862.852Z" transform="translate(-1.63)" fill="url(#linear-gradient)"/>
+  <path id="路径_57051" data-name="路径 57051" d="M195.648,194.975h4.522a.12.12,0,0,1,.12.12v1.043a.12.12,0,0,1-.12.12h-4.522a.12.12,0,0,1-.12-.12v-1.043a.12.12,0,0,1,.12-.12Z" transform="translate(-193.143 -190.863)" fill="url(#linear-gradient)"/>
+</svg>

+ 10 - 0
src/assets/images/ventAI/chatModalBlue/1-11.svg

@@ -0,0 +1,10 @@
+<svg id="组_16759" data-name="组 16759" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="12.442" height="12.482" viewBox="0 0 12.442 12.482">
+  <defs>
+    <linearGradient id="linear-gradient" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox">
+      <stop offset="0" stop-color="#bce8ff"/>
+      <stop offset="1" stop-color="#28a6ea"/>
+    </linearGradient>
+  </defs>
+  <path id="路径_57050" data-name="路径 57050" d="M6.37,0A4.74,4.74,0,1,1,1.63,4.74,4.741,4.741,0,0,1,6.37,0Zm0,1.435a3.305,3.305,0,1,0,3.3,3.305,3.305,3.305,0,0,0-3.3-3.305ZM9.653,8.888c-.113.082-.14.11-.054.222.666.871,1.386,1.8,2.049,2.665,1.4,1.827,3.541-.293,1.73-1.71-.86-.673-1.777-1.4-2.64-2.08-.111-.087-.138-.06-.222.052a3.921,3.921,0,0,1-.862.852Z" transform="translate(-1.63)" fill="url(#linear-gradient)"/>
+  <path id="路径_57051" data-name="路径 57051" d="M195.648,194.975h4.522a.12.12,0,0,1,.12.12v1.043a.12.12,0,0,1-.12.12h-4.522a.12.12,0,0,1-.12-.12v-1.043a.12.12,0,0,1,.12-.12Z" transform="translate(-193.143 -190.863)" fill="url(#linear-gradient)"/>
+</svg>

+ 9 - 0
src/assets/images/ventAI/chatModalBlue/1-12.svg

@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="12.442" height="12.414" viewBox="0 0 12.442 12.414">
+  <defs>
+    <linearGradient id="linear-gradient" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox">
+      <stop offset="0" stop-color="#659ebc"/>
+      <stop offset="1" stop-color="#207aab"/>
+    </linearGradient>
+  </defs>
+  <path id="路径_58051" data-name="路径 58051" d="M114.815,184.972a5.445,5.445,0,0,0-.124,1,4.328,4.328,0,0,0,4.355,4.355,3.757,3.757,0,0,0,1.866-.436v2.364a1.673,1.673,0,0,1-1.68,1.68h-2.551l-.933,1.12a.532.532,0,0,1-.809.062.061.061,0,0,1-.062-.062l-.933-1.12h-2.551a1.673,1.673,0,0,1-1.68-1.68v-5.6a1.673,1.673,0,0,1,1.68-1.68Zm4.23,4.106a3.111,3.111,0,1,1,3.111-3.111A3.08,3.08,0,0,1,119.045,189.078Zm.373-3.484v-1.306a.378.378,0,1,0-.747,0v1.618a.229.229,0,0,0,.062.187.342.342,0,0,0,.311.187h1.12a.378.378,0,1,0,0-.747h-.747Z" transform="translate(-109.713 -182.857)" fill="url(#linear-gradient)"/>
+</svg>

+ 9 - 0
src/assets/images/ventAI/chatModalBlue/1-13.svg

@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="12.442" height="12.414" viewBox="0 0 12.442 12.414">
+  <defs>
+    <linearGradient id="linear-gradient" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox">
+      <stop offset="0" stop-color="#bce8ff"/>
+      <stop offset="1" stop-color="#28a6ea"/>
+    </linearGradient>
+  </defs>
+  <path id="路径_58052" data-name="路径 58052" d="M114.815,184.972a5.445,5.445,0,0,0-.124,1,4.328,4.328,0,0,0,4.355,4.355,3.757,3.757,0,0,0,1.866-.436v2.364a1.673,1.673,0,0,1-1.68,1.68h-2.551l-.933,1.12a.532.532,0,0,1-.809.062.061.061,0,0,1-.062-.062l-.933-1.12h-2.551a1.673,1.673,0,0,1-1.68-1.68v-5.6a1.673,1.673,0,0,1,1.68-1.68Zm4.23,4.106a3.111,3.111,0,1,1,3.111-3.111A3.08,3.08,0,0,1,119.045,189.078Zm.373-3.484v-1.306a.378.378,0,1,0-.747,0v1.618a.229.229,0,0,0,.062.187.342.342,0,0,0,.311.187h1.12a.378.378,0,1,0,0-.747h-.747Z" transform="translate(-109.713 -182.857)" fill="url(#linear-gradient)"/>
+</svg>

+ 10 - 0
src/assets/images/ventAI/chatModalBlue/1-14.svg

@@ -0,0 +1,10 @@
+<svg id="组_16757" data-name="组 16757" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="13.901" height="13.9" viewBox="0 0 13.901 13.9">
+  <defs>
+    <linearGradient id="linear-gradient" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox">
+      <stop offset="0" stop-color="#659ebc"/>
+      <stop offset="1" stop-color="#207aab"/>
+    </linearGradient>
+  </defs>
+  <path id="路径_58006" data-name="路径 58006" d="M464.881,147.213a.881.881,0,0,1-.881-.881V134.193a.881.881,0,0,1,1.762,0v12.139A.881.881,0,0,1,464.881,147.213Z" transform="translate(-457.931 -133.312)" fill="url(#linear-gradient)"/>
+  <path id="路径_58007" data-name="路径 58007" d="M146.332,465.762H134.193a.881.881,0,0,1,0-1.762h12.139a.881.881,0,1,1,0,1.762Z" transform="translate(-133.312 -457.931)" fill="url(#linear-gradient)"/>
+</svg>

+ 10 - 0
src/assets/images/ventAI/chatModalBlue/1-15.svg

@@ -0,0 +1,10 @@
+<svg id="组_16758" data-name="组 16758" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="13.901" height="13.9" viewBox="0 0 13.901 13.9">
+  <defs>
+    <linearGradient id="linear-gradient" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox">
+      <stop offset="0" stop-color="#bce8ff"/>
+      <stop offset="1" stop-color="#28a6ea"/>
+    </linearGradient>
+  </defs>
+  <path id="路径_58006" data-name="路径 58006" d="M464.881,147.213a.881.881,0,0,1-.881-.881V134.193a.881.881,0,0,1,1.762,0v12.139A.881.881,0,0,1,464.881,147.213Z" transform="translate(-457.931 -133.312)" fill="url(#linear-gradient)"/>
+  <path id="路径_58007" data-name="路径 58007" d="M146.332,465.762H134.193a.881.881,0,0,1,0-1.762h12.139a.881.881,0,1,1,0,1.762Z" transform="translate(-133.312 -457.931)" fill="url(#linear-gradient)"/>
+</svg>

BIN
src/assets/images/ventAI/chatModalBlue/1-2.png


BIN
src/assets/images/ventAI/chatModalBlue/1-3.png


BIN
src/assets/images/ventAI/chatModalBlue/1-4.png


BIN
src/assets/images/ventAI/chatModalBlue/1-5.png


BIN
src/assets/images/ventAI/chatModalBlue/1-6.png


BIN
src/assets/images/ventAI/chatModalBlue/1-7.png


+ 3 - 0
src/assets/images/ventAI/chatModalBlue/2-10.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16.836" height="18.704" viewBox="0 0 16.836 18.704">
+  <path id="路径_58030" data-name="路径 58030" d="M143.9,102.337a.935.935,0,1,1,0,1.871H128.936a.935.935,0,1,1,0-1.871H143.9ZM136.419,85.5a.935.935,0,0,1,.935.935v11.3l4.48-4.48a.935.935,0,0,1,1.4,1.234l-.079.088-6.081,6.085a.935.935,0,0,1-1.235.079l-.088-.078L129.678,94.6a.935.935,0,0,1,1.233-1.4l.088.078,4.484,4.475V86.439A.935.935,0,0,1,136.419,85.5Z" transform="translate(-128 -85.504)" fill="#1889ed"/>
+</svg>

+ 3 - 0
src/assets/images/ventAI/chatModalBlue/2-11.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="18.843" height="18.705" viewBox="0 0 18.843 18.705">
+  <path id="路径_58031" data-name="路径 58031" d="M65.722,76.638H70.4l-5.141,5.139a.974.974,0,0,0,1.378,1.378l5.221-5.221v4.639a.975.975,0,0,0,1.95,0V75.661a.974.974,0,0,0-.975-.974h-7.11a.976.976,0,0,0,0,1.952ZM75.5,73.7h7.111a.974.974,0,0,0,0-1.949H77.939l5.141-5.141A.974.974,0,0,0,81.7,65.234L76.48,70.454V65.817a.975.975,0,1,0-1.95,0v6.911a.974.974,0,0,0,.975.974Z" transform="translate(-64.747 -64.842)" fill="#1889ed"/>
+</svg>

BIN
src/assets/images/ventAI/chatModalBlue/2-13.png


BIN
src/assets/images/ventAI/chatModalBlue/2-15.png


BIN
src/assets/images/ventAI/chatModalBlue/2-6.png


BIN
src/assets/images/ventAI/chatModalBlue/2-7.png


BIN
src/assets/images/ventAI/chatModalBlue/2-8.png


BIN
src/assets/images/ventAI/chatModalBlue/2-9.png


BIN
src/assets/images/ventAI/chatModalBlue/3-1.png


BIN
src/assets/images/ventAI/chatModalBlue/3-2.png


BIN
src/assets/images/ventAI/chatModalBlue/3-5.png


BIN
src/assets/images/ventAI/chatModalBlue/3-6.png


+ 7 - 0
src/assets/images/ventAI/chatModalBlue/3-7.svg

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="41.266" height="48.044" viewBox="0 0 41.266 48.044">
+  <g id="组_16767" data-name="组 16767" transform="translate(-102.4 -34.133)">
+    <path id="路径_58014" data-name="路径 58014" d="M296.429,342.194H271.378a3.442,3.442,0,0,1-3.432-3.432V312.338a3.442,3.442,0,0,1,3.432-3.432h25.051a3.442,3.442,0,0,1,3.432,3.432v26.424A3.442,3.442,0,0,1,296.429,342.194Z" transform="translate(-157.225 -260.96)" fill="#1e6f9f" opacity="0.3"/>
+    <path id="路径_58015" data-name="路径 58015" d="M138.947,82.177H107.033a4.614,4.614,0,0,1-4.633-4.633V38.766a4.614,4.614,0,0,1,4.633-4.633h32a4.614,4.614,0,0,1,4.633,4.633V77.458a4.7,4.7,0,0,1-4.719,4.719Zm-31.915-45.47a2.107,2.107,0,0,0-2.145,2.059V77.458a2.088,2.088,0,0,0,2.059,2.059h32a2.088,2.088,0,0,0,2.059-2.059V38.766a1.981,1.981,0,0,0-2.059-2.059H107.033Z" transform="translate(0)" fill="#1e6f9f"/>
+    <path id="路径_58016" data-name="路径 58016" d="M311.82,233.17H294.834a1.287,1.287,0,0,1,0-2.574H311.82a1.287,1.287,0,0,1,0,2.574Zm-8.493,6.863h-8.493a1.287,1.287,0,0,1,0-2.574h8.493a1.287,1.287,0,0,1,0,2.574Zm6.778-15.614H296.549a3.04,3.04,0,0,1-3-3v-8.493a3.04,3.04,0,0,1,3-3H310.1a3.04,3.04,0,0,1,3,3v8.493A3.04,3.04,0,0,1,310.1,224.419Zm-13.555-11.925a.405.405,0,0,0-.429.429v8.493a.405.405,0,0,0,.429.429H310.1a.405.405,0,0,0,.429-.429v-8.493a.405.405,0,0,0-.429-.429Z" transform="translate(-181.538 -166.95)" fill="#1e6f9f"/>
+  </g>
+</svg>

BIN
src/assets/images/ventAI/chatModalBlue/3-8.png


BIN
src/assets/images/ventAI/chatModalBlue/4-1.png


+ 1 - 0
src/assets/images/ventAI/chatModalBlue/4-2.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1784266963109" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10842" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M532.207 718.134l0 0c3.079-1.777 6.39-3.079 9.112-5.802 0.477-0.594 0.827-1.064 1.303-1.539 0.594-0.709 1.539-0.946 2.128-1.54l319.434-331.383c8.519-8.757 12.781-20.121 12.781-31.483 0-11.836-4.733-23.79-13.964-32.666-18.107-17.398-46.871-16.924-64.265 1.183l-288.071 298.956-283.922-300.139c-17.28-18.225-46.04-19.057-64.265-1.777-18.226 17.281-19.057 46.04-1.777 64.265l316.355 334.462c13.844 14.676 35.149 17.399 52.429 9.113 0.945-0.35 1.777-1.182 2.722-1.653l0 0zM532.207 718.134z" fill="#ffffff" p-id="10843"></path></svg>

+ 1 - 0
src/assets/images/ventAI/chatModalBlue/4-3.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1784266918614" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8777" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M833.33 767.96h-91.9c-21.73 0-39.34-17.6-39.34-39.34s17.62-39.34 39.34-39.34h91.9c8.82 0 15.98-7.18 15.98-15.98V193.8c0-8.8-7.17-15.98-15.98-15.98H353.84c-8.82 0-15.98 7.18-15.98 15.98v90.86c0 21.75-17.62 39.34-39.34 39.34s-39.34-17.6-39.34-39.34V193.8c0-52.21 42.47-94.67 94.67-94.67h479.49c52.19 0 94.67 42.45 94.67 94.67v479.49c-0.01 52.21-42.49 94.67-94.68 94.67z" fill="#ffffff" p-id="8778"></path><path d="M675.96 925.33H196.47c-52.19 0-94.67-42.45-94.67-94.67V351.17c0-52.21 42.47-94.67 94.67-94.67h479.49c52.19 0 94.67 42.45 94.67 94.67v479.49c-0.01 52.22-42.48 94.67-94.67 94.67zM196.47 335.19c-8.82 0-15.98 7.18-15.98 15.98v479.49c0 8.8 7.17 15.98 15.98 15.98h479.49c8.82 0 15.98-7.18 15.98-15.98V351.17c0-8.8-7.17-15.98-15.98-15.98H196.47z" fill="#ffffff" p-id="8779"></path></svg>

+ 1 - 0
src/assets/images/ventAI/chatModalBlue/4-4.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1784266863166" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7525" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M807.538939 256.459755a20.897959 20.897959 0 0 1 0 29.549714L571.099429 522.44898l236.43951 236.43951a20.897959 20.897959 0 0 1 0 29.549714l-29.549715 29.549714a20.897959 20.897959 0 0 1-29.549714 0L512 581.548408 275.56049 817.987918a20.897959 20.897959 0 0 1-29.549714 0l-29.549715-29.549714a20.897959 20.897959 0 0 1 0-29.549714L452.900571 522.44898 216.461061 286.009469a20.897959 20.897959 0 0 1 0-29.549714l29.549715-29.549714a20.897959 20.897959 0 0 1 29.549714 0L512 463.349551l236.43951-236.43951a20.897959 20.897959 0 0 1 29.549714 0l29.549715 29.549714z" fill="#ffffff" p-id="7526"></path></svg>

BIN
src/assets/images/zd-model.png


+ 1 - 1
src/components/AIChat/MiniChat.vue

@@ -476,7 +476,7 @@
   const parseMarkdownWithLatex = (mdStr) => {
     if (!mdStr) return '';
     try {
-      let html = marked(mdStr); // Markdown → HTML
+      let html = marked.parse(mdStr); // Markdown → HTML
       html = renderLatexInHtml(html); // 替换公式
       return html;
     } catch (error) {

+ 2 - 0
src/components/Form/src/componentMap.ts

@@ -61,6 +61,7 @@ import JSearchSelect from './jeecg/components/JSearchSelect.vue';
 import JAddInput from './jeecg/components/JAddInput.vue';
 import { Time } from '/@/components/Time';
 import JRangeNumber from './jeecg/components/JRangeNumber.vue';
+import ModelSelect from './jeecg/components/ModelSelect.vue';
 import UserSelect from './jeecg/components/userSelect/index.vue';
 import JRangeDate from './jeecg/components/JRangeDate.vue';
 import JRangeTime from './jeecg/components/JRangeTime.vue';
@@ -134,6 +135,7 @@ componentMap.set('JUpload', JUpload);
 componentMap.set('JSearchSelect', JSearchSelect);
 componentMap.set('JAddInput', JAddInput);
 componentMap.set('JRangeNumber', JRangeNumber);
+componentMap.set('ModelSelect', ModelSelect);
 componentMap.set('UserSelect', UserSelect);
 componentMap.set('RangeDate', JRangeDate);
 componentMap.set('RangeTime', JRangeTime);

+ 133 - 0
src/components/Form/src/jeecg/components/ModelSelect.vue

@@ -0,0 +1,133 @@
+<template>
+  <ASelect v-bind="attrs_" v-model:value="state" placeholder="请选择模型" @dropdown-visible-change="onDropdownVisibleChange">
+    <template #[item]="data" v-for="item in Object.keys($slots)">
+      <slot :name="item" v-bind="data || {}"></slot>
+    </template>
+    <template #dropdownRender>
+      <div v-if="getOptions.length" class="model-select-grid">
+        <div
+          v-for="opt in getOptions"
+          :key="opt.fileName"
+          class="model-select-grid-item"
+          :class="{ 'is-selected': state === opt.fileName }"
+          @click="handleSelect(opt)"
+        >
+          <div class="model-select-grid-thumb-wrap">
+            <AntImage v-if="hasOpened" :src="getFileAccessHttpUrl(opt.filePath)" :preview="true" width="64" class="model-select-thumb" />
+          </div>
+          <span class="model-select-name">{{ deviceType }}:{{ opt.fileName }}</span>
+        </div>
+      </div>
+    </template>
+  </ASelect>
+</template>
+<script lang="ts">
+  import { defineComponent, ref, computed, unref } from 'vue';
+  import { Select as ASelect, Image as AntImage } from 'ant-design-vue';
+  import { useRuleFormItem } from '/@/hooks/component/useFormItem';
+  import { useAttrs } from '/@/hooks/core/useAttrs';
+  import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
+  import { propTypes } from '/@/utils/propTypes';
+  import { useModelFileStore } from '/@/store/modules/modelFile';
+  import { storeToRefs } from 'pinia';
+
+  type OptionsItem = { label: string; value: string; fileName: string; filePath: string };
+
+  export default defineComponent({
+    name: 'ModelSelect',
+    components: { ASelect, AntImage },
+    inheritAttrs: false,
+    props: {
+      value: propTypes.oneOfType([propTypes.string, propTypes.number]),
+      deviceType: propTypes.string.def(''),
+    },
+    emits: ['change', 'update:value'],
+    setup(props, { emit }) {
+      const emitData = ref<any[]>([]);
+      const attrs = useAttrs();
+      const hasOpened = ref(false);
+
+      const [state] = useRuleFormItem(props, 'value', 'change', emitData);
+
+      const modelFileStore = useModelFileStore();
+      const { modelFileData } = storeToRefs(modelFileStore);
+
+      const attrs_ = computed(() => {
+        const obj: any = { ...unref(attrs) };
+        if (obj && obj['onUpdate:value']) {
+          delete obj['onUpdate:value'];
+        }
+        return obj;
+      });
+
+      const getOptions = computed((): OptionsItem[] => {
+        const key = props.deviceType;
+        if (!key || !modelFileData.value) return [];
+        const items = modelFileData.value[key.split('_')[0]];
+        if (!items) return [];
+        return items.map((item) => ({
+          label: item.fileName,
+          value: item.fileName,
+          fileName: item.fileName,
+          filePath: item.filePath,
+        }));
+      });
+
+      function onDropdownVisibleChange(visible: boolean) {
+        hasOpened.value = visible;
+      }
+
+      function handleSelect(opt: OptionsItem) {
+        state.value = opt.fileName;
+        emit('update:value', opt.fileName);
+      }
+
+      return { state, attrs_, getOptions, getFileAccessHttpUrl, hasOpened, onDropdownVisibleChange, handleSelect };
+    },
+  });
+</script>
+<style scoped>
+  .model-select-grid {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 8px;
+    padding: 8px;
+    max-height: 350px;
+    overflow-y: auto;
+  }
+  .model-select-grid-item {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 4px;
+    padding: 8px;
+    border: 1px solid #f0f0f0;
+    border-radius: 6px;
+    cursor: pointer;
+    transition:
+      border-color 0.2s,
+      box-shadow 0.2s;
+  }
+  .model-select-grid-item:hover {
+    border-color: #1890ff;
+    box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15);
+  }
+  .model-select-grid-item.is-selected {
+    border-color: #1890ff;
+    background: #e6f7ff;
+  }
+  .model-select-grid-thumb-wrap {
+    line-height: 0;
+  }
+  .model-select-thumb {
+    border-radius: 4px;
+    border: 1px solid #f0f0f0;
+    cursor: pointer;
+  }
+  .model-select-name {
+    font-size: 12px;
+    color: #333;
+    text-align: center;
+    word-break: break-all;
+  }
+</style>

+ 1 - 0
src/components/Form/src/types/index.ts

@@ -154,4 +154,5 @@ export type ComponentType =
   | 'RangeNumber'
   | 'linkRecordSelect'
   | 'RangeTime'
+  | 'ModelSelect'
   | 'JRangeNumber';

+ 2 - 4
src/components/Markdown/src/MarkdownViewer.vue

@@ -4,15 +4,13 @@
 
 <script lang="ts" setup>
   import { computed } from 'vue';
-  import showdown from 'showdown';
+  import { marked } from 'marked';
 
-  const converter = new showdown.Converter();
-  converter.setOption('tables', true);
   const props = defineProps({
     value: { type: String },
     class: { type: String },
   });
-  const getHtmlData = computed(() => converter.makeHtml(props.value || ''));
+  const getHtmlData = computed(() => marked.parse(props.value || '', { gfm: true }));
 </script>
 
 <style scoped>

+ 5 - 4
src/components/Table/src/hooks/useCustomSelection.tsx

@@ -279,13 +279,14 @@ export function useCustomSelection(
     const index = selectedKeys.value.findIndex((key) => key === recordKey);
     if (checked) {
       if (index === -1) {
-        selectedKeys.value.push(recordKey);
-        selectedRows.value.push(record);
+        // 使用新数组替换,确保Vue响应式系统能检测到变化
+        selectedKeys.value = [...selectedKeys.value, recordKey];
+        selectedRows.value = [...selectedRows.value, record];
       }
     } else {
       if (index !== -1) {
-        selectedKeys.value.splice(index, 1);
-        selectedRows.value.splice(index, 1);
+        selectedKeys.value = selectedKeys.value.filter((_, i) => i !== index);
+        selectedRows.value = selectedRows.value.filter((_, i) => i !== index);
       }
     }
   }

+ 34 - 0
src/design/themify/deepblue.less

@@ -63,4 +63,38 @@ html[data-theme='deepblue'] {
   --vent-warn-tab-bg: #0f376c;
   --vent-warn-tab-border: #107eec;
   --vent-warn-tab-bg-actived: #0963c1;
+
+  // AI助手悬浮按钮图片
+  --img-ai-float: url('/src/assets/images/ventAI/chatModalBlue/4-1.png');
+
+  // 聊天弹窗图片
+  --img-chat-header-icon: url('/src/assets/images/ventAI/chatModalBlue/2-5.png');
+  --img-chat-selector-bg: url('/src/assets/images/ventAI/chatModalBlue/2-6.png');
+  --img-chat-upload-bg: url('/src/assets/images/ventAI/chatModalBlue/2-7.png');
+  --img-chat-upload-icon: url('/src/assets/images/ventAI/chatModalBlue/2-10.svg');
+  --img-chat-download-icon: url('/src/assets/images/ventAI/chatModalBlue/2-8.svg');
+  --img-chat-modal-title: url('/src/assets/images/ventAI/chatModalBlue/1-1.png');
+  --img-chat-panel-header: url('/src/assets/images/ventAI/chatModalBlue/3-5.png');
+  --img-chat-close-icon: url('/src/assets/images/ventAI/chatModalBlue/2-11.svg');
+  --img-chat-thinking-bg: url('/src/assets/images/ventAI/chatModalBlue/3-6.png');
+  --img-chat-task-bg: url('/src/assets/images/ventAI/chatModalBlue/2-3.png');
+  --img-chat-options-active-bg: url('/src/assets/images/ventAI/chatModalBlue/1-3.png');
+  --img-chat-options-bg: url('/src/assets/images/ventAI/chatModalBlue/1-4.png');
+  --img-chat-task-panel-bg: url('/src/assets/images/ventAI/chatModalBlue/1-5.png');
+  --img-chat-options-search-icon: url('/src/assets/images/ventAI/chatModalBlue/1-10.svg');
+  --img-chat-options-search-active-icon: url('/src/assets/images/ventAI/chatModalBlue/1-11.svg');
+  --img-chat-options-history-icon: url('/src/assets/images/ventAI/chatModalBlue/1-12.svg');
+  --img-chat-options-history-active-icon: url('/src/assets/images/ventAI/chatModalBlue/1-13.svg');
+  --img-chat-options-add-icon: url('/src/assets/images/ventAI/chatModalBlue/1-14.svg');
+  --img-chat-options-add-active-icon: url('/src/assets/images/ventAI/chatModalBlue/1-15.svg');
+  --img-chat-task-add-icon: url('/src/assets/images/ventAI/chatModalBlue/2-4.svg');
+  --img-chat-ai-avatar: url('/src/assets/images/ventAI/chatModalBlue/2-9.png');
+  --img-chat-thinking-avatar: url('/src/assets/images/ventAI/chatModalBlue/3-1.png');
+  --img-chat-agent-card-bg: url('/src/assets/images/ventAI/chatModalBlue/3-2.png');
+  --img-chat-agent-card-header: url('/src/assets/images/ventAI/chatModalBlue/3-3.png');
+  --img-chat-collapse-bg: url('/src/assets/images/ventAI/chatModalBlue/2-15.png');
+  --img-chat-attach-icon: url('/src/assets/images/ventAI/chatModalBlue/3-7.svg');
+  --img-chat-send-icon: url('/src/assets/images/ventAI/chatModalBlue/2-6.png');
+  --img-chat-scroll-bottom: url('/src/assets/images/ventAI/chatModalBlue/3-8.png');
+  --img-chat-input-bg: url('/src/assets/images/ventAI/chatModalBlue/2-13.png');
 }

+ 39 - 0
src/design/themify/default.less

@@ -1,4 +1,8 @@
 html {
+  @font-face {
+    font-family: 'douyuFont';
+    src: url('@/assets/font/douyuFont.otf');
+  }
   // html[data-theme='vent1'] {
   --vent-primary-color: #1580cc;
 
@@ -65,4 +69,39 @@ html {
 
   --vent-balancePress-fan-bg-default: linear-gradient(to right, #071d3c22, #035ea266);
   --vent-balancePress-fan-bg-select: linear-gradient(to right, #21a6ce99, #052d3d99);
+
+  --vet-font-family: 'douyuFont';
+  // AI助手悬浮按钮图片
+  --img-ai-float: url('/src/assets/images/ventAI/chatModalBlue/4-1.png');
+
+  // 聊天弹窗图片
+  --img-chat-modal-bg: url('/src/assets/images/ventAI/chatModalBlue/1-1.png');
+  --img-chat-close-bg: url('/src/assets/images/ventAI/chatModalBlue/1-2.png');
+  --img-chat-options-active-bg: url('/src/assets/images/ventAI/chatModalBlue/1-3.png');
+  --img-chat-options-bg: url('/src/assets/images/ventAI/chatModalBlue/1-4.png');
+  --img-chat-task-panel-bg: url('/src/assets/images/ventAI/chatModalBlue/1-5.png');
+  --img-chat-panel-bg: url('/src/assets/images/ventAI/chatModalBlue/1-6.png');
+  --img-chat-selector-bg: url('/src/assets/images/ventAI/chatModalBlue/1-7.png');
+  --img-chat-upload-bg: url('/src/assets/images/ventAI/chatModalBlue/2-7.png');
+  --img-chat-upload-icon: url('/src/assets/images/ventAI/chatModalBlue/2-10.svg');
+  --img-chat-download-icon: url('/src/assets/images/ventAI/chatModalBlue/2-8.png');
+  --img-chat-panel-header: url('/src/assets/images/ventAI/chatModalBlue/3-5.png');
+  --img-chat-close-icon: url('/src/assets/images/ventAI/chatModalBlue/2-11.svg');
+  --img-chat-thinking-bg: url('/src/assets/images/ventAI/chatModalBlue/3-6.png');
+  --img-chat-options-search-icon: url('/src/assets/images/ventAI/chatModalBlue/1-10.svg');
+  --img-chat-options-search-active-icon: url('/src/assets/images/ventAI/chatModalBlue/1-11.svg');
+  --img-chat-options-history-icon: url('/src/assets/images/ventAI/chatModalBlue/1-12.svg');
+  --img-chat-options-history-active-icon: url('/src/assets/images/ventAI/chatModalBlue/1-13.svg');
+  --img-chat-options-add-icon: url('/src/assets/images/ventAI/chatModalBlue/1-14.svg');
+  --img-chat-options-add-active-icon: url('/src/assets/images/ventAI/chatModalBlue/1-15.svg');
+  --img-chat-task-add-icon: url('/src/assets/images/ventAI/chatModalBlue/2-4.svg');
+  --img-chat-ai-avatar: url('/src/assets/images/ventAI/chatModalBlue/2-9.png');
+  --img-chat-thinking-avatar: url('/src/assets/images/ventAI/chatModalBlue/3-1.png');
+  --img-chat-agent-card-bg: url('/src/assets/images/ventAI/chatModalBlue/3-2.png');
+  --img-chat-agent-card-header: url('/src/assets/images/ventAI/chatModalBlue/3-3.png');
+  --img-chat-collapse-bg: url('/src/assets/images/ventAI/chatModalBlue/2-15.png');
+  --img-chat-attach-icon: url('/src/assets/images/ventAI/chatModalBlue/3-7.svg');
+  --img-chat-send-icon: url('/src/assets/images/ventAI/chatModalBlue/2-6.png');
+  --img-chat-scroll-bottom: url('/src/assets/images/ventAI/chatModalBlue/3-8.png');
+  --img-chat-input-bg: url('/src/assets/images/ventAI/chatModalBlue/2-13.png');
 }

+ 121 - 1
src/layouts/default/index.vue

@@ -12,11 +12,19 @@
         <LayoutFooter />
       </Layout>
     </Layout>
+
+    <!-- AI智能助手悬浮按钮(支持拖动,固定右侧) -->
+    <div class="ai-assistant-float" :style="{ right: aiBtnPosition.right, bottom: aiBtnPosition.bottom }" @mousedown="handleDragStart">
+      <div class="ai-float-icon" @click="openAiAssistant"></div>
+    </div>
+
+    <!-- AI助手弹窗 -->
+    <AiAssistantModal :visible="aiAssistantVisible" @close="aiAssistantVisible = false" />
   </Layout>
 </template>
 
 <script lang="ts">
-  import { defineComponent, computed, unref } from 'vue';
+  import { defineComponent, computed, unref, ref } from 'vue';
   import { Layout } from 'ant-design-vue';
   import { createAsyncComponent } from '/@/utils/factory/createAsyncComponent';
 
@@ -24,10 +32,12 @@
   import LayoutContent from './content/index.vue';
   import LayoutSideBar from './sider/index.vue';
   import LayoutMultipleHeader from './header/MultipleHeader.vue';
+  import AiAssistantModal from '/@/views/ventAI/manageAssistent/components/AiAssistantModal.vue';
 
   import { useMenuSetting } from '/@/hooks/setting/useMenuSetting';
   import { useDesign } from '/@/hooks/web/useDesign';
   import { useLockPage } from '/@/hooks/web/useLockPage';
+  import { useAppStore } from '/@/store/modules/app';
 
   import { useAppInject } from '/@/hooks/web/useAppInject';
   import { antPrefixCls } from '/@/settings/designSetting';
@@ -42,6 +52,7 @@
       LayoutSideBar,
       LayoutMultipleHeader,
       Layout,
+      AiAssistantModal,
     },
     setup() {
       const { prefixCls } = useDesign('default-layout');
@@ -60,6 +71,85 @@
         return cls;
       });
 
+      // AI助手相关
+      const aiAssistantVisible = ref(false);
+      const appStore = useAppStore();
+
+      const openAiAssistant = () => {
+        aiAssistantVisible.value = true;
+      };
+
+      // 拖动辅助函数
+      const BTN_SIZE = 80;
+      function getLayoutHeight() {
+        const el = document.getElementById('adaptive-container');
+        return el ? el.clientHeight : window.innerHeight;
+      }
+      function getScale() {
+        return appStore.getHeightScale || 1;
+      }
+      function viewportToLayout(viewportVal: number) {
+        return viewportVal / getScale();
+      }
+
+      // 拖动状态和按钮位置(固定right,仅修改bottom)
+      const isDragging = ref(false);
+      const aiBtnPosition = ref({
+        right: '30px',
+        bottom: '30px',
+      });
+      let startY = 0;
+      let startBottomVp = 0;
+
+      // 获取按钮在视口中的bottom值
+      function getBtnBottomVp() {
+        const el = document.querySelector('.ai-assistant-float');
+        if (!el) {
+          const layoutBottom = parseFloat(aiBtnPosition.value.bottom);
+          return getLayoutHeight() - layoutBottom - BTN_SIZE;
+        }
+        const rect = el.getBoundingClientRect();
+        return window.innerHeight - rect.bottom;
+      }
+
+      // 开始拖动
+      const DRAG_THRESHOLD = 5;
+      const handleDragStart = (e: MouseEvent) => {
+        e.preventDefault();
+        const startX = e.clientX;
+        startY = e.clientY;
+        startBottomVp = getBtnBottomVp();
+        let dragged = false;
+        isDragging.value = false;
+
+        const handleDragMove = (e: MouseEvent) => {
+          e.preventDefault();
+          const deltaY = startY - e.clientY;
+          if (!dragged && (Math.abs(e.clientX - startX) > DRAG_THRESHOLD || Math.abs(deltaY) > DRAG_THRESHOLD)) {
+            dragged = true;
+            isDragging.value = true;
+          }
+          if (dragged) {
+            const newBottomVp = startBottomVp + deltaY;
+            const layoutBottom = viewportToLayout(newBottomVp);
+            const layoutH = getLayoutHeight();
+            const clamped = Math.max(0, Math.min(layoutH - BTN_SIZE, layoutBottom));
+            aiBtnPosition.value.bottom = `${clamped}px`;
+          }
+        };
+
+        const handleDragEnd = () => {
+          if (dragged) {
+            isDragging.value = false;
+          }
+          document.removeEventListener('mousemove', handleDragMove);
+          document.removeEventListener('mouseup', handleDragEnd);
+        };
+
+        document.addEventListener('mousemove', handleDragMove);
+        document.addEventListener('mouseup', handleDragEnd);
+      };
+
       return {
         getShowSidebar,
         prefixCls,
@@ -67,6 +157,10 @@
         getIsMixSidebar,
         layoutClass,
         lockEvents,
+        aiAssistantVisible,
+        aiBtnPosition,
+        openAiAssistant,
+        handleDragStart,
       };
     },
   });
@@ -81,6 +175,7 @@
     min-height: 100%;
     background-color: @content-bg;
     flex-direction: column;
+    position: relative;
 
     > .@{ventSpace}-layout {
       min-height: 100%;
@@ -92,4 +187,29 @@
       margin-left: 1px;
     }
   }
+
+  // AI助手悬浮按钮(支持拖动,固定右侧)
+  .ai-assistant-float {
+    position: fixed;
+    z-index: 1000;
+    cursor: move;
+    user-select: none;
+
+    .ai-float-icon {
+      width: 80px;
+      height: 80px;
+      background-image: var(--img-ai-float);
+      background-repeat: no-repeat;
+      background-size: contain;
+      background-position: center;
+      cursor: pointer;
+      transition: all 0.3s;
+      filter: drop-shadow(0 4px 12px rgba(10, 132, 255, 0.3));
+
+      &:hover {
+        transform: scale(1.1);
+        filter: drop-shadow(0 6px 16px rgba(10, 132, 255, 0.4));
+      }
+    }
+  }
 </style>

+ 5 - 0
src/router/guard/index.ts

@@ -2,6 +2,7 @@ import type { Router, RouteLocationNormalized } from 'vue-router';
 import { useAppStoreWithOut } from '/@/store/modules/app';
 import { useUserStoreWithOut } from '/@/store/modules/user';
 import { useVentStore } from '/@/store/modules/vent';
+import { useModelFileStore } from '/@/store/modules/modelFile';
 import { useTransitionSetting } from '/@/hooks/setting/useTransitionSetting';
 import { AxiosCanceler } from '/@/utils/http/axios/axiosCancel';
 import { Modal, notification } from 'ant-design-vue';
@@ -64,6 +65,7 @@ function createPageGuard(router: Router) {
 // Used to handle page loading status
 function createPageLoadingGuard(router: Router) {
   const ventStore = useVentStore();
+  const modelFileStore = useModelFileStore();
   const userStore = useUserStoreWithOut();
   const appStore = useAppStoreWithOut();
   const { getOpenPageLoading } = useTransitionSetting();
@@ -77,6 +79,9 @@ function createPageLoadingGuard(router: Router) {
     if (!ventStore.allTableHeaderColumns) {
       await ventStore.setAllTableHeaderColumns();
     }
+    if (!modelFileStore.modelFileData) {
+      await modelFileStore.fetchModelFileData();
+    }
     if (unref(getOpenPageLoading)) {
       appStore.setPageLoadingAction(true);
       return true;

+ 38 - 0
src/store/modules/modelFile.ts

@@ -0,0 +1,38 @@
+import { defineStore } from 'pinia';
+import { getAllModelFileJsonApi } from '/@/api/sys/modelFile';
+
+interface ModelFileItem {
+  fileName: string;
+  filePath: string;
+}
+
+export const useModelFileStore = defineStore({
+  id: 'app-model-file',
+  state: () => ({
+    modelFileData: null as Record<string, ModelFileItem[]> | null,
+  }),
+  getters: {
+    getModelFileDataByKey(): (key: string) => ModelFileItem[] {
+      return (key: string) => {
+        if (!this.modelFileData || !this.modelFileData[key]) return [];
+        return this.modelFileData[key];
+      };
+    },
+    getAllModelFileData(): Record<string, ModelFileItem[]> | null {
+      return this.modelFileData;
+    },
+  },
+  actions: {
+    async fetchModelFileData() {
+      const raw = await getAllModelFileJsonApi();
+      const processed: Record<string, ModelFileItem[]> = {};
+      for (const [key, items] of Object.entries(raw)) {
+        processed[key] = items.map((item) => ({
+          fileName: item.fileName.replace(/\.[^.]+$/, ''),
+          filePath: item.filePath.replace(/^\/data\/file/, ''),
+        }));
+      }
+      this.modelFileData = processed;
+    },
+  },
+});

+ 4 - 2
src/utils/encryption/signMd5Utils.js

@@ -1,4 +1,4 @@
-import md5 from 'md5';
+import md5 from 'crypto-js/md5';
 import { fromEnvCharCodes } from '/@/utils/env';
 
 // 签名密钥串(前后端要一致,正式发布请自行修改,通过环境变量配置)
@@ -35,7 +35,9 @@ export default class signMd5Utils {
     let jsonObj = this.mergeObject(urlParams, requestParams);
     let requestBody = this.sortAsc(jsonObj);
     delete requestBody._t;
-    return md5(JSON.stringify(requestBody) + signatureSecret).toUpperCase();
+    return md5(JSON.stringify(requestBody) + signatureSecret)
+      .toString()
+      .toUpperCase();
   }
 
   /**

+ 1 - 1
src/views/monitor/trace/trace.data.ts

@@ -1,6 +1,6 @@
 import { BasicColumn } from '/@/components/Table';
 import dayjs from 'dayjs';
-import _get from 'lodash.get';
+import { get as _get } from 'lodash-es';
 import { h } from 'vue';
 import { Tag } from 'ant-design-vue';
 

+ 128 - 136
src/views/vent/deviceManager/comment/DeviceReportInfo.vue

@@ -8,81 +8,103 @@
   </div>
 </template>
 <script lang="ts" setup>
-import { onMounted, ref, defineEmits, onUnmounted, watch, PropType, nextTick, inject, onBeforeMount } from 'vue';
-import { BasicForm, useForm } from '/@/components/Form/index';
-import { FormSchema } from '/@/components/Form';
-import { getFormSchemaColumns } from '/@/hooks/web/useWebColumns';
-import { list as substationList } from '/@/views/vent/deviceManager/substationTabel/substation.api';
-import { list, updateReportInfo, sysList, sysInput } from '../../monitorManager/comment/comment.api';
-const deviceData = inject('formData') as any;
-const emit = defineEmits(['close', 'register']);
-const FormRef = ref();
-const tabType = ref('');
+  import { onMounted, ref, defineEmits, onUnmounted, watch, PropType, nextTick, inject, onBeforeMount } from 'vue';
+  import { BasicForm, useForm } from '/@/components/Form/index';
+  import { FormSchema } from '/@/components/Form';
+  import { getFormSchemaColumns } from '/@/hooks/web/useWebColumns';
+  import { list as substationList } from '/@/views/vent/deviceManager/substationTabel/substation.api';
+  import { list, updateReportInfo, sysList, sysInput } from '../../monitorManager/comment/comment.api';
+  const deviceData = inject('formData') as any;
+  const emit = defineEmits(['close', 'register']);
+  const FormRef = ref();
+  const tabType = ref('');
 
-const formSchema = ref<any[]>([]);
-const formData = ref(deviceData);
-const deviceTypeName = ref(deviceData.devicekind ? deviceData.devicekind : deviceData.strtype);
+  const formSchema = ref<any[]>([]);
+  const formData = ref(deviceData);
+  const deviceTypeName = ref(deviceData.devicekind ? deviceData.devicekind : deviceData.strtype);
 
-const arrToFormColumns = (tableHeaderColumns = [], devicetype) => {
-  const columnList: any[] = [];
-  tableHeaderColumns.forEach((item: any) => {
-    let columnsItem;
-    if (item.type == 1 || item.type == 10) {
-      columnsItem = {
-        label: item.des, //_dictText
-        field: item.monitorcode,
-        component: item.type == 1 ? 'Input' : item.type == 10 ? 'InputTextArea' : '',
-      };
-    } else {
-      if (item.type == 2 && item['monitorcode'] == 'nsubstationid') {
-        columnsItem = {
-          label: item.des, //_dictText
-          field: item.monitorcode,
-          component: 'ApiSelect',
-          componentProps: {
-            api: substationList,
-            labelField: 'strname',
-            valueField: 'id',
-          },
-        };
-      }
-      if (item.type == 3) {
-        columnsItem = {
-          label: item.des, //_dictText
-          field: item.monitorcode,
-          component: 'RadioGroup',
-          defaultValue: 1,
-          componentProps: () => {
-            return {
+  const arrToFormColumns = (tableHeaderColumns = [], devicetype) => {
+    const columnList: any[] = [];
+    tableHeaderColumns.forEach((item: any) => {
+      let columnsItem;
+      switch (item.type) {
+        case 1:
+          columnsItem = { label: item.des, field: item.monitorcode, component: 'Input' };
+          break;
+        case 10:
+          columnsItem = { label: item.des, field: item.monitorcode, component: 'InputTextArea' };
+          break;
+        case 2:
+          if (item.monitorcode !== 'nsubstationid') break;
+          columnsItem = {
+            label: item.des,
+            field: item.monitorcode,
+            component: 'ApiSelect',
+            componentProps: { api: substationList, labelField: 'strname', valueField: 'id' },
+          };
+          break;
+        case 3:
+          columnsItem = {
+            label: item.des,
+            field: item.monitorcode,
+            component: 'RadioGroup',
+            defaultValue: 1,
+            componentProps: () => ({
               options: [
                 { label: '是', value: 1, key: '1' },
                 { label: '否', value: 0, key: '2' },
               ],
               stringToNumber: true,
-            };
-          },
-        };
+            }),
+          };
+          break;
+        case 4:
+          columnsItem = {
+            label: item.des,
+            field: item.monitorcode,
+            component: 'JDictSelectTag',
+            componentProps: { dictCode: item.dict, placeholder: '请选择' },
+          };
+          break;
+      }
+      if (!!columnsItem) {
+        columnList.push(columnsItem);
       }
-      if (item.type == 4) {
-        columnsItem = {
-          label: item.des, //_dictText
-          field: item.monitorcode,
+    });
+    formSchema.value = columnList;
+    if (tabType.value === 'deviceInfo') {
+      formSchema.value.unshift(
+        {
+          label: '设备id', //_dictText
+          field: 'id',
+          component: 'Input',
+          componentProps: {
+            disabled: true,
+            show: false,
+          },
+        },
+        {
+          label: '点表',
+          field: 'strtype',
           component: 'JDictSelectTag',
           componentProps: {
-            dictCode: item.dict,
-            placeholder: '请选择',
-            // stringToNumber: false,
+            dictCode: `${devicetype.split('_')[0]}kind`,
+            placeholder: '请选择点表',
           },
-        };
-      }
-    }
-    columnList.push(columnsItem);
-  });
-  formSchema.value = columnList;
-  console.log(formSchema.value, 'sssssssssssssssssssss');
-  if (tabType.value === 'deviceInfo') {
-    formSchema.value.unshift(
-      {
+        }
+      );
+      formSchema.value.push({
+        label: '备用分站',
+        field: 'stationids',
+        component: 'ApiSelect',
+        componentProps: {
+          api: substationList,
+          labelField: 'strname',
+          valueField: 'id',
+        },
+      });
+    } else {
+      formSchema.value.unshift({
         label: '设备id', //_dictText
         field: 'id',
         component: 'Input',
@@ -90,87 +112,57 @@ const arrToFormColumns = (tableHeaderColumns = [], devicetype) => {
           disabled: true,
           show: false,
         },
-      },
-      {
-        label: '点表',
-        field: 'strtype',
-        component: 'JDictSelectTag',
-        componentProps: {
-          dictCode: `${devicetype.split('_')[0]}kind`,
-          placeholder: '请选择点表',
-        },
-      }
-    );
-    formSchema.value.push({
-      label: '备用分站',
-      field: 'stationids',
-      component: 'ApiSelect',
-      componentProps: {
-        api: substationList,
-        labelField: 'strname',
-        valueField: 'id',
-      },
-    });
-  } else {
-    formSchema.value.unshift({
-      label: '设备id', //_dictText
-      field: 'id',
-      component: 'Input',
-      componentProps: {
-        disabled: true,
-        show: false,
-      },
-    });
-  }
-};
+      });
+    }
+  };
 
-const [registerForm, { resetSchema, getFieldsValue, setFieldsValue, resetFields }] = useForm({
-  schemas: <FormSchema[]>formSchema.value,
-  showActionButtonGroup: false,
-});
+  const [registerForm, { resetSchema, getFieldsValue, setFieldsValue, resetFields }] = useForm({
+    schemas: <FormSchema[]>formSchema.value,
+    showActionButtonGroup: false,
+  });
 
-function getColumns() {
-  let formSchemaArr = getFormSchemaColumns(`${deviceData.devicekind ? deviceData.devicekind : deviceData.strtype}_input`) || [];
-  if (formSchemaArr && formSchemaArr.length < 1) {
-    const arr = deviceTypeName.value.split('_');
-    formSchemaArr = getFormSchemaColumns(arr[0] + '_input') || [];
+  function getColumns() {
+    let formSchemaArr = getFormSchemaColumns(`${deviceData.devicekind ? deviceData.devicekind : deviceData.strtype}_input`) || [];
+    if (formSchemaArr && formSchemaArr.length < 1) {
+      const arr = deviceTypeName.value.split('_');
+      formSchemaArr = getFormSchemaColumns(arr[0] + '_input') || [];
+    }
+    arrToFormColumns(formSchemaArr, deviceTypeName.value);
+    resetSchema(formSchema.value);
   }
-  arrToFormColumns(formSchemaArr, deviceTypeName.value);
-  resetSchema(formSchema.value);
-}
 
-async function onReset() {
-  await resetFields();
-  await setFieldsValue({ ...deviceData });
-}
+  async function onReset() {
+    await resetFields();
+    await setFieldsValue({ ...deviceData });
+  }
 
-async function handleSubmit() {
-  const data = await getFieldsValue();
-  if (!deviceData.devicekind) {
-    await sysInput(data);
-  } else {
-    await updateReportInfo(data);
+  async function handleSubmit() {
+    const data = await getFieldsValue();
+    if (!deviceData.devicekind) {
+      await sysInput(data);
+    } else {
+      await updateReportInfo(data);
+    }
   }
-}
 
-onBeforeMount(async () => {});
+  onBeforeMount(async () => {});
 
-onMounted(async () => {
-  getColumns();
-  let result;
-  if (!deviceData.devicekind) {
-    result = await sysList({ id: deviceData.id });
-  } else {
-    result = await list({ id: deviceData.id });
-  }
-  formData.value = result['records'][0] || [];
-  await setFieldsValue({
-    ...formData.value,
+  onMounted(async () => {
+    getColumns();
+    let result;
+    if (!deviceData.devicekind) {
+      result = await sysList({ id: deviceData.id });
+    } else {
+      result = await list({ id: deviceData.id });
+    }
+    formData.value = result['records'][0] || [];
+    await setFieldsValue({
+      ...formData.value,
+    });
   });
-});
-onUnmounted(() => {});
+  onUnmounted(() => {});
 </script>
 <style scoped lang="less">
-@import '/@/design/theme.less';
-@import '/@/design/vent/modal.less';
+  @import '/@/design/theme.less';
+  @import '/@/design/vent/modal.less';
 </style>

+ 10 - 3
src/views/vent/deviceManager/comment/NormalTable.vue

@@ -5,7 +5,7 @@
         <a-button preIcon="ant-design:plus-outlined" type="primary" @click="handleAdd">新增</a-button>
         <a-button v-if="getExportUrl" type="primary" preIcon="ant-design:export-outlined" @click="onExportXlsFn"> 导出</a-button>
         <j-upload-button v-if="getImportUrl" type="primary" preIcon="ant-design:import-outlined" @click="onImportXls">导入</j-upload-button>
-        <a-dropdown v-if="selectedRowKeys.length > 0" :getPopupContainer="getPopupContainer">
+        <a-dropdown v-if="selectedRowKeys.length > 0">
           <template #overlay>
             <a-menu>
               <a-menu-item key="1" @click="batchHandleDelete">
@@ -39,6 +39,7 @@
 <script lang="ts" setup>
   //ts语法
   import { ref, provide, reactive, toRaw, defineExpose, watch } from 'vue';
+  import { message } from 'ant-design-vue';
   import { BasicTable, ActionItem, EditRecordRow, BasicColumn } from '/@/components/Table';
   import { useModal } from '/@/components/Modal';
   import DeviceModal from './DeviceModal.vue';
@@ -46,7 +47,6 @@
   // import { useGlobSetting } from '/@/hooks/setting';
   import { getTableHeaderColumns } from '/@/hooks/web/useWebColumns';
   import { useListPage } from '/@/hooks/system/useListPage';
-  import { getPopupContainer } from '/@/utils';
 
   const props = defineProps({
     columnsType: {
@@ -236,7 +236,14 @@
    * 批量删除事件
    */
   async function batchHandleDelete() {
-    doRequest(() => props.batchDelete({ ids: selectedRowKeys.value }));
+    if (!props.batchDelete) return;
+    doRequest(
+      () =>
+        new Promise((resolve, _reject) => {
+          props.batchDelete!({ ids: selectedRowKeys.value }, (res: any) => resolve(res));
+        }),
+      { confirm: false }
+    );
   }
   /**
    * 查看

+ 44 - 54
src/views/vent/deviceManager/deviceTable/index.vue

@@ -44,82 +44,72 @@
     const columnList: any[] = [];
     tableHeaderColumns.forEach((item: any) => {
       let columnsItem;
-      if (item.type == 1 || item.type == 10) {
-        columnsItem = {
-          label: item.des, //_dictText
-          field: item.monitorcode,
-          component: item.type == 1 ? 'Input' : item.type == 10 ? 'InputTextArea' : '',
-        };
-      } else {
-        if (item.type == 2 && item['monitorcode'] == 'nsubstationid') {
+      switch (item.type) {
+        case 1:
+          columnsItem = { label: item.des, field: item.monitorcode, component: 'Input' };
+          break;
+        case 10:
+          columnsItem = { label: item.des, field: item.monitorcode, component: 'InputTextArea' };
+          break;
+        case 2:
+          if (item.monitorcode !== 'nsubstationid') break;
           columnsItem = {
-            label: item.des, //_dictText
+            label: item.des,
             field: item.monitorcode,
             component: 'ApiSelect',
-            componentProps: {
-              api: substationList,
-              labelField: 'strname',
-              valueField: 'id',
-            },
+            componentProps: { api: substationList, labelField: 'strname', valueField: 'id' },
           };
-        }
-        if (item.type == 3) {
+          break;
+        case 3:
           columnsItem = {
-            label: item.des, //_dictText
+            label: item.des,
             field: item.monitorcode,
             component: 'RadioGroup',
             defaultValue: 1,
-            componentProps: () => {
-              return {
-                options: [
-                  { label: '是', value: 1, key: '1' },
-                  { label: '否', value: 0, key: '2' },
-                ],
-                stringToNumber: true,
-              };
-            },
+            componentProps: () => ({
+              options: [
+                { label: '是', value: 1, key: '1' },
+                { label: '否', value: 0, key: '2' },
+              ],
+              stringToNumber: true,
+            }),
           };
-        }
-        if (item.type == 4) {
+          break;
+        case 4:
           columnsItem = {
-            label: item.des, //_dictText
+            label: item.des,
             field: item.monitorcode,
             component: 'JDictSelectTag',
-            componentProps: {
-              dictCode: item.dict,
-              placeholder: '请选择',
-              // stringToNumber: true,
-            },
+            componentProps: { dictCode: item.dict, placeholder: '请选择' },
           };
-        }
-        // date日期
-        if (item.type == 8) {
+          break;
+        case 8:
           columnsItem = {
-            label: item.des, //_dictText
+            label: item.des,
             field: item.monitorcode,
             component: 'DatePicker',
-            componentProps: {
-              showTime: false,
-              valueFormat: 'YYYY-MM-DD',
-              getPopupContainer: getAutoScrollContainer,
-            },
+            componentProps: { showTime: false, valueFormat: 'YYYY-MM-DD', getPopupContainer: getAutoScrollContainer },
           };
-        }
-        // 日期+时间
-        if (item.type == 9) {
+          break;
+        case 9:
           columnsItem = {
-            label: item.des, //_dictText
+            label: item.des,
             field: item.monitorcode,
             component: 'DatePicker',
-            componentProps: {
-              showTime: true,
-              valueFormat: 'YYYY-MM-DD HH:mm:ss',
-              getPopupContainer: getAutoScrollContainer,
-            },
+            componentProps: { showTime: true, valueFormat: 'YYYY-MM-DD HH:mm:ss', getPopupContainer: getAutoScrollContainer },
           };
-        }
+          break;
+        case 11:
+          columnsItem = {
+            label: item.des,
+            field: item.monitorcode,
+            component: 'ModelSelect',
+            componentProps: { deviceType: deviceType.value },
+          };
+      }
+      if (!!columnsItem) {
+        columnList.push(columnsItem);
       }
-      columnList.push(columnsItem);
     });
     formSchema.value = columnList;
     formSchema.value.unshift(

+ 24 - 28
src/views/vent/deviceManager/workingFace/index.vue

@@ -36,44 +36,40 @@
     if (tableHeaderColumns.length > 0) {
       tableHeaderColumns.forEach((item: any) => {
         let columnsItem;
-        if (item.type == 1 || item.type == 10) {
-          columnsItem = {
-            label: item.des, //_dictText
-            field: item.monitorcode,
-            component: item.type == 1 ? 'Input' : item.type == 10 ? 'InputTextArea' : '',
-          };
-        } else {
-          if (item.type == 3) {
+        switch (item.type) {
+          case 1:
+            columnsItem = { label: item.des, field: item.monitorcode, component: 'Input' };
+            break;
+          case 10:
+            columnsItem = { label: item.des, field: item.monitorcode, component: 'InputTextArea' };
+            break;
+          case 3:
             columnsItem = {
-              label: item.des, //_dictText
+              label: item.des,
               field: item.monitorcode,
               component: 'RadioGroup',
               defaultValue: 1,
-              componentProps: () => {
-                return {
-                  options: [
-                    { label: '是', value: 1, key: '1' },
-                    { label: '否', value: 0, key: '2' },
-                  ],
-                  stringToNumber: true,
-                };
-              },
+              componentProps: () => ({
+                options: [
+                  { label: '是', value: 1, key: '1' },
+                  { label: '否', value: 0, key: '2' },
+                ],
+                stringToNumber: true,
+              }),
             };
-          }
-          if (item.type == 4) {
+            break;
+          case 4:
             columnsItem = {
-              label: item.des, //_dictText
+              label: item.des,
               field: item.monitorcode,
               component: 'JDictSelectTag',
-              componentProps: {
-                dictCode: item.dict,
-                placeholder: '请选择',
-                stringToNumber: true,
-              },
+              componentProps: { dictCode: item.dict, placeholder: '请选择', stringToNumber: true },
             };
-          }
+            break;
+        }
+        if (!!columnsItem) {
+          columnList.push(columnsItem);
         }
-        columnList.push(columnsItem);
         formSchema.value = [...commentFormSchema(strtype), ...columnList];
       });
     } else {

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

@@ -51,173 +51,173 @@
   </div>
 </template>
 <script lang="ts" setup>
-  import { computed, onMounted, onUnmounted } from 'vue';
-  // import { CaretDownOutlined } from '@ant-design/icons-vue';
-  import NewNav from './components/NewNavFire.vue';
-  import { useInitConfigs, useInitPage } from '../hooks/useInit';
-  import ModuleNew from './components/ModuleNew.vue';
-  import ModuleFireNew from './components/ModuleFireNew.vue';
-  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 { testConfigNewFire } from '../configurable.data.New';
-  const cfgs = computed(() =>
-    configs.value.filter((_, index) => {
-      return index !== 4 && index !== 3 && index !== 5;
-    })
-  );
-  const cfgA = computed<any>(() =>
-    configs.value.find((_, index) => {
-      return index === 3;
-    })
-  );
-  const cfgB = computed<any>(() =>
-    configs.value.find((_, index) => {
-      return index === 4;
-    })
-  );
-  const cfgTop = computed<any>(() =>
-    configs.value.find((_, index) => {
-      return index === 5;
-    })
-  );
-  const { configs, devicesTypes, fetchConfigs } = useInitConfigs();
-  const { mainTitle, data, updateData /** updateEnhancedConfigs */ } = useInitPage('火灾预警系统');
-  let interval: number | undefined;
-  onMounted(() => {
-    // fetchConfigs('New_fire').then(() => {
-    //   configs.value = testConfigNewFire;
-    //   Promise.resolve(BDFireMock).then(updateData);
-    // });
-    // setInterval(() => {
-    //   Promise.resolve(BDFireMock).then(updateData);
-    // }, 2000);
-    fetchConfigs('New_fire').then(() => {
-      configs.value = testConfigNewFire;
-      getDisHome({
-        dataList: devicesTypes.value.concat('fireAllMineWarn').join(','),
-      }).then(updateData);
-    });
-    setInterval(() => {
-      getDisHome({
-        dataList: devicesTypes.value.concat('fireAllMineWarn').join(','),
-      }).then(updateData);
-    }, 2000);
+import { computed, onMounted, onUnmounted } from 'vue';
+// import { CaretDownOutlined } from '@ant-design/icons-vue';
+import NewNav from './components/NewNavFire.vue';
+import { useInitConfigs, useInitPage } from '../hooks/useInit';
+import ModuleNew from './components/ModuleNew.vue';
+import ModuleFireNew from './components/ModuleFireNew.vue';
+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 { testConfigNewFire } from '../configurable.data.New';
+const cfgs = computed(() =>
+  configs.value.filter((_, index) => {
+    return index !== 4 && index !== 3 && index !== 5;
+  })
+);
+const cfgA = computed<any>(() =>
+  configs.value.find((_, index) => {
+    return index === 3;
+  })
+);
+const cfgB = computed<any>(() =>
+  configs.value.find((_, index) => {
+    return index === 4;
+  })
+);
+const cfgTop = computed<any>(() =>
+  configs.value.find((_, index) => {
+    return index === 5;
+  })
+);
+const { configs, devicesTypes, fetchConfigs } = useInitConfigs();
+const { mainTitle, data, updateData /** updateEnhancedConfigs */ } = useInitPage('火灾预警系统');
+let interval: number | undefined;
+onMounted(() => {
+  // fetchConfigs('New_fire').then(() => {
+  //   configs.value = testConfigNewFire;
+  //   Promise.resolve(BDFireMock).then(updateData);
+  // });
+  // setInterval(() => {
+  //   Promise.resolve(BDFireMock).then(updateData);
+  // }, 2000);
+  fetchConfigs('New_fire').then(() => {
+    configs.value = testConfigNewFire;
+    getDisHome({
+      dataList: devicesTypes.value.concat('fireAllMineWarn').join(','),
+    }).then(updateData);
   });
+  setInterval(() => {
+    getDisHome({
+      dataList: devicesTypes.value.concat('fireAllMineWarn').join(','),
+    }).then(updateData);
+  }, 2000);
+});
 
-  onUnmounted(() => {
-    clearInterval(interval);
-  });
+onUnmounted(() => {
+  clearInterval(interval);
+});
 
-  function redirectTo(url) {
-    window.open(url);
-  }
+function redirectTo(url) {
+  window.open(url);
+}
 </script>
 <style lang="less" scoped>
-  @import '/@/design/theme.less';
+@import '/@/design/theme.less';
 
-  @font-face {
-    font-family: 'douyuFont';
-    src: url('../../../../assets/font/douyuFont.otf');
-  }
-
-  @{theme-deepblue} {
-    .company-home {
-      --image-modal-top: url('@/assets/images/fireNew/1.png');
-    }
-  }
+@font-face {
+  font-family: 'douyuFont';
+  src: url('../../../../assets/font/douyuFont.otf');
+}
 
+@{theme-deepblue} {
   .company-home {
     --image-modal-top: url('@/assets/images/fireNew/1.png');
-    width: 100%;
-    height: 100%;
-    color: @white;
-    position: relative;
-    background: url('@/assets/images/fireNew/FireBj.png') no-repeat center;
+  }
+}
+
+.company-home {
+  --image-modal-top: url('@/assets/images/fireNew/1.png');
+  width: 100%;
+  height: 100%;
+  color: @white;
+  position: relative;
+  background: url('@/assets/images/vent/homeNew/bg.png') no-repeat center;
 
-    .top-bg {
-      width: 100%;
+  .top-bg {
+    width: 100%;
+    height: 56px;
+    background: var(--image-modal-top) no-repeat center;
+    position: absolute;
+    z-index: 1;
+    .main-title {
       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;
-      }
-    }
-    .left-t {
-      position: absolute;
-      width: 28%;
-      height: 100%;
-      background: url('@/assets/images/vent/homeNew/leftContent.png') no-repeat center;
-      z-index: 0;
+      font-family: 'douyuFont';
+      font-size: 20px;
+      letter-spacing: 2px;
+      display: flex;
+      justify-content: center;
+      align-items: center;
     }
-    .right-t {
+    .top-nav {
       position: absolute;
-      width: 172%;
+      top: 0;
+      width: 880px;
       height: 100%;
-      background: url('@/assets/images/vent/homeNew/rightContent.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;
-    }
-    .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-trigger-button {
-      color: @vent-font-color;
-      background-image: @vent-configurable-dropdown;
-      border: none;
-      border-bottom: 2px solid @vent-configurable-home-light-border;
+      display: flex;
+      justify-content: flex-start;
     }
   }
-  :deep(.loading-box) {
-    position: unset;
+  .left-t {
+    position: absolute;
+    width: 28%;
+    height: 100%;
+    background: url('@/assets/images/vent/homeNew/leftContent.png') no-repeat center;
+    z-index: 0;
+  }
+  .right-t {
+    position: absolute;
+    width: 172%;
+    height: 100%;
+    background: url('@/assets/images/vent/homeNew/rightContent.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;
+  }
+  .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-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;
+}
 </style>

+ 11 - 48
src/views/vent/home/configurable/components/preset/SysWindCard.vue

@@ -2,26 +2,8 @@
 <template>
   <!-- 基准的画廊模块,通过不同的 type 展示不同的样式 -->
   <div ref="container" class="syswind__container w-full h-full">
-    <!-- 画廊项的具体内容填充剩余宽度 -->
-    <div
-      v-for="item in data"
-      :key="item.deviceID"
-      class="syswind__card"
-      @mouseenter="getTrigger(item.deviceID).onEnter($event)"
-      @mousemove="getTrigger(item.deviceID).onMove($event)"
-      @mouseleave="getTrigger(item.deviceID).onLeave()"
-    >
-      <!-- 数据解读悬浮按钮(公共组件) -->
-      <DataInterpretationBtn
-        v-if="getTrigger(item.deviceID).state.visible"
-        :x="getTrigger(item.deviceID).state.x"
-        :y="getTrigger(item.deviceID).state.y"
-        @click="handleInterpret(item)"
-        @btn-enter="getTrigger(item.deviceID).onBtnEnter()"
-        @btn-leave="getTrigger(item.deviceID).onBtnLeave()"
-      />
-
-      <!-- 卡片内容 -->
+    <div v-for="item in data" :key="item.deviceID" v-data-picker="item" class="syswind__card">
+      <!-- 画廊项的具体内容填充剩余宽度 -->
       <div class="syswind__card__title font-bold" :title="getFormattedText(item, config.title)">{{ getFormattedText(item, config.title) }}</div>
       <div class="flex items-center justify-between syswind__card__vol_wrapper">
         <div class="syswind__card__vol_image" :class="{ overlimit: isVolumnOverLimit(item) }"></div>
@@ -49,35 +31,24 @@
             }"
           >
             <span>{{ getFormattedText(item, ctx.label) }}</span>
-            <span class="font-bold">{{ getFormattedText(item, ctx.value) }}</span>
+            <span class=".font-bold">{{ getFormattedText(item, ctx.value) }}</span>
           </div>
         </div>
       </div>
     </div>
-
-    <!-- 数据解读弹框(公共组件) -->
-    <DataInterpretationModal
-      :visible="modalVisible"
-      :loading="loading"
-      :streaming-text="streamingText"
-      :error="error"
-      :thinking="thinking"
-      :todos="todos"
-      :executing-tools="executingTools"
-      :tool-calls="toolCalls"
-      @close="handleClose"
-    />
   </div>
 </template>
 <script lang="ts" setup>
   import { defaultTo, inRange, first } from 'lodash-es';
   import { getFormattedText } from '../../hooks/helper';
-  // import { onMounted, ref } from 'vue';
-  // import { useEventListener, useScroll } from '@vueuse/core';
+  import { useDataPicker } from '/@/views/ventAI/dataPicker';
 
-  // import { get } from 'lodash-es';
-  // import { computed } from 'vue';
-  import { useDataInterpretationList, DataInterpretationBtn, DataInterpretationModal } from '/@/views/ventAI/dataPicker';
+  const { directive: vDataPicker } = useDataPicker((item) => ({
+    device_id: item.deviceID,
+    device_name: item.strinstallpos,
+    device_type: 'windrect',
+    mode: 'fast',
+  }));
 
   const props = defineProps<{
     config: {
@@ -89,15 +60,8 @@
     data: any[];
   }>();
   //   defineEmits(['click']);
-  // 判断val是否在low、high指定的范围内
-
-  const { modalVisible, loading, streamingText, error, thinking, todos, executingTools, toolCalls, getTrigger, handleInterpret, handleClose } =
-    useDataInterpretationList((item) => ({
-      device_id: item.deviceID,
-      device_name: item.strinstallpos,
-      device_type: 'windrect',
-    }));
 
+  // 判断val是否在low、high指定的范围内
   function isOverLimit(val: string, low: string = '', high: string = '') {
     const l = parseFloat(low);
     const h = parseFloat(high);
@@ -176,7 +140,6 @@
     --image-syswind-icon-spd: url(/@/assets/images/home-container/configurable/preset/syswind-icon-spd.svg);
     --image-syswind-icon-spd-red: url(/@/assets/images/home-container/configurable/preset/syswind-icon-spd-red.svg);
 
-    position: relative;
     background-image: var(--image-syswind-module);
     background-repeat: no-repeat;
     width: 320px;

+ 4 - 4
src/views/vent/monitorManager/alarmMonitor/common/predictionCurve.vue

@@ -10,7 +10,7 @@
 </template>
 
 <script lang="ts" setup>
-  import moment from 'moment';
+  import dayjs from 'dayjs';
   import { ref, watch } from 'vue';
   import EchartLine3 from './echartLine3.vue';
 
@@ -87,14 +87,14 @@
         // indexMark为0表示还没插入过数据,初始化数据
         if (chartConfig.value.indexMark === 0) {
           const arr = new Array(20).fill(0);
-          const startFrom = moment(el.time);
+          const startFrom = dayjs(el.time);
           chartConfig.value = {
             // label: el.label,
             label: '',
             indexMark: 1,
             x: arr.map(() => {
               const str = startFrom.format('HH:mm:ss');
-              startFrom.add(props.timeout || 3000);
+              startFrom.add(props.timeout || 3000, 'millisecond');
               return str;
             }),
             y1: arr.map(() => {
@@ -123,7 +123,7 @@
             val.y4.shift();
             val.indexMark = 19;
           }
-          val.x[val.indexMark] = moment(el.time).format('HH:mm:ss');
+          val.x[val.indexMark] = dayjs(el.time).format('HH:mm:ss');
           val.y1[val.indexMark] = el.data[0];
           val.y2[val.indexMark] = el.data[1];
           val.y3[val.indexMark] = el.data[2];

+ 45 - 54
src/views/vent/monitorManager/comment/components/DeviceBaseInfo.vue

@@ -145,82 +145,73 @@
     const columnList: any[] = [];
     tableHeaderColumns.forEach((item: any) => {
       let columnsItem;
-      if (item.type == 1 || item.type == 10) {
-        columnsItem = {
-          label: item.des, //_dictText
-          field: item.monitorcode,
-          component: item.type == 1 ? 'Input' : item.type == 10 ? 'InputTextArea' : '',
-        };
-      } else {
-        if (item.type == 2 && item['monitorcode'] == 'nsubstationid') {
+      switch (item.type) {
+        case 1:
+          columnsItem = { label: item.des, field: item.monitorcode, component: 'Input' };
+          break;
+        case 10:
+          columnsItem = { label: item.des, field: item.monitorcode, component: 'InputTextArea' };
+          break;
+        case 2:
+          if (item.monitorcode !== 'nsubstationid') break;
           columnsItem = {
-            label: item.des, //_dictText
+            label: item.des,
             field: item.monitorcode,
             component: 'ApiSelect',
-            componentProps: {
-              api: substationList,
-              labelField: 'strname',
-              valueField: 'id',
-            },
+            componentProps: { api: substationList, labelField: 'strname', valueField: 'id' },
           };
-        }
-        if (item.type == 3) {
+          break;
+        case 3:
           columnsItem = {
-            label: item.des, //_dictText
+            label: item.des,
             field: item.monitorcode,
             component: 'RadioGroup',
             defaultValue: 1,
-            componentProps: () => {
-              return {
-                options: [
-                  { label: '是', value: 1, key: '1' },
-                  { label: '否', value: 0, key: '2' },
-                ],
-                stringToNumber: true,
-              };
-            },
+            componentProps: () => ({
+              options: [
+                { label: '是', value: 1, key: '1' },
+                { label: '否', value: 0, key: '2' },
+              ],
+              stringToNumber: true,
+            }),
           };
-        }
-        if (item.type == 4) {
+          break;
+        case 4:
           columnsItem = {
-            label: item.des, //_dictText
+            label: item.des,
             field: item.monitorcode,
             component: 'JDictSelectTag',
-            componentProps: {
-              dictCode: item.dict,
-              placeholder: '请选择',
-              // stringToNumber: true,
-            },
+            componentProps: { dictCode: item.dict, placeholder: '请选择' },
           };
-        }
-        // date日期
-        if (item.type == 8) {
+          break;
+        case 8:
           columnsItem = {
-            label: item.des, //_dictText
+            label: item.des,
             field: item.monitorcode,
             component: 'DatePicker',
-            componentProps: {
-              showTime: false,
-              valueFormat: 'YYYY-MM-DD',
-              getPopupContainer: getAutoScrollContainer,
-            },
+            componentProps: { showTime: false, valueFormat: 'YYYY-MM-DD', getPopupContainer: getAutoScrollContainer },
           };
-        }
-        // 日期+时间
-        if (item.type == 9) {
+          break;
+        case 9:
           columnsItem = {
-            label: item.des, //_dictText
+            label: item.des,
             field: item.monitorcode,
             component: 'DatePicker',
-            componentProps: {
-              showTime: true,
-              valueFormat: 'YYYY-MM-DD HH:mm:ss',
-              getPopupContainer: getAutoScrollContainer,
-            },
+            componentProps: { showTime: true, valueFormat: 'YYYY-MM-DD HH:mm:ss', getPopupContainer: getAutoScrollContainer },
           };
-        }
+          break;
+        case 11:
+          columnsItem = {
+            label: item.des,
+            field: item.monitorcode,
+            component: 'ModelSelect',
+            componentProps: { deviceType: props.deviceType },
+          };
+          break;
+      }
+      if (!!columnsItem) {
+        columnList.push(columnsItem);
       }
-      columnList.push(columnsItem);
     });
     formSchema.value = columnList;
     formSchema.value.unshift(

+ 57 - 19
src/views/vent/monitorManager/deviceMonitor/components/device/modal/atomizing.modal.vue

@@ -72,15 +72,24 @@
               <span class="title">排污状态:</span>
               <div class="flex">
                 <div class="pr-10px"
-                  ><span class="signal-round signal-round-gry" :class="{ 'signal-round-run': atomizingMonitor['airStatus'] === 'FF00' }"></span
+                  ><span
+                    class="signal-round signal-round-gry"
+                    :class="{ 'signal-round-run': atomizingMonitor[airStatusConfig[0].prop] === airStatusConfig[0].val }"
+                  ></span
                   ><span class="pl-10px">开启</span></div
                 >
                 <div class="pr-10px"
-                  ><span class="signal-round signal-round-gry" :class="{ 'signal-round-run': atomizingMonitor['airStatus'] === '0000' }"></span
+                  ><span
+                    class="signal-round signal-round-gry"
+                    :class="{ 'signal-round-run': atomizingMonitor[airStatusConfig[1].prop] === airStatusConfig[1].val }"
+                  ></span
                   ><span class="pl-10px">关闭</span></div
                 >
                 <div class="pr-10px"
-                  ><span class="signal-round signal-round-gry" :class="{ 'signal-round-run': atomizingMonitor['airStatus'] === '0001' }"></span
+                  ><span
+                    class="signal-round signal-round-gry"
+                    :class="{ 'signal-round-run': atomizingMonitor[airStatusConfig[2].prop] === airStatusConfig[2].val }"
+                  ></span
                   ><span class="pl-10px">停止</span></div
                 >
               </div>
@@ -88,14 +97,20 @@
             <div class="base-item flex">
               <span class="title">连接状态:</span>
               <div class="flex">
-                <div class="pr-10px"
-                  ><span class="signal-round signal-round-gry" :class="{ 'signal-round-run': atomizingMonitor['connStatus'] === '0000' }"></span
-                  ><span class="pl-10px">未连接</span></div
-                >
-                <div class="pr-10px"
-                  ><span class="signal-round signal-round-gry" :class="{ 'signal-round-run': atomizingMonitor['connStatus'] === '0001' }"></span
-                  ><span class="pl-10px">已连接</span></div
-                >
+                <div class="pr-10px">
+                  <span
+                    class="signal-round signal-round-gry"
+                    :class="{ 'signal-round-run': atomizingMonitor[netStatusConfig[0].prop] === netStatusConfig[0].val }"
+                  ></span>
+                  <span class="pl-10px">未连接</span>
+                </div>
+                <div class="pr-10px">
+                  <span
+                    class="signal-round signal-round-gry"
+                    :class="{ 'signal-round-run': atomizingMonitor[netStatusConfig[1].prop] === netStatusConfig[1].val }"
+                  ></span>
+                  <span class="pl-10px">已连接</span>
+                </div>
               </div>
             </div>
             <div class="base-item flex">
@@ -198,7 +213,7 @@
             </div>
           </div>
         </div>
-        <div class="right-bottom">
+        <div class="right-bottom" v-if="posList && posList.length > 0">
           <span class="base-title">实时数据监测曲线</span>
           <div class="echarts-box">
             <BarAndLine xAxisPropType="time" :dataSource="posList" height="100%" :chartsColumns="chartsColumns" :option="echatsOption" />
@@ -330,7 +345,7 @@
             // })
             // if(list.length > 0) posList.value = list
             posList.value = item['history'];
-            atomizingMonitor.value = item.readData;
+            atomizingMonitor.value = { ...item, ...item.readData };
             activeDeviceType.value = item.deviceType;
           }
         });
@@ -401,15 +416,36 @@
       const atomizeConfig = computed(() => {
         if (activeDeviceType.value === 'atomizing_cy') {
           return [
-            { prop: 'deviceStatus', val: '1' },
-            { prop: 'deviceStatus', val: '0' },
-            { prop: 'deviceStatus', val: '2' },
+            { prop: 'deviceStatus', val: '-256' }, // 喷雾开启
+            { prop: 'deviceStatus', val: '0' }, // 喷雾关闭
+            { prop: 'deviceStatus', val: '2' }, // 喷雾停止
+          ];
+        }
+        return [
+          { prop: 'waterStatus', val: 'FF00' }, // 喷雾开启
+          { prop: 'waterStatus', val: '0000' }, // 喷雾关闭
+          { prop: 'waterStatus', val: '0001' }, // 喷雾停止
+        ];
+      });
+      // 排污状态配置:默认与 atomizing_cy 使用相同字段和值
+      const airStatusConfig = computed(() => {
+        return [
+          { prop: 'airStatus', val: 'FF00' }, // 排污开启
+          { prop: 'airStatus', val: '0000' }, // 排污关闭
+          { prop: 'airStatus', val: '0001' }, // 排污停止
+        ];
+      });
+      // 连接状态配置:默认与 atomizing_cy 使用相同字段和值
+      const netStatusConfig = computed(() => {
+        if (activeDeviceType.value === 'atomizing_cy') {
+          return [
+            { prop: 'connStatus', val: '0000' }, // 未连接
+            { prop: 'connStatus', val: '0001' }, // 已连接
           ];
         }
         return [
-          { prop: 'waterStatus', val: 'FF00' },
-          { prop: 'waterStatus', val: '0000' },
-          { prop: 'waterStatus', val: '0001' },
+          { prop: 'netStatus', val: 0 }, // 未连接
+          { prop: 'netStatus', val: 1 }, // 已连接
         ];
       });
 
@@ -433,6 +469,8 @@
         handlePasswordConfirm,
         workModeConfig,
         atomizeConfig,
+        airStatusConfig,
+        netStatusConfig,
       };
     },
   });

+ 17 - 7
src/views/vent/monitorManager/fanLocalMonitor/fanLocal.threejs.ts

@@ -95,8 +95,6 @@ export function setModelType(modelType: 'fanLocal' | 'fanLocalDual' | 'fanLocalS
         group.children.forEach((e) => {
           e.visible = true;
         });
-        debugger;
-
         // 模型发生了替换,需要使用摄像头动画// 模型不同需要不同的初始角度与位置
         if (type == 'fanLocal') {
           const oldCameraPosition = { x: 615, y: 275, z: 744 };
@@ -194,19 +192,31 @@ export const destroy = () => {
     if (item.context) item.context.destroy();
   });
   model.destroy();
+  // 清除模块级状态,确保第二次进入时重新初始化
+  model = null;
+  group = undefined;
+  cacheKey = '';
+  modelContextList.length = 0;
 };
 
 // 为了兼容性而添加的方法导出
 export function addText(d, e) {
   if (!model) return;
-  if (modelContextList[0]) modelContextList[0].context?.addText(d);
-  if (modelContextList[1]) modelContextList[1].context?.addText(e);
-  if (modelContextList[2]) modelContextList[2].context?.addText(d);
+  modelContextList.forEach(({ type, context }) => {
+    if (!context || !context.addText) return;
+    // fanLocalDual 需要数组参数(左右风机数据),其余各模型接受单个对象
+    if (type === 'fanLocalDual') {
+      context.addText(e);
+    } else {
+      context.addText(d);
+    }
+  });
 }
 export function addCssText() {
   if (!model) return;
-  if (modelContextList[0] && modelContextList[0].context && modelContextList[0].context['addCssText']) modelContextList[0].context['addCssText']();
-  if (modelContextList[2] && modelContextList[2].context && modelContextList[2].context['addCssText']) modelContextList[2].context['addCssText']();
+  modelContextList.forEach(({ context }) => {
+    if (context && context['addCssText']) context['addCssText']();
+  });
 }
 export function playSmoke(d) {
   if (!model) return;

File diff suppressed because it is too large
+ 700 - 750
src/views/vent/monitorManager/fanLocalMonitor/index.vue


+ 0 - 2
src/views/vent/monitorManager/fireDoorMonitor/fireDoor.threejs.ymh.ts

@@ -87,7 +87,6 @@ class FireDoorYmh {
    */
   initAnimation() {
     const group = this.group.children[0];
-    console.log('debug rr', group.animations[0].tracks);
     /** 卷帘门的元素名称,pCude34-pCude73 */
     if (group) {
       const tracksA: THREE.KeyframeTrack[] = [];
@@ -136,7 +135,6 @@ class FireDoorYmh {
    * @param {number} [timeScale=0.01] - 动画时间缩放因子,控制动画播放速度
    */
   play(handlerState, timeScale = 0.01) {
-    console.log('debug ', handlerState);
     let handler = () => {};
     switch (handlerState) {
       case 1: // 打开门

+ 6 - 3
src/views/vent/monitorManager/gateMonitor/gate.threejs.ts

@@ -15,8 +15,9 @@ import FmXR from './gate.threejs.xr';
 // import FmSp1 from './gate.threejs.one.sp';
 import { animateCamera } from '/@/utils/threejs/util';
 import useEvent from '../../../../utils/threejs/useEvent';
+import { useModelFileStore } from '/@/store/modules/modelFile';
 import { getDictItemsByCode } from '/@/utils/dict';
-import { useGlobSetting } from '/@/hooks/setting';
+// import { useGlobSetting } from '/@/hooks/setting';
 
 // 模型对象、 文字对象
 let model,
@@ -868,11 +869,12 @@ export const mountedThree = () => {
     model.setEnvMap('test1.hdr');
     model.renderer.toneMappingExposure = 0.9;
     model.camera.position.set(100, 0, 1000);
-    const dictCodes = getDictItemsByCode('gateStyle');
+    const store = useModelFileStore();
+    const dictCodes = store.getModelFileDataByKey('gate').length ? store.getModelFileDataByKey('gate') : getDictItemsByCode('gateStyle');
     if (dictCodes && dictCodes.length > 0) {
       for (let i = 0; i < dictCodes.length; i++) {
         const dict = dictCodes[i];
-        switch (dict.value) {
+        switch (dict.fileName) {
           case 'fmXr':
             const FmXR = await loadModel('FmXR');
             fmXr = new FmXR(model);
@@ -981,6 +983,7 @@ export const mountedThree = () => {
     }
 
     model.animate();
+    // generateThumbnailsBySwitch();
     // startAnimation();
   });
 };

+ 521 - 521
src/views/vent/monitorManager/gateMonitor/index.vue

@@ -401,589 +401,589 @@
 </template>
 
 <script setup lang="ts">
-  import { onBeforeUnmount, onUnmounted, onMounted, ref, reactive, nextTick, inject, unref, defineAsyncComponent, provide } from 'vue';
-  import MonitorTable from '../comment/MonitorTable.vue';
-  import HistoryTable from '../comment/HistoryTable.vue';
-  import AlarmHistoryTable from '../comment/AlarmHistoryTable.vue';
-  import HandlerHistoryTable from '../comment/HandlerHistoryTable.vue';
-  import WindDoorFaultTable from './components/WindDoorFaultTable.vue';
-  import CarDamageTable from './components/CarDamageTable.vue';
-  import AlarmHistoryTableHj from './components/AlarmHistoryTableHj.vue';
-  import HandleModal from './modal.vue';
-  import DeviceBaseInfo from '../comment/components/DeviceBaseInfo.vue';
-  import { mountedThree, addMonitorText, play, destroy, setModelType, computePlay } from './gate.threejs';
-  import { deviceControlApi } from '/@/api/vent/index';
-  import { message } from 'ant-design-vue';
-  import { list, getTableList, cameraList, cameraAddrList, getOrderByCount, getCarList } from './gate.api';
-  import { chartsColumns, getModelComponent } from './gate.data';
-  import { cloneDeep } from 'lodash-es';
-  import { setDivHeight } from '/@/utils/event';
-  import { BorderBox8 as DvBorderBox8 } from '@kjgl77/datav-vue3';
-  import { useRouter } from 'vue-router';
-  import LivePlayer from '@liveqing/liveplayer-v3';
-  import { useModal } from '/@/components/Modal';
-  import { useCamera } from '/@/hooks/system/useCamera';
-  import { usePermission } from '/@/hooks/web/usePermission';
-  import { getDictItems } from '/@/api/common/api';
-  import { render } from '/@/utils/common/renderUtils';
-  import { useGlobSetting } from '/@/hooks/setting';
-  import { getDictItemsByCode } from '/@/utils/dict';
-  import HistoryTableChart from '../comment/HistoryTableChart.vue';
-
-  const { hasPermission } = usePermission();
-  const { sysOrgCode } = useGlobSetting();
-  const globalConfig = inject<any>('globalConfig');
-
-  const modelRef = ref();
-  /** 模型对应的组件,根据实际情况分为二维三维 */
-  const modelComponent = getModelComponent(globalConfig.is2DModel);
-
-  const { currentRoute } = useRouter();
-  const gateId = ref(''); //风门ID
-  const MonitorDataTable = ref();
-  let contrlValue = '';
-  const playerRef = ref();
-  const deviceType = ref('gate');
-  const activeKey = ref('1'); // tab
-  const loading = ref(false);
-  const renderPlayer = ref(true);
-  // const stationType = ref('plc1');
-  const scroll = reactive({
-    y: 230,
-  });
-  const modelList = ref<{ text: string; value: string }[]>([]);
-  const frontDoorIsOpen = ref(false); //前门是否开启
-  const backDoorIsOpen = ref(false); //后门是否开启
-  const midDoorIsOpen = ref(false); //中间门是否开启
-
-  const modalIsShow = ref<boolean>(false); // 是否显示模态框
-  const modalTitle = ref(''); // 模态框标题显示内容,根据设备操作类型决定
-  const modalType = ref(''); // 模态框内容显示类型,设备操作类型
-
-  const selectRowIndex = ref(-1); // 选中行
-  const dataSource = ref([]);
-  const sharedData = ref([]);
-  const deviceBaseList = ref([]); // 设备基本信息
-  const updateSharedData = (data) => {
-    sharedData.value = data;
-  };
-  const Option = {
-    grid: {
-      top: '20%',
-      left: '5%',
-      right: '5%',
-      bottom: '3%',
-      containLabel: true,
-    },
-    toolbox: {
-      feature: null,
-    },
-  };
-
-  /** 封装通用的模态框打开方法(setControl 模式:点位code直接传参) */
-  const openGateControlModal = (paramcode: string, title: string) => {
-    modalType.value = paramcode;
-    modalTitle.value = title;
-    modalIsShow.value = true;
-
-    // 模拟密码模式下直接执行操作
-    if (globalConfig?.simulatedPassword) {
-      handleOK('', paramcode);
-    }
-  };
-
-  /** 统一控制入口:点位code + 标题 直接传参(与 windowMonitor 的 setControl 模式一致) */
-  const setControl = (paramcode: string, title: string) => {
-    openGateControlModal(paramcode, title);
-  };
-
-  /** 控制模式切换(paramcode 需根据 contrlMod + contrlValue 动态决定) */
-  const setControlMode = (value?: string) => {
-    contrlValue = value;
-    openGateControlModal('modeSwitch', '控制模式切换');
-  };
-
-  const [registerModal, { openModal, closeModal }] = useModal();
-
-  const { getCamera, removeCamera } = useCamera();
+import { onBeforeUnmount, onUnmounted, onMounted, ref, reactive, nextTick, inject, unref, defineAsyncComponent, provide } from 'vue';
+import MonitorTable from '../comment/MonitorTable.vue';
+import HistoryTable from '../comment/HistoryTable.vue';
+import AlarmHistoryTable from '../comment/AlarmHistoryTable.vue';
+import HandlerHistoryTable from '../comment/HandlerHistoryTable.vue';
+import WindDoorFaultTable from './components/WindDoorFaultTable.vue';
+import CarDamageTable from './components/CarDamageTable.vue';
+import AlarmHistoryTableHj from './components/AlarmHistoryTableHj.vue';
+import HandleModal from './modal.vue';
+import DeviceBaseInfo from '../comment/components/DeviceBaseInfo.vue';
+import { mountedThree, addMonitorText, play, destroy, setModelType, computePlay } from './gate.threejs';
+import { deviceControlApi } from '/@/api/vent/index';
+import { message } from 'ant-design-vue';
+import { list, getTableList, cameraList, cameraAddrList, getOrderByCount, getCarList } from './gate.api';
+import { chartsColumns, getModelComponent } from './gate.data';
+import { cloneDeep } from 'lodash-es';
+import { setDivHeight } from '/@/utils/event';
+import { BorderBox8 as DvBorderBox8 } from '@kjgl77/datav-vue3';
+import { useRouter } from 'vue-router';
+import LivePlayer from '@liveqing/liveplayer-v3';
+import { useModal } from '/@/components/Modal';
+import { useCamera } from '/@/hooks/system/useCamera';
+import { usePermission } from '/@/hooks/web/usePermission';
+import { getDictItems } from '/@/api/common/api';
+import { render } from '/@/utils/common/renderUtils';
+import { useGlobSetting } from '/@/hooks/setting';
+import { getDictItemsByCode } from '/@/utils/dict';
+import HistoryTableChart from '../comment/HistoryTableChart.vue';
+
+const { hasPermission } = usePermission();
+const { sysOrgCode } = useGlobSetting();
+const globalConfig = inject<any>('globalConfig');
+
+const modelRef = ref();
+/** 模型对应的组件,根据实际情况分为二维三维 */
+const modelComponent = getModelComponent(globalConfig.is2DModel);
+
+const { currentRoute } = useRouter();
+const gateId = ref(''); //风门ID
+const MonitorDataTable = ref();
+let contrlValue = '';
+const playerRef = ref();
+const deviceType = ref('gate');
+const activeKey = ref('1'); // tab
+const loading = ref(false);
+const renderPlayer = ref(true);
+// const stationType = ref('plc1');
+const scroll = reactive({
+  y: 230,
+});
+const modelList = ref<{ text: string; value: string }[]>([]);
+const frontDoorIsOpen = ref(false); //前门是否开启
+const backDoorIsOpen = ref(false); //后门是否开启
+const midDoorIsOpen = ref(false); //中间门是否开启
+
+const modalIsShow = ref<boolean>(false); // 是否显示模态框
+const modalTitle = ref(''); // 模态框标题显示内容,根据设备操作类型决定
+const modalType = ref(''); // 模态框内容显示类型,设备操作类型
+
+const selectRowIndex = ref(-1); // 选中行
+const dataSource = ref([]);
+const sharedData = ref([]);
+const deviceBaseList = ref([]); // 设备基本信息
+const updateSharedData = (data) => {
+  sharedData.value = data;
+};
+const Option = {
+  grid: {
+    top: '20%',
+    left: '5%',
+    right: '5%',
+    bottom: '3%',
+    containLabel: true,
+  },
+  toolbox: {
+    feature: null,
+  },
+};
+
+/** 封装通用的模态框打开方法(setControl 模式:点位code直接传参) */
+const openGateControlModal = (paramcode: string, title: string) => {
+  modalType.value = paramcode;
+  modalTitle.value = title;
+  modalIsShow.value = true;
+
+  // 模拟密码模式下直接执行操作
+  if (globalConfig?.simulatedPassword) {
+    handleOK('', paramcode);
+  }
+};
 
-  const tabChange = (activeKeyVal) => {
-    activeKey.value = activeKeyVal;
-    if (activeKeyVal == 1) {
-      nextTick(() => {
-        if (MonitorDataTable.value) MonitorDataTable.value.setSelectedRowKeys([selectData.deviceID]);
-      });
-    }
-  };
+/** 统一控制入口:点位code + 标题 直接传参(与 windowMonitor 的 setControl 模式一致) */
+const setControl = (paramcode: string, title: string) => {
+  openGateControlModal(paramcode, title);
+};
 
-  const initData = {
-    deviceID: '',
-    deviceType: '',
-    strname: '',
-    frontRearDP: '-', //压差
-    // sourcePressure: '-', //气源压力
-    runRoRecondition: null,
-    autoRoManual: null,
-    netStatus: '0', //通信状态
-    frontGateOpen: '0',
-    frontGateClose: '1',
-    rearGateOpen: '0',
-    rearGateClose: '1',
-    midGateOpen: '0',
-    midGateClose: '1',
-    fault: '气源压力超限',
-    masterComputer: 0,
-    frontGateOpenCtrl: false,
-    rearGateOpenCtrl: false,
-    cameras: [],
-  };
+/** 控制模式切换(paramcode 需根据 contrlMod + contrlValue 动态决定) */
+const setControlMode = (value?: string) => {
+  contrlValue = value;
+  openGateControlModal('modeSwitch', '控制模式切换');
+};
 
-  // 监测数据
-  const selectData = reactive(cloneDeep(initData));
+const [registerModal, { openModal, closeModal }] = useModal();
 
-  const flvURL1 = () => {
-    // return ''
-    return `/video/gate.mp4`;
-  };
+const { getCamera, removeCamera } = useCamera();
 
-  function deviceEdit(e: Event, type: string, record) {
-    e.stopPropagation();
-    openModal(true, {
-      type,
-      deviceId: record['deviceID'],
-    });
-  }
-  // 获取设备基本信息列表
-  function getDeviceBaseList() {
-    getTableList({ pageSize: 1000 }).then((res) => {
-      deviceBaseList.value = res.records;
+const tabChange = (activeKeyVal) => {
+  activeKey.value = activeKeyVal;
+  if (activeKeyVal == 1) {
+    nextTick(() => {
+      if (MonitorDataTable.value) MonitorDataTable.value.setSelectedRowKeys([selectData.deviceID]);
     });
   }
-
-  // https获取监测数据
-  let timer: null | NodeJS.Timeout = null;
-  async function getMonitor(flag?) {
-    if (Object.prototype.toString.call(timer) === '[object Null]') {
-      timer = await setTimeout(
-        async () => {
-          const res = await list({ devicetype: deviceType.value, pagetype: 'normal' });
-          if (res.msgTxt && res.msgTxt[0]) {
-            dataSource.value = res.msgTxt[0].datalist || [];
-            dataSource.value.forEach((data: any) => {
-              const readData = data.readData;
-              data = Object.assign(data, readData);
-            });
-            if (dataSource.value.length > 0 && selectRowIndex.value == -1 && MonitorDataTable.value) {
-              // 初始打开页面
-              if (currentRoute.value && currentRoute.value['query'] && currentRoute.value['query']['id']) {
-                MonitorDataTable.value.setSelectedRowKeys([currentRoute.value['query']['id']]);
-              } else {
-                MonitorDataTable.value.setSelectedRowKeys([dataSource.value[0]['deviceID']]);
-              }
-            }
-            Object.assign(selectData, dataSource.value[selectRowIndex.value]);
-            if (selectData.contrlMod == 'jnjhCtrl') {
-              selectData['autoRoManual'] = selectData['autoRoManual'] == 1 ? true : false;
-              selectData['autoRoManual1'] = selectData['autoRoManual1'] == 1 ? true : false;
-              selectData['autoRoManual2'] = selectData['autoRoManual2'] == 1 ? true : false;
-            }
-            addMonitorText(selectData);
-            monitorAnimation(selectData);
-            if (timer) {
-              timer = null;
+};
+
+const initData = {
+  deviceID: '',
+  deviceType: '',
+  strname: '',
+  frontRearDP: '-', //压差
+  // sourcePressure: '-', //气源压力
+  runRoRecondition: null,
+  autoRoManual: null,
+  netStatus: '0', //通信状态
+  frontGateOpen: '0',
+  frontGateClose: '1',
+  rearGateOpen: '0',
+  rearGateClose: '1',
+  midGateOpen: '0',
+  midGateClose: '1',
+  fault: '气源压力超限',
+  masterComputer: 0,
+  frontGateOpenCtrl: false,
+  rearGateOpenCtrl: false,
+  cameras: [],
+};
+
+// 监测数据
+const selectData = reactive(cloneDeep(initData));
+
+const flvURL1 = () => {
+  // return ''
+  return `/video/gate.mp4`;
+};
+
+function deviceEdit(e: Event, type: string, record) {
+  e.stopPropagation();
+  openModal(true, {
+    type,
+    deviceId: record['deviceID'],
+  });
+}
+// 获取设备基本信息列表
+function getDeviceBaseList() {
+  getTableList({ pageSize: 1000 }).then((res) => {
+    deviceBaseList.value = res.records;
+  });
+}
+
+// https获取监测数据
+let timer: null | NodeJS.Timeout = null;
+async function getMonitor(flag?) {
+  if (Object.prototype.toString.call(timer) === '[object Null]') {
+    timer = await setTimeout(
+      async () => {
+        const res = await list({ devicetype: deviceType.value, pagetype: 'normal' });
+        if (res.msgTxt && res.msgTxt[0]) {
+          dataSource.value = res.msgTxt[0].datalist || [];
+          dataSource.value.forEach((data: any) => {
+            const readData = data.readData;
+            data = Object.assign(data, readData);
+          });
+          if (dataSource.value.length > 0 && selectRowIndex.value == -1 && MonitorDataTable.value) {
+            // 初始打开页面
+            if (currentRoute.value && currentRoute.value['query'] && currentRoute.value['query']['id']) {
+              MonitorDataTable.value.setSelectedRowKeys([currentRoute.value['query']['id']]);
+            } else {
+              MonitorDataTable.value.setSelectedRowKeys([dataSource.value[0]['deviceID']]);
             }
-            getMonitor();
           }
-        },
-        flag ? 0 : 1000
-      );
-    }
+          Object.assign(selectData, dataSource.value[selectRowIndex.value]);
+          if (selectData.contrlMod == 'jnjhCtrl') {
+            selectData['autoRoManual'] = selectData['autoRoManual'] == 1 ? true : false;
+            selectData['autoRoManual1'] = selectData['autoRoManual1'] == 1 ? true : false;
+            selectData['autoRoManual2'] = selectData['autoRoManual2'] == 1 ? true : false;
+          }
+          addMonitorText(selectData);
+          monitorAnimation(selectData);
+          if (timer) {
+            timer = null;
+          }
+          getMonitor();
+        }
+      },
+      flag ? 0 : 1000
+    );
   }
-
-  // 切换检测数据
-  async function getSelectRow(selectRow, index) {
-    if (!selectRow) return;
-    loading.value = true;
-    selectRowIndex.value = index;
-    gateId.value = selectRow.deviceID;
-    const baseData: any = deviceBaseList.value.find((baseData: any) => baseData.id === selectRow.deviceID);
-    Object.assign(selectData, initData, selectRow, baseData);
-    isFrontOpenRunning = false; //开关门动作是否在进行
-    isRearOpenRunning = false; //开关门动作是否在进行
-    isMidOpenRunning = false; //开关门动作是否在进行
-    frontDeviceState = 0; //记录设备状态,为了与下一次监测数据做比较
-    rearDeviceState = 0; //记录设备状态,为了与下一次监测数据做比较
-    midDeviceState = 0; //记录设备状态,为了与下一次监测数据做比较
-
-    let type;
-    const dictCodes = getDictItemsByCode('gateStyle');
-    if (selectData && dictCodes && dictCodes.length > 0) {
-      const gateStyle = selectData['gateStyle'];
-      switch (gateStyle) {
-        case 'gate_qd':
-          type = 'fm3';
-          break;
-        case 'fmtl3':
-          type = 'fmThreeTl';
-          break;
-        case 'fmSs':
-          type = 'fmTwoSs';
-          break;
-        case 'fm_fc':
-          type = 'fmWindow';
-          break;
-        case 'fmXr':
-          type = 'fmXr';
-          break;
-        case 'fmYy':
-          type = 'fm1';
-          break;
-        case 'fmSs3':
-          type = 'fm2';
-          break;
-        case 'fm_fc_hjg':
-          type = 'fmWindowHjg';
-          break;
-        case 'fm_fc_zhq':
-          type = 'fmWindowZhq';
-          break;
-        default:
-          type = gateStyle;
-      }
-    } else {
-      type = selectData.nwindownum == 1 ? 'singleWindow' : 'doubleWindow';
-      if (selectData['doorUse'] == 2) {
-        type = 'fmXr';
-      } else if (selectData.ndoorcount == '3' || selectData.deviceType == 'gate_nomal3') {
+}
+
+// 切换检测数据
+async function getSelectRow(selectRow, index) {
+  if (!selectRow) return;
+  loading.value = true;
+  selectRowIndex.value = index;
+  gateId.value = selectRow.deviceID;
+  const baseData: any = deviceBaseList.value.find((baseData: any) => baseData.id === selectRow.deviceID);
+  Object.assign(selectData, initData, selectRow, baseData);
+  isFrontOpenRunning = false; //开关门动作是否在进行
+  isRearOpenRunning = false; //开关门动作是否在进行
+  isMidOpenRunning = false; //开关门动作是否在进行
+  frontDeviceState = 0; //记录设备状态,为了与下一次监测数据做比较
+  rearDeviceState = 0; //记录设备状态,为了与下一次监测数据做比较
+  midDeviceState = 0; //记录设备状态,为了与下一次监测数据做比较
+
+  let type;
+  const dictCodes = getDictItemsByCode('gateStyle');
+  if (selectData && dictCodes && dictCodes.length > 0) {
+    const gateStyle = selectData['gateStyle'];
+    switch (gateStyle) {
+      case 'gate_qd':
+        type = 'fm3';
+        break;
+      case 'fmtl3':
         type = 'fmThreeTl';
+        break;
+      case 'fmSs':
+        type = 'fmTwoSs';
+        break;
+      case 'fm_fc':
+        type = 'fmWindow';
+        break;
+      case 'fmXr':
+        type = 'fmXr';
+        break;
+      case 'fmYy':
+        type = 'fm1';
+        break;
+      case 'fmSs3':
+        type = 'fm2';
+        break;
+      case 'fm_fc_hjg':
+        type = 'fmWindowHjg';
+        break;
+      case 'fm_fc_zhq':
+        type = 'fmWindowZhq';
+        break;
+      default:
+        type = gateStyle;
+    }
+  } else {
+    type = selectData.nwindownum == 1 ? 'singleWindow' : 'doubleWindow';
+    if (selectData['doorUse'] == 2) {
+      type = 'fmXr';
+    } else if (selectData.ndoorcount == '3' || selectData.deviceType == 'gate_nomal3') {
+      type = 'fmThreeTl';
+    } else {
+      if (selectData.deviceType == 'gate_ss') {
+        type = 'fm2';
+      } else if (selectData.deviceType == 'gate_qd' || selectData.deviceType == 'gate_normal') {
+        type = 'fm3';
+      } else if (selectData.deviceType == 'gate_ss_two' || selectData.deviceType == 'gate_ss_two1') {
+        type = 'fmTwoSs';
+      } else if (selectData.deviceType == 'gate_tj') {
+        type = 'fmWindow';
       } else {
-        if (selectData.deviceType == 'gate_ss') {
-          type = 'fm2';
-        } else if (selectData.deviceType == 'gate_qd' || selectData.deviceType == 'gate_normal') {
-          type = 'fm3';
-        } else if (selectData.deviceType == 'gate_ss_two' || selectData.deviceType == 'gate_ss_two1') {
-          type = 'fmTwoSs';
-        } else if (selectData.deviceType == 'gate_tj') {
-          type = 'fmWindow';
-        } else {
-          type = 'fm1'; // 液压
-        }
+        type = 'fm1'; // 液压
       }
     }
+  }
 
-    await setSVGModelType(type);
-    await setModelType(type);
-    addMonitorText(selectData);
-    // playAnimation(selectRow, selectData.value.maxarea, true);
-    loading.value = false;
-    await getCamera(selectRow.deviceID, playerRef, renderPlayer);
+  await setSVGModelType(type);
+  await setModelType(type);
+  addMonitorText(selectData);
+  // playAnimation(selectRow, selectData.value.maxarea, true);
+  loading.value = false;
+  await getCamera(selectRow.deviceID, playerRef, renderPlayer);
+}
+
+/** 检修切换 */
+const changeOverhaul = (e) => {
+  // 检修值需要在打开模态框前设置(handleOK 中按 toggle 逻辑计算下发值)
+  openGateControlModal('overhaul', '检修控制');
+  contrlValue = e;
+};
+
+/** 统一下发控制指令(仿 windowMonitor 模式:handlerState 即点位code,特殊情况单独判断) */
+function handleOK(passWord, handlerState, value?) {
+  if (!passWord && !globalConfig?.simulatedPassword) {
+    message.warning('请输入密码');
+    return;
   }
 
-  /** 检修切换 */
-  const changeOverhaul = (e) => {
-    // 检修值需要在打开模态框前设置(handleOK 中按 toggle 逻辑计算下发值)
-    openGateControlModal('overhaul', '检修控制');
-    contrlValue = e;
+  const data = {
+    deviceid: selectData.deviceID,
+    devicetype: selectData.deviceType,
+    paramcode: '',
+    value: value ?? null,
+    password: passWord || globalConfig?.simulatedPassword,
+    masterComputer: selectData.masterComputer,
   };
 
-  /** 统一下发控制指令(仿 windowMonitor 模式:handlerState 即点位code,特殊情况单独判断) */
-  function handleOK(passWord, handlerState, value?) {
-    if (!passWord && !globalConfig?.simulatedPassword) {
-      message.warning('请输入密码');
-      return;
-    }
-
-    const data = {
-      deviceid: selectData.deviceID,
-      devicetype: selectData.deviceType,
-      paramcode: '',
-      value: value ?? null,
-      password: passWord || globalConfig?.simulatedPassword,
-      masterComputer: selectData.masterComputer,
-    };
-
-    // 特殊:控制模式切换(paramcode 根据 contrlMod + contrlValue 动态决定)
-    if (handlerState === 'modeSwitch') {
-      if (selectData.contrlMod == 'codeCtrl') {
-        if (contrlValue == '1') {
-          data.paramcode = 'autoRoManualControl1';
-        } else if (contrlValue == '0') {
-          data.paramcode = 'autoRoManualControl2';
-        } else {
-          data.paramcode = 'autoRoManualControl0';
-        }
-        data.value = '';
-      } else if (selectData.contrlMod == 'loopCtrl' || selectData.contrlMod == 'jnjhCtrl') {
-        data.paramcode = 'autoRoManualControl';
-        data.value = '';
+  // 特殊:控制模式切换(paramcode 根据 contrlMod + contrlValue 动态决定)
+  if (handlerState === 'modeSwitch') {
+    if (selectData.contrlMod == 'codeCtrl') {
+      if (contrlValue == '1') {
+        data.paramcode = 'autoRoManualControl1';
+      } else if (contrlValue == '0') {
+        data.paramcode = 'autoRoManualControl2';
       } else {
-        data.paramcode = 'autoRoManualControl';
-        data.value = contrlValue;
+        data.paramcode = 'autoRoManualControl0';
       }
-      selectData.autoRoManual = null;
-    }
-    // 特殊:检修控制(value 取反 toggle)
-    else if (handlerState === 'overhaul') {
-      data.paramcode = 'runRoRecondition_S';
-      data.value = selectData['runRoRecondition'] == 0 ? '1' : '0';
-    }
-    // 默认:handlerState 即点位code(setControl 直接传参)
-    else {
-      data.paramcode = handlerState;
-      data.value = value ?? contrlValue;
+      data.value = '';
+    } else if (selectData.contrlMod == 'loopCtrl' || selectData.contrlMod == 'jnjhCtrl') {
+      data.paramcode = 'autoRoManualControl';
+      data.value = '';
+    } else {
+      data.paramcode = 'autoRoManualControl';
+      data.value = contrlValue;
     }
+    selectData.autoRoManual = null;
+  }
+  // 特殊:检修控制(value 取反 toggle)
+  else if (handlerState === 'overhaul') {
+    data.paramcode = 'runRoRecondition_S';
+    data.value = selectData['runRoRecondition'] == 0 ? '1' : '0';
+  }
+  // 默认:handlerState 即点位code(setControl 直接传参)
+  else {
+    data.paramcode = handlerState;
+    data.value = '';
+  }
 
-    if (data.paramcode) {
-      deviceControlApi(data)
-        .then((res) => {
-          if (res.success) {
-            modalIsShow.value = false;
-            if (globalConfig.History_Type == 'remote') {
-              message.success('指令已下发至生产管控平台成功!');
-            } else {
-              message.success('指令已下发成功!');
-            }
+  if (data.paramcode) {
+    deviceControlApi(data)
+      .then((res) => {
+        if (res.success) {
+          modalIsShow.value = false;
+          if (globalConfig.History_Type == 'remote') {
+            message.success('指令已下发至生产管控平台成功!');
           } else {
-            message.error(res.message);
+            message.success('指令已下发成功!');
           }
-          contrlValue = '';
-        })
-        .catch(() => {
-          message.error('控制异常,请排查问题。。。');
-          contrlValue = '';
-        });
-    }
+        } else {
+          message.error(res.message);
+        }
+        contrlValue = '';
+      })
+      .catch(() => {
+        message.error('控制异常,请排查问题。。。');
+        contrlValue = '';
+      });
   }
-  let isOpenRunning = false; //开关门动作是否在进行
-  /** 开关门动画调用 */
-  let isFrontOpenRunning = false; //开关门动作是否在进行
-  // let isFrontCloseRunning = false; //开关门动作是否在进行
-  let isRearOpenRunning = false; //开关门动作是否在进行
-  // let isRearCloseRunning = false; //开关门动作是否在进行
-  let isMidOpenRunning = false; //中间门动作是否在进行
-  // let isMidCloseRunning = false; //中间门动作是否在进行
-  // 0 关闭 1 正在打开 2 打开 3正在关闭
-  let frontDeviceState = 0; //记录设备状态,为了与下一次监测数据做比较
-  let rearDeviceState = 0; //记录设备状态,为了与下一次监测数据做比较
-  let midDeviceState = 0; //记录设备状态,为了与下一次监测数据做比较
-
-  function monitorAnimation(selectData) {
-    const timeScale = 0.005;
-    // 带风窗 风窗动画
-    if (selectData['gateStyle'] && selectData['gateStyle'].includes('fm_fc')) playWindowAnimation(selectData);
-
-    if (selectData.frontGateOpen == '1' && selectData.frontGateClose == '0' && !isFrontOpenRunning) {
-      isFrontOpenRunning = true;
-      if (frontDeviceState != 1) {
-        // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(1, timeScale) : play(1);
-        play(1, timeScale);
-        frontDeviceState = 1;
-        frontDoorIsOpen.value = false;
-        backDoorIsOpen.value = true;
-      }
+}
+let isOpenRunning = false; //开关门动作是否在进行
+/** 开关门动画调用 */
+let isFrontOpenRunning = false; //开关门动作是否在进行
+// let isFrontCloseRunning = false; //开关门动作是否在进行
+let isRearOpenRunning = false; //开关门动作是否在进行
+// let isRearCloseRunning = false; //开关门动作是否在进行
+let isMidOpenRunning = false; //中间门动作是否在进行
+// let isMidCloseRunning = false; //中间门动作是否在进行
+// 0 关闭 1 正在打开 2 打开 3正在关闭
+let frontDeviceState = 0; //记录设备状态,为了与下一次监测数据做比较
+let rearDeviceState = 0; //记录设备状态,为了与下一次监测数据做比较
+let midDeviceState = 0; //记录设备状态,为了与下一次监测数据做比较
+
+function monitorAnimation(selectData) {
+  const timeScale = 0.005;
+  // 带风窗 风窗动画
+  if (selectData['gateStyle'] && selectData['gateStyle'].includes('fm_fc')) playWindowAnimation(selectData);
+
+  if (selectData.frontGateOpen == '1' && selectData.frontGateClose == '0' && !isFrontOpenRunning) {
+    isFrontOpenRunning = true;
+    if (frontDeviceState != 1) {
+      // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(1, timeScale) : play(1);
+      play(1, timeScale);
+      frontDeviceState = 1;
+      frontDoorIsOpen.value = false;
+      backDoorIsOpen.value = true;
     }
+  }
 
-    if (selectData.frontGateOpen == '0' && selectData.frontGateClose == '0' && !isFrontOpenRunning) {
-      isFrontOpenRunning = true;
-      if (frontDeviceState != 1) {
-        // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(1, timeScale) : play(1);
-        play(1, timeScale);
-        frontDeviceState = 1;
-        frontDoorIsOpen.value = false;
-        backDoorIsOpen.value = true;
-      }
+  if (selectData.frontGateOpen == '0' && selectData.frontGateClose == '0' && !isFrontOpenRunning) {
+    isFrontOpenRunning = true;
+    if (frontDeviceState != 1) {
+      // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(1, timeScale) : play(1);
+      play(1, timeScale);
+      frontDeviceState = 1;
+      frontDoorIsOpen.value = false;
+      backDoorIsOpen.value = true;
     }
+  }
 
-    if (selectData.frontGateClose == '1' && selectData.frontGateOpen == '0' && isFrontOpenRunning) {
-      isFrontOpenRunning = false;
-      if (frontDeviceState != 0) {
-        // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(2, timeScale) : play(2);
-        play(2, timeScale);
-        frontDeviceState = 0;
-        frontDoorIsOpen.value = false;
-        // backDoorIsOpen.value = false
-      }
+  if (selectData.frontGateClose == '1' && selectData.frontGateOpen == '0' && isFrontOpenRunning) {
+    isFrontOpenRunning = false;
+    if (frontDeviceState != 0) {
+      // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(2, timeScale) : play(2);
+      play(2, timeScale);
+      frontDeviceState = 0;
+      frontDoorIsOpen.value = false;
+      // backDoorIsOpen.value = false
     }
-    if (selectData.rearGateOpen == '1' && selectData.rearGateClose == '0' && !isRearOpenRunning) {
-      isRearOpenRunning = true;
-
-      if (rearDeviceState != 1) {
-        // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(3, timeScale) : play(3);
-        play(3, timeScale);
-        rearDeviceState = 1;
-        backDoorIsOpen.value = false;
-        frontDoorIsOpen.value = true;
-      }
+  }
+  if (selectData.rearGateOpen == '1' && selectData.rearGateClose == '0' && !isRearOpenRunning) {
+    isRearOpenRunning = true;
+
+    if (rearDeviceState != 1) {
+      // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(3, timeScale) : play(3);
+      play(3, timeScale);
+      rearDeviceState = 1;
+      backDoorIsOpen.value = false;
+      frontDoorIsOpen.value = true;
     }
-    if (selectData.rearGateOpen == '0' && selectData.rearGateClose == '0' && !isRearOpenRunning) {
-      isRearOpenRunning = true;
-
-      if (rearDeviceState != 1) {
-        // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(3, timeScale) : play(3);
-        play(3, timeScale);
-        rearDeviceState = 1;
-        backDoorIsOpen.value = false;
-        frontDoorIsOpen.value = true;
-      }
+  }
+  if (selectData.rearGateOpen == '0' && selectData.rearGateClose == '0' && !isRearOpenRunning) {
+    isRearOpenRunning = true;
+
+    if (rearDeviceState != 1) {
+      // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(3, timeScale) : play(3);
+      play(3, timeScale);
+      rearDeviceState = 1;
+      backDoorIsOpen.value = false;
+      frontDoorIsOpen.value = true;
     }
+  }
 
-    if (selectData.rearGateClose == '1' && selectData.rearGateOpen == '0' && isRearOpenRunning) {
-      isRearOpenRunning = false;
-      if (rearDeviceState != 0) {
-        // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(4, timeScale) : play(4);
-        play(4, timeScale);
-        rearDeviceState = 0;
-        backDoorIsOpen.value = false;
-      }
+  if (selectData.rearGateClose == '1' && selectData.rearGateOpen == '0' && isRearOpenRunning) {
+    isRearOpenRunning = false;
+    if (rearDeviceState != 0) {
+      // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(4, timeScale) : play(4);
+      play(4, timeScale);
+      rearDeviceState = 0;
+      backDoorIsOpen.value = false;
     }
+  }
 
-    if (selectData.midGateOpen == '1' && selectData.midGateClose == '0' && !isMidOpenRunning) {
-      isMidOpenRunning = true;
+  if (selectData.midGateOpen == '1' && selectData.midGateClose == '0' && !isMidOpenRunning) {
+    isMidOpenRunning = true;
 
-      if (midDeviceState != 1) {
-        // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(3, timeScale) : play(3);
-        play(8, timeScale);
-        midDeviceState = 1;
-        backDoorIsOpen.value = false;
-        frontDoorIsOpen.value = true;
-      }
+    if (midDeviceState != 1) {
+      // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(3, timeScale) : play(3);
+      play(8, timeScale);
+      midDeviceState = 1;
+      backDoorIsOpen.value = false;
+      frontDoorIsOpen.value = true;
     }
+  }
 
-    if (selectData.midGateOpen == '0' && selectData.midGateClose == '0' && !isMidOpenRunning) {
-      isMidOpenRunning = true;
+  if (selectData.midGateOpen == '0' && selectData.midGateClose == '0' && !isMidOpenRunning) {
+    isMidOpenRunning = true;
 
-      if (midDeviceState != 1) {
-        // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(3, timeScale) : play(3);
-        play(8, timeScale);
-        midDeviceState = 1;
-        backDoorIsOpen.value = false;
-        frontDoorIsOpen.value = true;
-      }
+    if (midDeviceState != 1) {
+      // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(3, timeScale) : play(3);
+      play(8, timeScale);
+      midDeviceState = 1;
+      backDoorIsOpen.value = false;
+      frontDoorIsOpen.value = true;
     }
-
-    if (selectData.midGateClose == '1' && selectData.midGateOpen == '0' && isMidOpenRunning) {
-      isMidOpenRunning = false;
-      if (midDeviceState != 0) {
-        // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(4, timeScale) : play(4);
-        play(9, timeScale);
-        midDeviceState = 0;
-        backDoorIsOpen.value = false;
-      }
-    }
-
-    modelRef.value?.animate?.(selectData.frontGateOpen == '1', selectData.midGateOpen == '1', selectData.rearGateOpen == '1');
   }
 
-  function playWindowAnimation(data, maxarea = 90, isFirst = false) {
-    computePlay(data, maxarea, isFirst);
+  if (selectData.midGateClose == '1' && selectData.midGateOpen == '0' && isMidOpenRunning) {
+    isMidOpenRunning = false;
+    if (midDeviceState != 0) {
+      // import.meta.env.VITE_GLOB_IS_SIMULATE ? play(4, timeScale) : play(4);
+      play(9, timeScale);
+      midDeviceState = 0;
+      backDoorIsOpen.value = false;
+    }
   }
 
-  function handleCancel() {
-    modalIsShow.value = false;
-    modalTitle.value = '';
-    modalType.value = '';
+  modelRef.value?.animate?.(selectData.frontGateOpen == '1', selectData.midGateOpen == '1', selectData.rearGateOpen == '1');
+}
+
+function playWindowAnimation(data, maxarea = 90, isFirst = false) {
+  computePlay(data, maxarea, isFirst);
+}
+
+function handleCancel() {
+  modalIsShow.value = false;
+  modalTitle.value = '';
+  modalType.value = '';
+}
+
+const handleSwitchChange = (checked: boolean) => {
+  contrlValue = checked;
+  setControl('manualAutoMode_w', '手动/自动模式切换');
+};
+
+function setSVGModelType(type) {
+  modelComponent.value = getModelComponent(globalConfig.is2DModel, type);
+  return nextTick();
+}
+
+onMounted(async () => {
+  const { query } = unref(currentRoute);
+  if (query['deviceType']) deviceType.value = query['deviceType'] as string;
+  modelList.value = await getDictItems('gateModel');
+  if (globalConfig.is2DModel) {
+    await getMonitor(true);
+  } else {
+    loading.value = true;
+    // const playerDom = document.getElementById('fm-player1')?.getElementsByClassName('vjs-tech')[0];
+    mountedThree()
+      .then(async () => {
+        if (sysOrgCode != 'zmhjhzmy') {
+          await getMonitor(true);
+          loading.value = false;
+        } else {
+          // 韩咀无风门设备,只有报警历史数据,无其他数据
+          setModelType('fm1').then(async () => {
+            loading.value = false;
+            dataSource.value = [];
+            addMonitorText(selectData);
+          });
+        }
+      })
+      .catch((e) => {
+        console.log(e);
+      });
   }
+});
 
-  const handleSwitchChange = (checked: boolean) => {
-    contrlValue = checked;
-    setControl('manualAutoMode_w', '手动/自动模式切换');
-  };
+onBeforeUnmount(() => {
+  removeCamera(playerRef);
+  getDeviceBaseList();
+});
 
-  function setSVGModelType(type) {
-    modelComponent.value = getModelComponent(globalConfig.is2DModel, type);
-    return nextTick();
+onUnmounted(() => {
+  if (timer) {
+    clearTimeout(timer);
+    timer = undefined;
   }
-
-  onMounted(async () => {
-    const { query } = unref(currentRoute);
-    if (query['deviceType']) deviceType.value = query['deviceType'] as string;
-    modelList.value = await getDictItems('gateModel');
-    if (globalConfig.is2DModel) {
-      await getMonitor(true);
-    } else {
-      loading.value = true;
-      // const playerDom = document.getElementById('fm-player1')?.getElementsByClassName('vjs-tech')[0];
-      mountedThree()
-        .then(async () => {
-          if (sysOrgCode != 'zmhjhzmy') {
-            await getMonitor(true);
-            loading.value = false;
-          } else {
-            // 韩咀无风门设备,只有报警历史数据,无其他数据
-            setModelType('fm1').then(async () => {
-              loading.value = false;
-              dataSource.value = [];
-              addMonitorText(selectData);
-            });
-          }
-        })
-        .catch((e) => {
-          console.log(e);
-        });
-    }
-  });
-
-  onBeforeUnmount(() => {
-    removeCamera(playerRef);
-    getDeviceBaseList();
-  });
-
-  onUnmounted(() => {
-    if (timer) {
-      clearTimeout(timer);
-      timer = undefined;
-    }
-    destroy();
-  });
+  destroy();
+});
 </script>
 
 <style lang="less" scoped>
-  @import '/@/design/theme.less';
-  @import '/@/design/vent/modal.less';
+@import '/@/design/theme.less';
+@import '/@/design/vent/modal.less';
 
-  .scene-box {
-    .bottom-tabs-box {
-      height: 350px;
-    }
+.scene-box {
+  .bottom-tabs-box {
+    height: 350px;
   }
+}
 
-  .button-box {
-    border: none !important;
-    height: 34px !important;
-
-    &:hover {
-      background: var(--vent-device-manager-control-btn-hover) !important;
-    }
-
-    &::before {
-      height: 27px !important;
-      background: var(--vent-device-manager-control-btn) !important;
-    }
+.button-box {
+  border: none !important;
+  height: 34px !important;
 
-    &::after {
-      top: 35px !important;
-    }
+  &:hover {
+    background: var(--vent-device-manager-control-btn-hover) !important;
   }
 
-  :deep(.@{ventSpace}-tabs-tabpane-active) {
-    height: 100%;
+  &::before {
+    height: 27px !important;
+    background: var(--vent-device-manager-control-btn) !important;
   }
 
-  ::-webkit-scrollbar-thumb {
-    -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
-    background: #4288a444;
+  &::after {
+    top: 35px !important;
   }
+}
 
-  :deep(.zxm-radio-disabled + span) {
-    color: var(--vent-font-color) !important;
-  }
+:deep(.@{ventSpace}-tabs-tabpane-active) {
+  height: 100%;
+}
 
-  :deep(.zxm-radio-disabled .zxm-radio-inner::after) {
-    background-color: #127cb5 !important;
-  }
+::-webkit-scrollbar-thumb {
+  -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
+  background: #4288a444;
+}
 
-  :deep(.@{ventSpace}-picker-datetime-panel) {
-    height: 200px !important;
-    overflow-y: auto !important;
-  }
+:deep(.zxm-radio-disabled + span) {
+  color: var(--vent-font-color) !important;
+}
+
+:deep(.zxm-radio-disabled .zxm-radio-inner::after) {
+  background-color: #127cb5 !important;
+}
+
+:deep(.@{ventSpace}-picker-datetime-panel) {
+  height: 200px !important;
+  overflow-y: auto !important;
+}
 </style>

+ 11 - 0
src/views/vent/monitorManager/mainFanMonitor/index.vue

@@ -330,6 +330,7 @@
                 >
                 <a class="table-action-link" @click="deviceEdit($event, 'deviceInfo', record)">设备编辑</a>
                 <a v-if="hasPermission('fan:exportDataBtn')" class="table-action-link" @click="exportData($event, record)">数据导出</a>
+                <a class="table-action-link" v-data-picker="record">数据解读</a>
               </template>
             </GroupMonitorTable>
           </a-tab-pane>
@@ -753,6 +754,7 @@
   import { useModal } from '/@/components/Modal';
   import type { BasicColumn } from '/@/components/Table/src/types/table';
   import { useCamera } from '/@/hooks/system/useCamera';
+  import { useDataPicker } from '/@/views/ventAI/dataPicker';
   import DeviceBaseInfo from '../comment/components/DeviceBaseInfo.vue';
   import { useGlobSetting } from '/@/hooks/setting';
   import { usePermission } from '/@/hooks/web/usePermission';
@@ -772,6 +774,15 @@
 
   const fanSwitchoverSimulation = VENT_PARAM['fanSwitchoverSimulation'];
 
+  const { directive: vDataPicker } = useDataPicker(
+    (record: any) => ({
+      device_id: record.deviceID,
+      device_name: record.strinstallpos,
+      device_type: 'fanmain',
+    }),
+    { mode: 'click' }
+  );
+
   const [registerModal, { openModal, closeModal }] = useModal();
   const [registerModalDeviceEdit, { openModal: openModalDeviceEdit }] = useModal();
   const [registerModalDeviceSet, { openModal: openModalDeviceSet }] = useModal();

+ 0 - 9
src/views/vent/monitorManager/windowMonitor/components/windowDualAdjustSVG.vue

@@ -1701,15 +1701,6 @@
 
   /** 根据SVG的使用场景播放动画 */
   function animate(data: any, maxarea) {
-    console.log(
-      'debug animate',
-      data,
-      maxarea,
-      data.frontArea / maxarea,
-      data.rearArea / maxarea,
-      data.frontArea3 / maxarea,
-      data.frontArea4 / maxarea
-    );
     // 前门(画面左边的门)
     triggerAnimation([`auto_anim_0${props.identify}`], false, { progress: data.frontArea / maxarea || 0 });
     // 前门(画面左边的门)

+ 2 - 2
src/views/ventAI/dataPicker/api.ts

@@ -12,7 +12,7 @@ enum Api {
  * @returns 流完成后的 thread_id、session_id
  */
 export async function fetchTunnelInterpretation(
-  params: { tun_id: string; tun_name: string },
+  params: { tun_id: string; tun_name: string; mode?: string },
   onEvent: (event: SSERawEvent) => void
 ): Promise<{ thread_id: string; session_id: string }> {
   return ssePost(Api.clickTun, params, onEvent);
@@ -25,7 +25,7 @@ export async function fetchTunnelInterpretation(
  * @returns 流完成后的 thread_id、session_id
  */
 export async function fetchDeviceInterpretation(
-  params: { device_id: string; device_name: string; device_type: string },
+  params: { device_id: string; device_name: string; device_type: string; mode?: string },
   onEvent: (event: SSERawEvent) => void
 ): Promise<{ thread_id: string; session_id: string }> {
   return ssePost(Api.clickDevice, params, onEvent);

+ 0 - 60
src/views/ventAI/dataPicker/components/DataInterpretationBtn.vue

@@ -1,60 +0,0 @@
-<template>
-  <div
-    class="di-floating-btn"
-    :style="{ left: x + 'px', top: y + 'px' }"
-    @click.stop="$emit('click')"
-    @mouseenter="$emit('btn-enter')"
-    @mouseleave="$emit('btn-leave')"
-  >
-    <span>数据解读</span>
-  </div>
-</template>
-
-<script lang="ts" setup>
-  defineProps<{
-    x: number;
-    y: number;
-  }>();
-
-  defineEmits<{
-    click: [];
-    'btn-enter': [];
-    'btn-leave': [];
-  }>();
-</script>
-
-<style lang="less" scoped>
-  .di-floating-btn {
-    position: absolute;
-    top: 0;
-    left: 0;
-    z-index: 10;
-    padding: 3px 12px;
-    background: linear-gradient(135deg, #1e90ff, #4da6ff);
-    border-radius: 3px;
-    cursor: pointer;
-    font-size: 12px;
-    color: #fff;
-    letter-spacing: 1px;
-    white-space: nowrap;
-    pointer-events: auto;
-    box-shadow: 0 0 10px rgba(30, 144, 255, 0.4);
-    animation: diFadeIn 0.15s ease-out;
-
-    &:hover {
-      background: linear-gradient(135deg, #4da6ff, #79c0ff);
-      box-shadow: 0 0 16px rgba(30, 144, 255, 0.6);
-    }
-  }
-
-  @keyframes diFadeIn {
-    from {
-      opacity: 0;
-      transform: scale(0.9);
-    }
-    to {
-      opacity: 1;
-      transform: scale(1);
-    }
-  }
-</style>

+ 150 - 122
src/views/ventAI/dataPicker/components/DataInterpretationModal.vue → src/views/ventAI/dataPicker/components/DataPickerModal.vue

@@ -9,70 +9,48 @@
     wrap-class-name="data-interpretation-modal"
     @cancel="$emit('close')"
   >
-    <!-- 标题栏 -->
     <div class="di-header">
       <span class="di-header__title">数据解读</span>
       <span class="di-header__close" @click="$emit('close')">✕</span>
     </div>
-
-    <!-- 内容区域 -->
-    <div class="di-body">
-      <!-- 过程状态展示区 -->
+    <div ref="bodyRef" class="di-body">
       <div v-if="showProcess" class="di-process">
-        <!-- 思考中 -->
-        <div v-if="thinking" class="di-process__row di-process__thinking"><a-spin size="small" /><span>AI 正在思考...</span></div>
-
-        <!-- Todo 列表 -->
-        <div v-if="todos.length > 0" class="di-process__todos">
-          <div v-for="(todo, i) in todos" :key="i" class="di-todo-item" :class="`di-todo-item--${todo.status}`">
-            <span class="di-todo-item__icon">
-              <span v-if="todo.status === 'completed'" class="di-todo-check">✓</span>
-              <a-spin v-else-if="todo.status === 'in_progress'" size="small" />
-              <span v-else class="di-todo-dot"></span>
-            </span>
-            <span class="di-todo-item__text">{{ todo.content }}</span>
-          </div>
+        <div v-if="processCollapsed" class="di-process__summary" @click="processCollapsed = false">
+          <span class="di-process__summary-icon">▸</span>
+          <span>查看处理过程</span>
         </div>
-
-        <!-- 执行工具中 -->
-        <!-- <div v-if="executingTools.length > 0" class="di-process__row di-process__executing">
-          <a-spin size="small" />
-          <span>正在执行:{{ executingTools.join(', ') }}</span>
-        </div> -->
-
-        <!-- 工具调用记录 -->
-        <!-- <div v-if="toolCalls.length > 0" class="di-process__tools">
-          <div v-for="(tc, i) in toolCalls" :key="i" class="di-tool-item">
-            <span class="di-tool-item__dot" :class="{ 'di-tool-item__dot--done': tc.status === 'result' }"></span>
-            <span class="di-tool-item__name">{{ tc.tool }}</span>
-            <span v-if="tc.status === 'call'" class="di-tool-item__tag">执行中</span>
-            <span v-else class="di-tool-item__tag di-tool-item__tag--done">完成</span>
+        <template v-else>
+          <div v-if="!props.loading" class="di-process__summary" @click="processCollapsed = true">
+            <span class="di-process__summary-icon">▾</span>
+            <span>收起处理过程</span>
           </div>
-        </div> -->
-      </div>
-
-      <!-- 无过程且加载中 -->
-      <div v-if="loading && !hasContent && !hasProcess" class="di-loading">
-        <a-spin tip="正在分析数据..." />
+          <div v-if="thinking" class="di-process__row di-process__thinking"><a-spin size="small" /><span>AI 正在思考...</span></div>
+          <div v-if="todos.length > 0" class="di-process__todos">
+            <div v-for="(todo, i) in todos" :key="i" class="di-todo-item" :class="`di-todo-item--${todo.status}`">
+              <span class="di-todo-item__icon">
+                <span v-if="todo.status === 'completed'" class="di-todo-check">✓</span>
+                <span v-else-if="todo.status === 'in_progress'" class="di-todo-spinner"></span>
+                <span v-else class="di-todo-dot"></span>
+              </span>
+              <span class="di-todo-item__text">{{ todo.content }}</span>
+            </div>
+          </div>
+          <div v-if="props.latestMessage && loading" class="di-process__row di-process__message">
+            <span class="di-process__dot"></span><span>{{ props.latestMessage }}</span>
+          </div>
+        </template>
       </div>
-
-      <!-- Markdown 正文 -->
+      <div v-if="loading && !hasContent" class="di-loading"><a-spin tip="正在分析数据..." /></div>
       <div v-if="hasContent" class="di-markdown" v-html="renderedText"></div>
-
-      <!-- 错误 -->
       <div v-else-if="error && !loading" class="di-error">数据解读失败:{{ error }}</div>
     </div>
-
-    <!-- 流式加载指示条 -->
-    <div v-if="loading && hasContent" class="di-streaming-bar">
-      <a-spin size="small" /><span class="di-streaming-bar__tip">AI 正在生成...</span>
-    </div>
+    <div v-if="loading && hasContent" class="di-streaming-bar"><a-spin size="small" /><span class="di-streaming-bar__tip">AI 正在生成...</span></div>
   </a-modal>
 </template>
 
 <script lang="ts" setup>
-  import { computed } from 'vue';
-  import markdownIt from 'markdown-it';
+  import { computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
+  import { marked } from 'marked';
   import type { TodoItem, ToolCallRecord } from '../types';
 
   const props = defineProps<{
@@ -84,31 +62,96 @@
     todos: TodoItem[];
     executingTools: string[];
     toolCalls: ToolCallRecord[];
+    latestMessage: string;
   }>();
 
   defineEmits<{ close: [] }>();
 
-  const md = markdownIt();
+  const bodyRef = ref<HTMLDivElement>();
+  const isNearBottom = ref(true);
+
+  function checkNearBottom() {
+    const el = bodyRef.value;
+    if (!el) return;
+    isNearBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
+  }
+
+  function scrollToBottom() {
+    const el = bodyRef.value;
+    if (!el) return;
+    el.scrollTop = el.scrollHeight;
+  }
+
+  let scrollListenerAttached = false;
+
+  onMounted(() => {
+    const el = bodyRef.value;
+    if (el) {
+      el.addEventListener('scroll', checkNearBottom, { passive: true });
+      scrollListenerAttached = true;
+    }
+  });
+
+  onBeforeUnmount(() => {
+    if (scrollListenerAttached && bodyRef.value) {
+      bodyRef.value.removeEventListener('scroll', checkNearBottom);
+    }
+  });
 
   const hasContent = computed(() => !!props.streamingText);
-  const hasProcess = computed(() => props.thinking || props.todos.length > 0 || props.executingTools.length > 0 || props.toolCalls.length > 0);
+  const hasProcess = computed(
+    () => props.thinking || props.todos.length > 0 || props.executingTools.length > 0 || props.toolCalls.length > 0 || !!props.latestMessage
+  );
   const showProcess = computed(() => hasProcess.value);
+  const processCollapsed = ref(false);
+
+  watch(
+    () => props.loading,
+    (val, oldVal) => {
+      if (oldVal && !val && hasProcess.value) processCollapsed.value = true;
+    }
+  );
+
+  watch(
+    () => [props.streamingText, props.latestMessage, props.todos],
+    () => {
+      if (isNearBottom.value) {
+        nextTick(scrollToBottom);
+      }
+    },
+    { deep: true }
+  );
 
   const renderedText = computed(() => {
     if (!props.streamingText) return '';
     const processed = props.streamingText.replace(/<br\s*\/?>/gi, '  \n');
-    return md.render(processed);
+    return marked.parse(processed);
   });
 </script>
 
 <style lang="less">
   .data-interpretation-modal {
+    position: fixed !important;
+    top: 0 !important;
+    left: 0 !important;
+    width: 100vw !important;
+    height: 100vh !important;
+    display: flex !important;
+    align-items: flex-start !important;
+    justify-content: center !important;
+    padding-top: 10vh;
+    z-index: 1000 !important;
+    .ant-modal {
+      top: 0;
+      max-width: 90vw;
+    }
     .ant-modal-content {
-      background: linear-gradient(180deg, #0a1a3a 0%, #0d2450 100%);
-      border: 1px solid rgba(30, 144, 255, 0.3);
-      border-radius: 8px;
-      box-shadow: 0 0 30px rgba(0, 120, 255, 0.15);
-      color: #c8d6e5;
+      color: var(--vent-font-color) !important;
+      background-color: var(--vent-modal-bg) !important;
+      border: 1px solid var(--vent-modal-border) !important;
+      -webkit-box-shadow: 0px 0px 10px var(--vent-modal-box-shadow) inset !important;
+      box-shadow: 0px 0px 10px var(--vent-modal-box-shadow) inset !important;
+      backdrop-filter: blur(30px);
     }
     .ant-modal-body {
       padding: 0;
@@ -127,14 +170,12 @@
     justify-content: space-between;
     align-items: center;
     padding: 18px 24px;
-    background: linear-gradient(90deg, rgba(30, 144, 255, 0.15) 0%, transparent 100%);
     border-bottom: 1px solid rgba(30, 144, 255, 0.15);
     flex-shrink: 0;
-
     &__title {
       font-size: 18px;
       font-weight: bold;
-      color: #4da6ff;
+      color: #fff;
       letter-spacing: 2px;
     }
     &__close {
@@ -146,27 +187,21 @@
       }
     }
   }
-
   .di-body {
     padding: 18px 24px;
     min-height: 120px;
     max-height: 700px;
     overflow-y: auto;
   }
-
   .di-loading {
     display: flex;
     justify-content: center;
     align-items: center;
     padding: 40px 0;
   }
-
-  // ── 过程状态区 ──
   .di-process {
     margin-bottom: 16px;
-    padding-bottom: 12px;
-    border-bottom: 1px solid rgba(30, 144, 255, 0.12);
-
+    // border-bottom: 1px solid rgba(30, 144, 255, 0.12);
     &__row {
       display: flex;
       align-items: center;
@@ -176,22 +211,53 @@
       color: #8899aa;
     }
   }
-
   .di-process__thinking {
     color: #4da6ff;
     font-size: 13px;
   }
-
   .di-process__executing {
     color: #c8a853;
     font-size: 13px;
   }
-
-  // ── Todo 列表 ──
+  .di-process__message {
+    color: #8899aa;
+    font-size: 13px;
+  }
+  .di-process__dot {
+    width: 6px;
+    height: 6px;
+    border-radius: 50%;
+    background: #4da6ff;
+    animation: di-pulse 1.4s ease-in-out infinite;
+  }
+  @keyframes di-pulse {
+    0%,
+    100% {
+      opacity: 0.3;
+    }
+    50% {
+      opacity: 1;
+    }
+  }
+  .di-process__summary {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    padding: 6px 0;
+    font-size: 13px;
+    color: #8899aa;
+    cursor: pointer;
+    user-select: none;
+    &:hover {
+      color: #4da6ff;
+    }
+  }
+  .di-process__summary-icon {
+    font-size: 12px;
+  }
   .di-process__todos {
     margin: 6px 0;
   }
-
   .di-todo-item {
     display: flex;
     align-items: center;
@@ -199,7 +265,6 @@
     padding: 4px 0;
     font-size: 13px;
     line-height: 1.6;
-
     &__icon {
       flex-shrink: 0;
       width: 16px;
@@ -210,7 +275,6 @@
     &__text {
       color: #c8d6e5;
     }
-
     &--completed &__text {
       color: #6b7a8a;
       text-decoration: line-through;
@@ -219,13 +283,11 @@
       color: #4da6ff;
     }
   }
-
   .di-todo-check {
     color: #27ae60;
     font-size: 12px;
     font-weight: bold;
   }
-
   .di-todo-dot {
     width: 6px;
     height: 6px;
@@ -233,54 +295,23 @@
     background: #4a5568;
     display: inline-block;
   }
-
-  // ── 工具调用记录 ──
-  .di-process__tools {
-    margin-top: 4px;
+  .di-todo-spinner {
+    width: 12px;
+    height: 12px;
+    border: 2px solid rgba(77, 166, 255, 0.25);
+    border-top-color: #4da6ff;
+    border-radius: 50%;
+    animation: di-spin 0.8s linear infinite;
   }
-
-  .di-tool-item {
-    display: flex;
-    align-items: center;
-    gap: 6px;
-    padding: 2px 0;
-    font-size: 12px;
-    color: #6b7a8a;
-
-    &__dot {
-      width: 6px;
-      height: 6px;
-      border-radius: 50%;
-      background: #c8a853;
-      flex-shrink: 0;
-
-      &--done {
-        background: #27ae60;
-      }
-    }
-    &__name {
-      color: #8899aa;
-    }
-    &__tag {
-      font-size: 10px;
-      padding: 0 4px;
-      border-radius: 2px;
-      background: rgba(200, 168, 83, 0.2);
-      color: #c8a853;
-
-      &--done {
-        background: rgba(39, 174, 96, 0.15);
-        color: #27ae60;
-      }
+  @keyframes di-spin {
+    to {
+      transform: rotate(360deg);
     }
   }
-
-  // ── Markdown 正文 ──
   .di-markdown {
     line-height: 1.8;
-    color: #c8d6e5;
+    color: #fff;
     word-break: break-word;
-
     :deep(h1),
     :deep(h2),
     :deep(h3),
@@ -329,24 +360,21 @@
       }
       th {
         background: rgba(30, 144, 255, 0.1);
-        color: #4da6ff;
+        color: #000;
       }
     }
   }
-
   .di-streaming-bar {
     display: flex;
     align-items: center;
     gap: 8px;
     padding: 8px 24px 16px;
     border-top: 1px solid rgba(30, 144, 255, 0.1);
-
     &__tip {
       font-size: 12px;
       color: #8899aa;
     }
   }
-
   .di-error {
     padding: 40px;
     text-align: center;

+ 3 - 6
src/views/ventAI/dataPicker/index.ts

@@ -1,7 +1,4 @@
-export { useDataInterpretation, useDataInterpretationList } from './useDataInterpretation';
-export { createTrigger } from './useDataInterpretationTrigger';
+export { useDataPicker } from './useDataPicker';
+export type { DataPickerOptions } from './useDataPicker';
 export { fetchTunnelInterpretation, fetchDeviceInterpretation } from './api';
-export type { TriggerState, TriggerHandle, TriggerOptions } from './useDataInterpretationTrigger';
-export type { TunnelInterpretRequest, DeviceInterpretRequest, InterpretationResult, SSERawEvent, SSEEventType, TodoItem, ToolCallRecord } from './types';
-export { default as DataInterpretationModal } from './components/DataInterpretationModal.vue';
-export { default as DataInterpretationBtn } from './components/DataInterpretationBtn.vue';
+export type { SSERawEvent, SSEEventType, TodoItem, ToolCallRecord } from './types';

+ 0 - 20
src/views/ventAI/dataPicker/types.ts

@@ -1,16 +1,3 @@
-/** 巷道解读请求参数 */
-export interface TunnelInterpretRequest {
-  tun_id: string;
-  tun_name: string;
-}
-
-/** 设备解读请求参数 */
-export interface DeviceInterpretRequest {
-  device_id: string;
-  device_name: string;
-  device_type: string;
-}
-
 /** SSE 事件类型 */
 export type SSEEventType = 'thinking' | 'executing' | 'tool_call' | 'tool_result' | 'updated_todo_list' | 'token' | 'done' | 'error';
 
@@ -40,10 +27,3 @@ export interface ToolCallRecord {
   source: string;
   status: 'call' | 'result';
 }
-
-/** 解读完成后的结果 */
-export interface InterpretationResult {
-  content: string;
-  threadId: string;
-  sessionId: string;
-}

+ 0 - 185
src/views/ventAI/dataPicker/useDataInterpretation.ts

@@ -1,185 +0,0 @@
-import { ref } from 'vue';
-import { fetchTunnelInterpretation, fetchDeviceInterpretation } from './api';
-import { createTrigger, type TriggerHandle } from './useDataInterpretationTrigger';
-import type { TodoItem, ToolCallRecord } from './types';
-
-/** 数据解读 hook —— 调用后端 SSE 流式接口,实时追踪所有过程状态 */
-export function useDataInterpretation() {
-  const loading = ref(false);
-  const streamingText = ref('');
-  const error = ref<string | null>(null);
-  const threadId = ref('');
-  const sessionId = ref('');
-
-  // 过程状态
-  const thinking = ref(false);
-  const todos = ref<TodoItem[]>([]);
-  const executingTools = ref<string[]>([]);
-  const toolCalls = ref<ToolCallRecord[]>([]);
-
-  /** 统一的事件处理 */
-  function handleEvent(event: any) {
-    switch (event.type) {
-      case 'thinking':
-        thinking.value = true;
-        break;
-      case 'executing':
-        thinking.value = false;
-        executingTools.value = event.tools || [];
-        break;
-      case 'tool_call':
-        executingTools.value = [];
-        toolCalls.value = [...toolCalls.value, { tool: event.tool || '', source: event.source || 'main', status: 'call' as const }];
-        break;
-      case 'tool_result':
-        // 更新最近一条 tool_call 为 result 状态
-        if (toolCalls.value.length > 0) {
-          const last = toolCalls.value[toolCalls.value.length - 1];
-          if (last.tool === event.tool && last.status === 'call') {
-            last.status = 'result';
-          }
-        }
-        break;
-      case 'updated_todo_list':
-        thinking.value = false;
-        todos.value = event.todos || [];
-        break;
-      case 'token':
-        thinking.value = false;
-        if (event.content) {
-          streamingText.value += event.content;
-        }
-        break;
-    }
-  }
-
-  async function interpretTunnel(params: { tun_id: string; tun_name: string }) {
-    loading.value = true;
-    error.value = null;
-    streamingText.value = '';
-    thinking.value = false;
-    todos.value = [];
-    executingTools.value = [];
-    toolCalls.value = [];
-
-    try {
-      const result = await fetchTunnelInterpretation(params, handleEvent);
-      threadId.value = result.thread_id;
-      sessionId.value = result.session_id;
-    } catch (e: any) {
-      const msg = e?.message || '数据解读请求失败';
-      error.value = msg;
-      throw e;
-    } finally {
-      loading.value = false;
-      thinking.value = false;
-      executingTools.value = [];
-    }
-  }
-
-  async function interpretDevice(params: { device_id: string; device_name: string; device_type: string }) {
-    loading.value = true;
-    error.value = null;
-    streamingText.value = '';
-    thinking.value = false;
-    todos.value = [];
-    executingTools.value = [];
-    toolCalls.value = [];
-
-    try {
-      const result = await fetchDeviceInterpretation(params, handleEvent);
-      threadId.value = result.thread_id;
-      sessionId.value = result.session_id;
-    } catch (e: any) {
-      const msg = e?.message || '数据解读请求失败';
-      error.value = msg;
-      throw e;
-    } finally {
-      loading.value = false;
-      thinking.value = false;
-      executingTools.value = [];
-    }
-  }
-
-  function reset() {
-    streamingText.value = '';
-    error.value = null;
-    threadId.value = '';
-    sessionId.value = '';
-    thinking.value = false;
-    todos.value = [];
-    executingTools.value = [];
-    toolCalls.value = [];
-  }
-
-  return {
-    loading,
-    streamingText,
-    error,
-    threadId,
-    sessionId,
-    thinking,
-    todos,
-    executingTools,
-    toolCalls,
-    interpretTunnel,
-    interpretDevice,
-    reset,
-  };
-}
-
-/**
- * 数据解读列表级便捷 Hook。
- * 整合了弹框状态 + 每行数据的悬浮触发器管理 + SSE 流式解读。
- *
- * @param mapParams - 将列表行数据映射为接口请求参数
- *
- * 使用:在列表/表格/卡片组等 v-for 场景的组件中调用一次即可。
- */
-export function useDataInterpretationList(
-  mapParams: (item: any) => { tun_id: string; tun_name: string } | { device_id: string; device_name: string; device_type: string }
-) {
-  const { loading, streamingText, error, threadId, sessionId, thinking, todos, executingTools, toolCalls, interpretTunnel, interpretDevice, reset } =
-    useDataInterpretation();
-
-  const modalVisible = ref(false);
-  const triggerMap = new Map<string, TriggerHandle>();
-
-  function getTrigger(id: string): TriggerHandle {
-    if (!triggerMap.has(id)) {
-      triggerMap.set(id, createTrigger());
-    }
-    return triggerMap.get(id)!;
-  }
-
-  async function handleInterpret(item: any) {
-    modalVisible.value = true;
-    const params = mapParams(item);
-    if ('tun_id' in params) {
-      await interpretTunnel(params);
-    } else {
-      await interpretDevice(params);
-    }
-  }
-
-  function handleClose() {
-    modalVisible.value = false;
-    reset();
-  }
-
-  return {
-    modalVisible,
-    loading,
-    streamingText,
-    error,
-    threadId,
-    sessionId,
-    thinking,
-    todos,
-    executingTools,
-    toolCalls,
-    getTrigger,
-    handleInterpret,
-    handleClose,
-  };
-}

+ 0 - 124
src/views/ventAI/dataPicker/useDataInterpretationTrigger.ts

@@ -1,124 +0,0 @@
-import { reactive } from 'vue';
-
-export interface TriggerState {
-  visible: boolean;
-  x: number;
-  y: number;
-}
-
-export interface TriggerHandle {
-  state: TriggerState;
-  onEnter: (e: MouseEvent) => void;
-  onMove: (e: MouseEvent) => void;
-  onLeave: () => void;
-  onBtnEnter: () => void;
-  onBtnLeave: () => void;
-  destroy: () => void;
-}
-
-export interface TriggerOptions {
-  /** 鼠标停顿多久后显示按钮 (ms),默认 400 */
-  pauseDelay?: number;
-  /** 按钮显示后多久自动消失 (ms),默认 3000 */
-  hideDelay?: number;
-}
-
-/**
- * 创建一个悬浮按钮触发器实例。
- * 每个需要数据解读的 DOM 元素(卡片行、表格行、3D 标记等)对应一个 trigger。
- *
- * 使用方式:
- * 1. 在 v-for 中为每行数据 createTrigger()
- * 2. 将 onEnter/onMove/onLeave 绑定到容器元素的 mouseenter/mousemove/mouseleave
- * 3. 将 DataInterpretationBtn 放在容器内,绑定 state 和 onBtnEnter/onBtnLeave
- */
-export function createTrigger(options?: TriggerOptions): TriggerHandle {
-  const PAUSE_DELAY = options?.pauseDelay ?? 400;
-  const HIDE_DELAY = options?.hideDelay ?? 3000;
-
-  const state = reactive<TriggerState>({
-    visible: false,
-    x: 0,
-    y: 0,
-  });
-
-  let pauseTimer: ReturnType<typeof setTimeout> | null = null;
-  let hideTimer: ReturnType<typeof setTimeout> | null = null;
-
-  function trackMouse(e: MouseEvent) {
-    const target = e.currentTarget as HTMLElement;
-    const rect = target.getBoundingClientRect();
-    state.x = e.clientX - rect.left - 36;
-    state.y = e.clientY - rect.top - 34;
-  }
-
-  function startPauseTimer() {
-    pauseTimer = setTimeout(() => {
-      state.visible = true;
-      startHideTimer();
-    }, PAUSE_DELAY);
-  }
-
-  function resetPauseTimer() {
-    if (pauseTimer) {
-      clearTimeout(pauseTimer);
-      pauseTimer = null;
-    }
-  }
-
-  function startHideTimer() {
-    clearHideTimer();
-    hideTimer = setTimeout(() => {
-      state.visible = false;
-    }, HIDE_DELAY);
-  }
-
-  function clearHideTimer() {
-    if (hideTimer) {
-      clearTimeout(hideTimer);
-      hideTimer = null;
-    }
-  }
-
-  function onEnter(e: MouseEvent) {
-    clearHideTimer();
-    trackMouse(e);
-    startPauseTimer();
-  }
-
-  function onMove(e: MouseEvent) {
-    if (state.visible) return;
-    trackMouse(e);
-    resetPauseTimer();
-    startPauseTimer();
-  }
-
-  function onLeave() {
-    resetPauseTimer();
-    clearHideTimer();
-    state.visible = false;
-  }
-
-  function onBtnEnter() {
-    clearHideTimer();
-  }
-
-  function onBtnLeave() {
-    startHideTimer();
-  }
-
-  function destroy() {
-    resetPauseTimer();
-    clearHideTimer();
-  }
-
-  return {
-    state,
-    onEnter,
-    onMove,
-    onLeave,
-    onBtnEnter,
-    onBtnLeave,
-    destroy,
-  };
-}

+ 409 - 0
src/views/ventAI/dataPicker/useDataPicker.ts

@@ -0,0 +1,409 @@
+import { ref, h, createApp, Teleport, type Directive, type App } from 'vue';
+import Antd from 'ant-design-vue';
+import { fetchTunnelInterpretation, fetchDeviceInterpretation } from './api';
+import type { TodoItem, ToolCallRecord, SSERawEvent } from './types';
+import DataPickerModal from './components/DataPickerModal.vue';
+
+// ─── 按钮 DOM 构建(悬停模式专用)──────────────────────────────────────────
+const BUTTON_CLASS = 'di-floating-btn';
+
+function createButtonEl(): HTMLElement {
+  const btn = document.createElement('div');
+  btn.className = BUTTON_CLASS;
+  btn.innerHTML = '<span>数据解读</span>';
+  Object.assign(btn.style, {
+    position: 'fixed',
+    zIndex: '99999',
+    padding: '8px 12px',
+    background: 'linear-gradient(135deg, #1e90ff, #4da6ff)',
+    borderRadius: '3px',
+    cursor: 'pointer',
+    fontSize: '16px',
+    color: '#fff',
+    letterSpacing: '1px',
+    whiteSpace: 'nowrap',
+    boxShadow: '0 0 10px rgba(30,144,255,0.4)',
+    display: 'none',
+    pointerEvents: 'auto',
+    transition: 'transform 0.15s ease-out, opacity 0.15s ease-out',
+    transform: 'scale(0.9)',
+    opacity: '0',
+  });
+  btn.addEventListener('mouseenter', () => {
+    const owner = (btn as any).__di_owner;
+    if (owner) {
+      owner.cancelLeave();
+      owner.clearHideTimer();
+    }
+  });
+  btn.addEventListener('mouseleave', () => {
+    const owner = (btn as any).__di_owner;
+    if (owner) owner.startHideTimer();
+  });
+  return btn;
+}
+
+// ─── 悬停触发器实例 ────────────────────────────────────────────────────────
+const PAUSE_DELAY = 400;
+const HIDE_DELAY = 3000;
+
+function createHoverInstance(btn: HTMLElement) {
+  let pauseTimer: ReturnType<typeof setTimeout> | null = null;
+  let hideTimer: ReturnType<typeof setTimeout> | null = null;
+  let leaveTimer: ReturnType<typeof setTimeout> | null = null;
+  let visible = false;
+  let raw: any = null;
+  let hostEl: HTMLElement | null = null;
+
+  function setPosition(x: number, y: number) {
+    btn.style.left = x + 'px';
+    btn.style.top = y + 'px';
+  }
+
+  function detachMove() {
+    if (hostEl && inst.onMove) hostEl.removeEventListener('mousemove', inst.onMove);
+  }
+
+  function attachMove() {
+    if (hostEl && inst.onMove) hostEl.addEventListener('mousemove', inst.onMove);
+  }
+
+  function show() {
+    clearPauseTimer();
+    detachMove();
+    visible = true;
+    btn.style.display = 'block';
+    requestAnimationFrame(() => {
+      btn.style.transform = 'scale(1)';
+      btn.style.opacity = '1';
+    });
+    startHideTimer();
+  }
+
+  function hide() {
+    visible = false;
+    attachMove();
+    btn.style.display = 'none';
+    btn.style.transform = 'scale(0.9)';
+    btn.style.opacity = '0';
+  }
+
+  function clearPauseTimer() {
+    if (pauseTimer !== null) {
+      clearTimeout(pauseTimer);
+      pauseTimer = null;
+    }
+  }
+
+  function clearHideTimer() {
+    if (hideTimer !== null) {
+      clearTimeout(hideTimer);
+      hideTimer = null;
+    }
+  }
+
+  function startHideTimer() {
+    clearHideTimer();
+    hideTimer = setTimeout(hide, HIDE_DELAY);
+  }
+
+  const inst = {
+    get visible() {
+      return visible;
+    },
+    get raw() {
+      return raw;
+    },
+    setHost(el: HTMLElement) {
+      hostEl = el;
+    },
+    onEnter(e: MouseEvent) {
+      clearHideTimer();
+      if (!visible) {
+        setPosition(e.clientX - 36, e.clientY - 34);
+        clearPauseTimer();
+        pauseTimer = setTimeout(show, PAUSE_DELAY);
+      } else {
+        startHideTimer();
+      }
+    },
+    onMove(e: MouseEvent) {
+      setPosition(e.clientX - 36, e.clientY - 34);
+      clearPauseTimer();
+      pauseTimer = setTimeout(show, PAUSE_DELAY);
+    },
+    onLeave() {
+      clearPauseTimer();
+      clearHideTimer();
+      leaveTimer = setTimeout(hide, 150);
+    },
+    cancelLeave() {
+      if (leaveTimer !== null) {
+        clearTimeout(leaveTimer);
+        leaveTimer = null;
+      }
+    },
+    setRaw(val: any) {
+      raw = val;
+    },
+    clearHideTimer,
+    startHideTimer,
+    destroy() {
+      clearPauseTimer();
+      clearHideTimer();
+      if (leaveTimer !== null) {
+        clearTimeout(leaveTimer);
+        leaveTimer = null;
+      }
+      btn.remove();
+    },
+  };
+
+  return inst;
+}
+
+type HoverInstance = ReturnType<typeof createHoverInstance>;
+
+// ─── 钩子主函数 ─────────────────────────────────────────────────────────────
+export interface DataPickerOptions {
+  /** hover: 悬停出现浮动按钮;click: 点击元素直接触发 */
+  mode?: 'hover' | 'click';
+}
+
+export function useDataPicker(
+  mapParams: (
+    item: any
+  ) => { tun_id: string; tun_name: string; mode?: string } | { device_id: string; device_name: string; device_type: string; mode?: string },
+  options: DataPickerOptions = {}
+) {
+  console.log('useDataPicker', mapParams);
+  const { mode = 'hover' } = options;
+
+  const loading = ref(false);
+  const streamingText = ref('');
+  const error = ref<string | null>(null);
+  const thinking = ref(false);
+  const todos = ref<TodoItem[]>([]);
+  const executingTools = ref<string[]>([]);
+  const toolCalls = ref<ToolCallRecord[]>([]);
+  const latestMessage = ref('');
+  const modalVisible = ref(false);
+
+  // 弹框容器 + 独立 Vue 应用
+  const modalContainer = document.createElement('div');
+  Object.assign(modalContainer.style, {
+    position: 'fixed',
+    top: '0',
+    left: '0',
+    width: '100%',
+    height: '100%',
+    pointerEvents: 'none',
+    zIndex: '1000',
+  });
+  document.body.appendChild(modalContainer);
+
+  let modalApp: App | null = null;
+
+  function mountModal() {
+    modalApp = createApp({
+      setup: () => ({
+        visible: modalVisible,
+        loading,
+        streamingText,
+        error,
+        thinking,
+        todos,
+        executingTools,
+        toolCalls,
+        latestMessage,
+        onClose: () => {
+          modalVisible.value = false;
+          streamingText.value = '';
+          error.value = null;
+          thinking.value = false;
+          todos.value = [];
+          executingTools.value = [];
+          toolCalls.value = [];
+          latestMessage.value = '';
+        },
+      }),
+      render() {
+        return h(Teleport, { to: 'body' }, [
+          h(DataPickerModal, {
+            visible: this.visible,
+            loading: this.loading,
+            streamingText: this.streamingText,
+            error: this.error,
+            thinking: this.thinking,
+            todos: this.todos,
+            executingTools: this.executingTools,
+            toolCalls: this.toolCalls,
+            latestMessage: this.latestMessage,
+            onClose: this.onClose,
+          }),
+        ]);
+      },
+    });
+    modalApp.use(Antd);
+    modalApp.mount(modalContainer);
+  }
+
+  mountModal();
+
+  // SSE 事件处理
+  function handleEvent(event: SSERawEvent) {
+    switch (event.type) {
+      case 'thinking':
+        thinking.value = true;
+        break;
+      case 'executing':
+        thinking.value = false;
+        executingTools.value = event.tools || [];
+        if (event.message) latestMessage.value = event.message;
+        break;
+      case 'tool_call':
+        executingTools.value = [];
+        toolCalls.value = [...toolCalls.value, { tool: event.tool || '', source: event.source || 'main', status: 'call' as const }];
+        if (event.message) latestMessage.value = event.message;
+        break;
+      case 'tool_result':
+        if (toolCalls.value.length > 0) {
+          const last = toolCalls.value[toolCalls.value.length - 1];
+          if (last.tool === event.tool && last.status === 'call') last.status = 'result';
+        }
+        if (event.message) latestMessage.value = event.message;
+        break;
+      case 'updated_todo_list':
+        thinking.value = false;
+        todos.value = event.todos || [];
+        break;
+      case 'token':
+        thinking.value = false;
+        if (event.content) streamingText.value += event.content;
+        break;
+    }
+  }
+
+  // 点击触发
+  function handleClick(item: any) {
+    modalVisible.value = true;
+    loading.value = true;
+    error.value = null;
+    streamingText.value = '';
+    thinking.value = false;
+    todos.value = [];
+    executingTools.value = [];
+    toolCalls.value = [];
+    latestMessage.value = '';
+
+    const params = mapParams(item);
+    const request = 'tun_id' in params ? fetchTunnelInterpretation(params, handleEvent) : fetchDeviceInterpretation(params, handleEvent);
+
+    request
+      .catch((e: any) => {
+        error.value = e?.message || '请求失败';
+      })
+      .finally(() => {
+        loading.value = false;
+        thinking.value = false;
+        executingTools.value = [];
+      });
+  }
+
+  // ── 悬停模式:按钮容器 + 触发器 ──
+  let btnContainer: HTMLDivElement | null = null;
+  const hoverMap = new Map<Element, HoverInstance>();
+
+  if (mode === 'hover') {
+    btnContainer = document.createElement('div');
+    Object.assign(btnContainer.style, { position: 'fixed', top: '0', left: '0', zIndex: '99999', pointerEvents: 'none' });
+    document.body.appendChild(btnContainer);
+  }
+
+  function createHoverTrigger(): HoverInstance {
+    const btn = createButtonEl();
+    btnContainer!.appendChild(btn);
+    const inst = createHoverInstance(btn);
+    (btn as any).__di_owner = inst;
+    btn.addEventListener('click', (e) => {
+      e.stopPropagation();
+      const item = (inst as any).raw;
+      if (item) handleClick(item);
+    });
+    return inst;
+  }
+
+  // ── 点击模式:元素映射 ──
+  const clickRawMap = new Map<Element, any>();
+  const clickHandlerMap = new Map<Element, (e: Event) => void>();
+
+  // ── Vue 指令 ──
+  const directive: Directive = {
+    mounted(el, binding) {
+      if (mode === 'hover') {
+        const inst = createHoverTrigger();
+        inst.setHost(el);
+        inst.setRaw(binding.value);
+        hoverMap.set(el, inst);
+        el.addEventListener('mouseenter', inst.onEnter);
+        el.addEventListener('mousemove', inst.onMove);
+        el.addEventListener('mouseleave', inst.onLeave);
+      } else {
+        clickRawMap.set(el, binding.value);
+        const handler = (e: Event) => {
+          e.stopPropagation();
+          const item = clickRawMap.get(el);
+          if (item) handleClick(item);
+        };
+        clickHandlerMap.set(el, handler);
+        el.addEventListener('click', handler);
+      }
+    },
+    updated(el, binding) {
+      if (mode === 'hover') {
+        const inst = hoverMap.get(el);
+        if (inst) inst.setRaw(binding.value);
+      } else {
+        clickRawMap.set(el, binding.value);
+      }
+    },
+    unmounted(el) {
+      if (mode === 'hover') {
+        const inst = hoverMap.get(el);
+        if (inst) {
+          inst.destroy();
+          hoverMap.delete(el);
+        }
+      } else {
+        const handler = clickHandlerMap.get(el);
+        if (handler) {
+          el.removeEventListener('click', handler);
+          clickHandlerMap.delete(el);
+        }
+        clickRawMap.delete(el);
+      }
+    },
+  };
+
+  function cleanup() {
+    if (mode === 'hover') {
+      hoverMap.forEach((inst) => inst.destroy());
+      hoverMap.clear();
+      if (btnContainer) {
+        btnContainer.remove();
+        btnContainer = null;
+      }
+    } else {
+      clickRawMap.clear();
+      clickHandlerMap.forEach((handler, el) => el.removeEventListener('click', handler));
+      clickHandlerMap.clear();
+    }
+    if (modalApp) {
+      modalApp.unmount();
+      modalApp = null;
+    }
+    modalContainer.remove();
+  }
+
+  return { directive, cleanup, loading, streamingText, error, thinking, todos, executingTools, toolCalls, latestMessage, modalVisible };
+}

+ 105 - 0
src/views/ventAI/manageAssistent/api.ts

@@ -0,0 +1,105 @@
+import { defHttp } from '/@/utils/http/axios';
+
+enum Api {
+  unified = '/ventAI/api/chat',
+  getHistoryList = '/ventAI/api/sessions',
+  getDetail = '/ventAI/api/chat/history/',
+  deleteSession = '/ventAI/api/sessions/',
+}
+
+/**
+ * 历史会话接口
+ */
+export const getHistoryList = (params) => defHttp.get({ url: Api.getHistoryList, params }, { joinParamsToUrl: true, isTransformResponse: false });
+
+/**
+ * 获取历史会话详情
+ * @param session_id - 会话ID
+ */
+export const getDetail = (session_id: string) => defHttp.get({ url: Api.getDetail + session_id }, { isTransformResponse: false });
+
+/**
+ * 删除会话
+ * @param session_id - 会话ID
+ */
+export const deleteSession = (session_id: string) => defHttp.delete({ url: Api.deleteSession + session_id }, { isTransformResponse: false });
+
+/**
+ * 统一对话接口(SSE流式响应)
+ * @param params.message - 用户输入的文本,必填
+ * @param params.session_id - 会话唯一标识ID,不传参时服务端自动生成全新会话ID
+ * @param params.file - 上传的PDF文件,可选
+ * @param onChunk - 流式数据回调函数
+ * @returns Promise<{ session_id: string }>
+ */
+export const unifiedStream = async (
+  params: { message: string; session_id?: string; file?: File },
+  onChunk: (chunk: string) => void
+): Promise<{ session_id: string }> => {
+  try {
+    const formData = new FormData();
+    formData.append('message', params.message);
+    if (params.session_id) {
+      formData.append('session_id', params.session_id);
+    }
+    if (params.file) {
+      formData.append('file', params.file);
+    }
+
+    const response = await fetch(Api.unified, {
+      method: 'POST',
+      body: formData,
+    });
+
+    if (!response.ok) {
+      throw new Error(`HTTP错误: ${response.status}`);
+    }
+
+    const reader = response.body!.getReader();
+    const decoder = new TextDecoder();
+    let buffer = '';
+    let sessionId = '';
+    let isDone = false;
+
+    // eslint-disable-next-line no-constant-condition
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done || isDone) break;
+
+      buffer += decoder.decode(value, { stream: true });
+      const lines = buffer.split('\n');
+      buffer = lines.pop() || '';
+
+      for (const line of lines) {
+        if (line.trim() === '') continue;
+
+        if (line.startsWith('data: ')) {
+          const dataStr = line.replace(/^data: /, '');
+
+          try {
+            const data = JSON.parse(dataStr);
+
+            onChunk(line);
+
+            if (data.type === 'done' || data.type === 'error') {
+              if (data.session_id) {
+                sessionId = data.session_id;
+              }
+              isDone = true;
+              break;
+            }
+          } catch (e) {
+            console.warn('SSE数据解析失败:', dataStr);
+          }
+        }
+      }
+
+      if (isDone) break;
+    }
+
+    return { session_id: sessionId };
+  } catch (error) {
+    console.error('统一对话流式请求失败:', error);
+    throw error;
+  }
+};

+ 1119 - 0
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -0,0 +1,1119 @@
+<template>
+  <a-modal :visible="props.visible" @cancel="emit('close')" :width="1400" :footer="null" destroyOnClose class="ai-assistant-modal">
+    <template #title>
+      <div class="draggable-title" @mousedown="onDragStart">"通安智风"大模型</div>
+    </template>
+    <div class="ai-container">
+      <!-- 左侧任务列表 -->
+      <TaskListPanel
+        :taskList="taskList"
+        :currentTaskId="currentTaskId"
+        @switch-task="switchTask"
+        @create-task="createNewTask"
+        @delete-task="handleDeleteTask"
+      />
+
+      <!-- 中间聊天区域 -->
+      <div class="chat-panel">
+        <!-- 顶部任务选择器 -->
+        <div class="chat-header">
+          <a-select
+            v-model:value="currentTaskId"
+            style="width: 300px"
+            @change="handleTaskChange"
+            :getContainer="false"
+            popup-class-name="ai-task-select-dropdown"
+          >
+            <a-select-option v-for="task in taskList" :key="task.id" :value="task.id">
+              {{ task.name }}
+            </a-select-option>
+          </a-select>
+          <div v-if="currentWordUrl" class="upload-bg" @click="downloadWordReport">
+            <div class="download-icon"></div>
+          </div>
+        </div>
+
+        <!-- 附件列表面板 -->
+        <FileListPanel :visible="showFileList" :files="getCurrentTaskFiles()" @close="showFileList = false" @file-click="openFilePreview" />
+
+        <!-- 消息列表 -->
+        <ChatMessages ref="chatMessagesRef" :messages="messages" @file-preview="openFilePreview" @download-word="downloadWordFile" />
+
+        <!-- 输入区域 -->
+        <ChatInputArea
+          ref="chatInputRef"
+          v-model="inputMessage"
+          :pendingFile="pendingFile"
+          :loading="loading"
+          @send="handleSendMessage"
+          @file-upload="triggerFileUpload"
+          @file-change="handleFileUpload"
+          @remove-pending-file="removePendingFile"
+        />
+      </div>
+
+      <!-- 右侧文件预览面板 -->
+      <div v-if="previewFile" class="right-panel">
+        <FilePreviewPanel :file="previewFile" @close="removePreviewFile" />
+      </div>
+    </div>
+  </a-modal>
+</template>
+
+<script setup lang="ts">
+  import { ref, nextTick, watch, computed, onMounted, onBeforeUnmount } from 'vue';
+  import dayjs from 'dayjs';
+  import { unifiedStream, getHistoryList, getDetail, deleteSession } from '../api';
+  import { message, Modal } from 'ant-design-vue';
+  import { isPdfFile } from './chatModal/utils';
+  import TaskListPanel from './chatModal/TaskListPanel.vue';
+  import FileListPanel from './chatModal/FileListPanel.vue';
+  import ChatMessages from './chatModal/ChatMessages.vue';
+  import ChatInputArea from './chatModal/ChatInputArea.vue';
+  import FilePreviewPanel from './chatModal/FilePreviewPanel.vue';
+  import type { AttachedFile, Message, Task } from './chatModal/types';
+
+  interface Props {
+    visible: boolean;
+  }
+
+  interface Emits {
+    (e: 'close'): void;
+  }
+
+  const props = defineProps<Props>();
+  const emit = defineEmits<Emits>();
+
+  const taskList = ref<Task[]>([]);
+
+  // 从接口获取会话列表
+  const fetchSessionList = async () => {
+    try {
+      const res = await getHistoryList({});
+      const sessions = res?.sessions || res?.data || [];
+      if (Array.isArray(sessions)) {
+        const apiTasks: Task[] = sessions.map((item: { session_id: string; title: string }) => ({
+          id: item.session_id,
+          name: item.title || '未命名会话',
+          sessionId: item.session_id,
+          messages: [],
+          attachedFiles: [],
+        }));
+
+        const mergedTasks = apiTasks.map((apiTask) => {
+          const existing = taskList.value.find((t) => t.id === apiTask.id);
+          if (existing) {
+            return {
+              ...apiTask,
+              messages: existing.messages,
+              attachedFiles: existing.attachedFiles,
+              wordUrl: existing.wordUrl,
+            };
+          }
+          return apiTask;
+        });
+
+        const localOnlyTasks = taskList.value.filter((t) => !t.sessionId && !apiTasks.find((a) => a.id === t.id));
+
+        taskList.value = [...localOnlyTasks, ...mergedTasks];
+        return mergedTasks;
+      }
+    } catch (error) {
+      console.error('获取会话列表失败:', error);
+    }
+    return [];
+  };
+
+  const currentTaskId = ref('');
+  const chatInputRef = ref<InstanceType<typeof ChatInputArea>>();
+  const chatMessagesRef = ref<InstanceType<typeof ChatMessages>>();
+
+  const previewFile = ref<AttachedFile | null>(null);
+  const showFileList = ref(false);
+  const pendingFile = ref<AttachedFile | null>(null);
+
+  const messages = ref<Message[]>([
+    {
+      type: 'ai',
+      content: '您好!我是通安智风助手,请问有什么可以帮助您的吗?',
+      time: dayjs().format('HH:mm'),
+    },
+  ]);
+
+  const inputMessage = ref('');
+  const loading = ref(false);
+
+  const getCurrentSessionId = (): string => {
+    const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
+    return currentTask?.sessionId || '';
+  };
+
+  const historyLoadedTasks = ref<Set<string>>(new Set());
+
+  const setCurrentSessionId = (sessionId: string) => {
+    const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
+    if (currentTask && currentTask.id !== sessionId) {
+      taskList.value = taskList.value.filter((t) => t.id !== sessionId);
+      currentTask.id = sessionId;
+      currentTask.sessionId = sessionId;
+      currentTaskId.value = sessionId;
+      historyLoadedTasks.value.add(sessionId);
+    }
+  };
+
+  const switchTask = async (taskId: string) => {
+    const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
+    if (currentTask) {
+      currentTask.messages = [...messages.value];
+    }
+
+    currentTaskId.value = taskId;
+    const newTask = taskList.value.find((t) => t.id === taskId);
+    if (!newTask) return;
+
+    if (newTask.sessionId) {
+      taskList.value = taskList.value.filter((t) => t.sessionId);
+    }
+
+    inputMessage.value = '';
+    pendingFile.value = null;
+    previewFile.value = null;
+    showFileList.value = false;
+
+    if (newTask.sessionId && !historyLoadedTasks.value.has(taskId)) {
+      loading.value = true;
+      try {
+        const res = await getDetail(newTask.sessionId);
+        const messages = res?.messages || (Array.isArray(res) ? res : res?.data);
+        if (Array.isArray(messages)) {
+          newTask.messages = transformHistoryToMessages(messages);
+        }
+        historyLoadedTasks.value.add(taskId);
+      } catch (error) {
+        console.error('加载历史会话失败:', error);
+      } finally {
+        loading.value = false;
+      }
+    }
+
+    messages.value = newTask.messages && newTask.messages.length > 0 ? [...newTask.messages] : getDefaultMessages();
+
+    await nextTick();
+    scrollToBottom();
+  };
+
+  const handleTaskChange = (taskId: string) => {
+    switchTask(taskId);
+  };
+
+  const createNewTask = () => {
+    const existingBlank = taskList.value.find((t) => !t.sessionId);
+    if (existingBlank) {
+      switchTask(existingBlank.id);
+      return;
+    }
+
+    const newTask: Task = {
+      id: `task-${Date.now()}`,
+      name: '新任务',
+      sessionId: '',
+      messages: getDefaultMessages(),
+      attachedFiles: [],
+    };
+
+    taskList.value.unshift(newTask);
+    switchTask(newTask.id);
+  };
+
+  const handleDeleteTask = (task: Task, index: number) => {
+    Modal.confirm({
+      title: '提示',
+      content: '是否删除该对话?',
+      okText: '确认',
+      cancelText: '取消',
+      onOk: async () => {
+        try {
+          if (task.sessionId) {
+            await deleteSession(task.sessionId);
+          }
+          taskList.value.splice(index, 1);
+          if (currentTaskId.value === task.id) {
+            if (taskList.value.length > 0) {
+              switchTask(taskList.value[0].id);
+            } else {
+              messages.value = [];
+              currentTaskId.value = '';
+            }
+          }
+          message.success('删除成功');
+        } catch {
+          message.error('删除失败');
+        }
+      },
+    });
+  };
+
+  const getDefaultMessages = (): Message[] => {
+    return [
+      {
+        type: 'ai',
+        content: '您好!我是通安智风助手,请问有什么可以帮助您的吗?',
+        time: dayjs().format('HH:mm'),
+      },
+    ];
+  };
+
+  const normalizeLineBreaks = (str: string): string => {
+    if (!str) return '';
+    return str.replace(/\\n/g, '\n').replace(/\r\n/g, '\n');
+  };
+
+  const transformHistoryToMessages = (data: Array<{ role?: string; type?: string; content: string; created_at?: string }>): Message[] => {
+    const messages: Message[] = [];
+
+    for (const item of data) {
+      const normalizedContent = normalizeLineBreaks(item.content);
+      const time = item.created_at ? dayjs(item.created_at).format('HH:mm') : '';
+      const createdAt = item.created_at || '';
+
+      // 新格式:role 字段 (user/assistant)
+      if (item.role === 'user') {
+        messages.push({ type: 'user', content: normalizedContent, time, createdAt });
+      } else if (item.role === 'assistant') {
+        messages.push({ type: 'ai', content: normalizedContent, time, createdAt });
+      }
+      // 兼容旧格式:type 字段
+      else if (item.type === 'user_message') {
+        messages.push({ type: 'user', content: normalizedContent, time, createdAt });
+      } else if (item.type === 'model_thinking') {
+        const lastAiMsg = messages[messages.length - 1];
+        if (lastAiMsg && lastAiMsg.type === 'ai') {
+          lastAiMsg.content += normalizedContent;
+          lastAiMsg.content = lastAiMsg.content.replace(/\|\|\|SPLIT_CONTENT\|\|\|/g, '');
+        } else {
+          messages.push({ type: 'ai', content: normalizedContent, time, createdAt });
+        }
+      }
+    }
+
+    return messages;
+  };
+
+  const triggerFileUpload = () => {
+    chatInputRef.value?.fileInputRef?.click();
+  };
+
+  const handleFileUpload = async (event: Event) => {
+    const target = event.target as HTMLInputElement;
+    const file = target.files?.[0];
+    if (!file) return;
+
+    if (!isPdfFile(file.name)) {
+      message.error('仅支持 PDF 文件格式');
+      target.value = '';
+      return;
+    }
+
+    try {
+      const newFile: AttachedFile = {
+        id: `file-${Date.now()}`,
+        name: file.name,
+        size: file.size,
+        type: file.type,
+        uploadTime: dayjs().format('YYYY-MM-DD HH:mm'),
+        originalFile: file,
+      };
+
+      const reader = new FileReader();
+      reader.onload = (e) => {
+        newFile.preview = e.target?.result as string;
+      };
+      reader.readAsDataURL(file);
+
+      pendingFile.value = newFile;
+      message.success(`文件 "${file.name}" 已添加,点击发送按钮提交`);
+    } catch (error) {
+      console.error('文件处理失败:', error);
+      message.error('文件处理失败,请重试');
+    }
+
+    target.value = '';
+  };
+
+  const removePendingFile = () => {
+    pendingFile.value = null;
+  };
+
+  const removePreviewFile = () => {
+    previewFile.value = null;
+  };
+
+  const openFilePreview = (file: AttachedFile) => {
+    previewFile.value = file;
+
+    const msgIndex = messages.value.findIndex((m) => m.attachedFile?.id === file.id);
+    if (msgIndex !== -1) {
+      nextTick(() => {
+        const messageElements = chatMessagesRef.value?.messagesRef?.querySelectorAll('.message-wrapper');
+        if (messageElements && messageElements[msgIndex]) {
+          (messageElements[msgIndex] as HTMLElement).scrollIntoView({
+            behavior: 'smooth',
+            block: 'center',
+          });
+        }
+      });
+    }
+  };
+
+  const getCurrentTaskFiles = (): AttachedFile[] => {
+    const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
+    return currentTask?.attachedFiles || [];
+  };
+
+  const currentWordUrl = computed(() => {
+    const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
+    return currentTask?.wordUrl || '';
+  });
+
+  const downloadWordReport = () => {
+    const url = currentWordUrl.value;
+    if (!url) return;
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = '';
+    a.target = '_blank';
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+  };
+
+  const downloadWordFile = (url: string) => {
+    if (!url) return;
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = '';
+    a.target = '_blank';
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+  };
+
+  const handleSendMessage = async () => {
+    const hasText = inputMessage.value.trim();
+    const hasFile = pendingFile.value;
+
+    if (!hasText) {
+      message.warning('请输入消息内容');
+      return;
+    }
+    if (loading.value) return;
+
+    const userInput = inputMessage.value.trim();
+    const fileToSend = pendingFile.value;
+
+    inputMessage.value = '';
+    pendingFile.value = null;
+
+    try {
+      loading.value = true;
+
+      if (hasFile) {
+        await sendWithAttachment(fileToSend!, userInput);
+      } else {
+        await sendTextOnly(userInput);
+      }
+    } catch (error) {
+      console.error('发送消息失败:', error);
+      message.error('发送失败,请重试');
+
+      const lastMsg = messages.value[messages.value.length - 1];
+      if (lastMsg && lastMsg.type === 'ai' && lastMsg.isLoading) {
+        messages.value.pop();
+      }
+
+      const errorMsg: Message = {
+        type: 'ai',
+        content: '抱歉,请求失败,请稍后重试。',
+        time: dayjs().format('HH:mm'),
+      };
+      messages.value.push(errorMsg);
+
+      const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
+      if (currentTask) {
+        currentTask.messages = [...messages.value];
+      }
+    } finally {
+      loading.value = false;
+      await scrollToBottom();
+    }
+  };
+
+  const extractAgentName = (toolName: string): string => {
+    const nameMap: Record<string, string> = {
+      form_review: '形式审查',
+      verify_coal_face_ventilation: '采煤工作面需风量计算核验',
+      verify_tunneling_face_ventilation: '掘进工作面需风量计算核验',
+      verify_chamber_ventilation: '硐室需风量计算核验',
+      verify_other_points_ventilation: '其他地点需风量计算核验',
+      check_data_by_gas_report: '瓦斯数据一致性审查',
+      check_data_by_face_design: '设计规程一致性审查',
+      check_data_by_vent_report: '测风报表一致性审查',
+      check_current_month_plan: '日期有效性审查',
+      get_key_translate: '计算参数对照',
+      calculate_ventilation: '需风量计算',
+      get_ventilation_data: '需风量查询',
+      recalculate_ventilation: '需风量重计算',
+      get_plan_required_wind: '需风量提取',
+      get_model_calculated_wind: '三维模型解算风量提取',
+      get_sensor_wind: '巷道监测风量提取',
+      merge_wind_by_location: '地点风量智能合并',
+      check_wind_compliance: '风量检查',
+    };
+
+    return nameMap[toolName] || nameMap[toolName.toLowerCase()] || '智能分析助手';
+  };
+
+  // 每个 agent 只保留最新一条步骤,新数据替换旧数据
+  const upsertStep = (steps: any[], step: any) => {
+    const agentName = step.agent || '系统';
+    const idx = steps.findIndex((s) => (s.agent || '系统') === agentName && s.type !== 'updated_todo_list');
+    if (idx !== -1) {
+      steps.splice(idx, 1, step);
+    } else {
+      steps.push(step);
+    }
+  };
+
+  const handleSSEMessage = (data: any, aiMsgIndex: number) => {
+    const aiMsg = messages.value[aiMsgIndex];
+    const normalizedContent = normalizeLineBreaks(data.content || '');
+
+    // 初始化 thinkingSteps 数组
+    if (!aiMsg.thinkingSteps) {
+      aiMsg.thinkingSteps = [];
+    }
+
+    switch (data.type) {
+      // token 流式内容 - 直接拼接到消息内容
+      case 'token':
+        if (normalizedContent) {
+          aiMsg.content += normalizedContent;
+        }
+        break;
+
+      // 系统进度消息 - 仅当 agent 不是 system 时才作为 thinking step
+      case 'progress': {
+        if (data.agent && data.agent !== 'system') {
+          upsertStep(aiMsg.thinkingSteps!, {
+            type: 'executing',
+            agent: data.agent,
+            message: data.message || '',
+            timestamp: Date.now(),
+          });
+        }
+        break;
+      }
+
+      // agent 开始处理
+      case 'agent_start': {
+        upsertStep(aiMsg.thinkingSteps!, {
+          type: 'agent_start',
+          agent: data.cn_agent || data.agent || '智能助手',
+          message: data.message || '开始处理...',
+          timestamp: Date.now(),
+        });
+        break;
+      }
+
+      // agent 任务列表更新(同一 agent 的 todos 更新已有条目,不重复添加)
+      case 'agent_todos':
+      case 'updated_todo_list': {
+        const agentName = data.cn_agent || data.agent || '智能助手';
+        const existingIdx = aiMsg.thinkingSteps!.findIndex((s) => s.type === 'updated_todo_list' && s.agent === agentName);
+        if (existingIdx !== -1) {
+          aiMsg.thinkingSteps![existingIdx].todos = data.todos || [];
+          aiMsg.thinkingSteps![existingIdx].timestamp = Date.now();
+        } else {
+          aiMsg.thinkingSteps!.push({
+            type: 'updated_todo_list',
+            agent: agentName,
+            todos: data.todos || [],
+            message: data.message || '任务进度已更新',
+            timestamp: Date.now(),
+          });
+        }
+        break;
+      }
+
+      // agent 执行中(工具调用)
+      case 'agent_executing':
+      case 'executing': {
+        upsertStep(aiMsg.thinkingSteps!, {
+          type: 'executing',
+          agent: data.cn_agent || data.agent || '智能助手',
+          tool: (data.cn_tools || data.tools || []).join('、'),
+          message: data.message || '',
+          timestamp: Date.now(),
+        });
+        break;
+      }
+
+      // 工具结果
+      case 'agent_tool_result':
+      case 'tool_result': {
+        upsertStep(aiMsg.thinkingSteps!, {
+          type: 'tool_result',
+          agent: data.cn_agent || data.agent || '智能助手',
+          tool: data.cn_tool || data.tool || '',
+          message: data.message || '执行完成',
+          timestamp: Date.now(),
+        });
+        break;
+      }
+
+      // agent 完成
+      case 'agent_done': {
+        upsertStep(aiMsg.thinkingSteps!, {
+          type: 'agent_done',
+          agent: data.cn_agent || data.agent || '智能助手',
+          message: `${data.message || '审查完成'}${data.duration_ms ? `(${(data.duration_ms / 1000).toFixed(0)}s)` : ''}${data.progress ? ` [${data.progress}]` : ''}`,
+          timestamp: Date.now(),
+        });
+        if (data.preview) {
+          aiMsg.content += data.preview;
+        }
+        break;
+      }
+
+      // 旧格式兼容
+      case 'model_thinking':
+        if (normalizedContent) {
+          aiMsg.content += normalizedContent;
+          aiMsg.content = aiMsg.content.replace(/\|\|\|SPLIT_CONTENT\|\|\|/g, '');
+        }
+        break;
+
+      case 'tool_select': {
+        upsertStep(aiMsg.thinkingSteps!, {
+          type: 'tool_call',
+          agent: extractAgentName(normalizedContent),
+          tool: normalizedContent,
+          message: `选择工具:${normalizedContent}`,
+          timestamp: Date.now(),
+        });
+        break;
+      }
+
+      case 'error':
+        upsertStep(aiMsg.thinkingSteps!, {
+          type: 'tool_result',
+          agent: '系统',
+          message: `❌ 执行失败:${normalizedContent}`,
+          timestamp: Date.now(),
+        });
+        aiMsg.isLoading = false;
+        break;
+
+      case 'word_download':
+        if (normalizedContent) {
+          aiMsg.wordDownloadUrl = normalizedContent;
+          const wordTask = taskList.value.find((t) => t.id === currentTaskId.value);
+          if (wordTask) wordTask.wordUrl = normalizedContent;
+        }
+        break;
+
+      case 'done':
+        if (data.session_id) {
+          aiMsg.sessionId = data.session_id;
+        }
+        if (data.duration_ms) {
+          aiMsg.durationMs = data.duration_ms;
+        }
+        aiMsg.isLoading = false;
+        break;
+
+      case 'system':
+        if (normalizedContent) {
+          aiMsg.content += normalizedContent.replace(/\n/g, '  \n');
+        }
+        break;
+    }
+
+    const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
+    if (currentTask) {
+      currentTask.messages = [...messages.value];
+    }
+  };
+
+  const parseSSEData = (chunk: string, aiMsgIndex: number) => {
+    const lines = chunk.split('\n');
+
+    for (const lineRaw of lines) {
+      const line = lineRaw.trim();
+      if (!line) continue;
+
+      if (line.startsWith('data:')) {
+        try {
+          const jsonStr = line.substring(5).trim();
+          const data = JSON.parse(jsonStr);
+          handleSSEMessage(data, aiMsgIndex);
+        } catch (error) {
+          console.error('解析 SSE data 行失败:', error, line);
+        }
+        continue;
+      }
+
+      try {
+        const data = JSON.parse(line);
+        handleSSEMessage(data, aiMsgIndex);
+        continue;
+      } catch {
+        // not json
+      }
+
+      handleSSEMessage({ type: 'model_thinking', content: line }, aiMsgIndex);
+    }
+  };
+
+  const sendTextOnly = async (userInput: string) => {
+    const now = dayjs();
+    const userMsg: Message = {
+      type: 'user',
+      content: userInput,
+      time: now.format('HH:mm'),
+      createdAt: now.toISOString(),
+    };
+    messages.value.push(userMsg);
+    await scrollToBottom();
+
+    const aiMsgIndex = messages.value.length;
+    const aiMsg: Message = {
+      type: 'ai',
+      content: '',
+      time: now.format('HH:mm'),
+      createdAt: now.toISOString(),
+      isLoading: true,
+      thinkingSteps: [],
+      generateStartTime: Date.now(),
+    };
+    messages.value.push(aiMsg);
+    await scrollToBottom();
+
+    const result = await unifiedStream(
+      {
+        message: userInput,
+        session_id: getCurrentSessionId() || undefined,
+      },
+      (chunk: string) => {
+        parseSSEData(chunk, aiMsgIndex);
+        scrollToBottom();
+      }
+    );
+
+    if (result.session_id) {
+      const taskBeforeUpdate = taskList.value.find((t) => t.id === currentTaskId.value);
+      const isNewTask = taskBeforeUpdate && !taskBeforeUpdate.sessionId;
+
+      setCurrentSessionId(result.session_id);
+
+      if (isNewTask) {
+        taskBeforeUpdate.name = userInput.substring(0, 20) + (userInput.length > 20 ? '...' : '');
+      }
+    }
+
+    const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
+    if (currentTask) {
+      currentTask.messages = [...messages.value];
+    }
+  };
+
+  const sendWithAttachment = async (file: AttachedFile, userInput: string) => {
+    let currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
+    if (currentTask) {
+      currentTask.attachedFiles.push(file);
+    } else {
+      throw new Error('未找到当前任务');
+    }
+
+    const now = dayjs();
+    const userMsg: Message = {
+      type: 'user',
+      content: userInput || `上传了文件:${file.name}`,
+      time: now.format('HH:mm'),
+      createdAt: now.toISOString(),
+      attachedFile: file,
+    };
+    messages.value.push(userMsg);
+    await scrollToBottom();
+
+    const aiMsgIndex = messages.value.length;
+    const aiMsg: Message = {
+      type: 'ai',
+      content: '',
+      time: now.format('HH:mm'),
+      createdAt: now.toISOString(),
+      isLoading: true,
+      thinkingSteps: [],
+      generateStartTime: Date.now(),
+    };
+    messages.value.push(aiMsg);
+    await scrollToBottom();
+
+    if (!file.originalFile) {
+      throw new Error('缺少原始文件对象');
+    }
+
+    const result = await unifiedStream(
+      {
+        message: userInput || `上传了文件:${file.name}`,
+        session_id: getCurrentSessionId() || undefined,
+        file: file.originalFile,
+      },
+      (chunk: string) => {
+        parseSSEData(chunk, aiMsgIndex);
+        scrollToBottom();
+      }
+    );
+
+    if (result.session_id) {
+      setCurrentSessionId(result.session_id);
+    }
+
+    currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
+    if (currentTask) {
+      currentTask.messages = [...messages.value];
+    }
+  };
+
+  const scrollToBottom = async () => {
+    await nextTick();
+    if (!chatMessagesRef.value?.isAtBottom) return;
+    const el = chatMessagesRef.value?.messagesRef;
+    if (el) {
+      el.scrollTop = el.scrollHeight;
+    }
+  };
+
+  watch(
+    () => props.visible,
+    async (newVisible) => {
+      if (!newVisible) {
+        const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
+        if (currentTask) {
+          currentTask.messages = [...messages.value];
+        }
+      } else {
+        await fetchSessionList();
+
+        // 去除重复的空白任务,只保留一个
+        taskList.value = taskList.value.filter((task, index) => {
+          if (task.sessionId) return true;
+          return index === taskList.value.findIndex((t) => !t.sessionId);
+        });
+
+        // 默认选中第一条历史记录
+        const firstHistoryTask = taskList.value.find((t) => t.sessionId);
+
+        let targetTask: Task;
+        if (firstHistoryTask) {
+          targetTask = firstHistoryTask;
+        } else {
+          // 没有历史记录时才创建空白新任务
+          targetTask = {
+            id: `task-${Date.now()}`,
+            name: '新任务',
+            sessionId: '',
+            messages: getDefaultMessages(),
+            attachedFiles: [],
+          };
+          taskList.value.unshift(targetTask);
+        }
+
+        currentTaskId.value = targetTask.id;
+
+        // 如果是历史任务,加载其消息
+        if (targetTask.sessionId && !historyLoadedTasks.value.has(targetTask.id)) {
+          loading.value = true;
+          try {
+            const res = await getDetail(targetTask.sessionId);
+            const messages = res?.messages || (Array.isArray(res) ? res : res?.data);
+            if (Array.isArray(messages)) {
+              targetTask.messages = transformHistoryToMessages(messages);
+            }
+            historyLoadedTasks.value.add(targetTask.id);
+          } catch (error) {
+            console.error('加载历史会话失败:', error);
+          } finally {
+            loading.value = false;
+          }
+        }
+
+        messages.value = targetTask.messages && targetTask.messages.length > 0 ? [...targetTask.messages] : getDefaultMessages();
+
+        previewFile.value = null;
+        pendingFile.value = null;
+        showFileList.value = false;
+        inputMessage.value = '';
+
+        await nextTick(() => {
+          scrollToBottom();
+        });
+      }
+    }
+  );
+
+  // 拖拽相关(使用普通变量避免 mousemove 触发 Vue 响应式导致卡顿)
+  let isDragging = false;
+  let dragStartX = 0;
+  let dragStartY = 0;
+  let modalStartLeft = 0;
+  let modalStartTop = 0;
+  let draggedEl: HTMLElement | null = null;
+
+  const findModalWrap = (target: HTMLElement): HTMLElement | null =>
+    target.closest<HTMLElement>('.zxm-modal-wrap, .ant-modal-wrap, [class*="modal-wrap"]');
+
+  const queryModalWrap = (): HTMLElement | null => document.querySelector<HTMLElement>('.zxm-modal-wrap, .ant-modal-wrap, [class*="modal-wrap"]');
+
+  const centerModal = () => {
+    nextTick(() => {
+      const wrap = queryModalWrap();
+      if (wrap) {
+        wrap.style.position = '';
+        wrap.style.left = '';
+        wrap.style.top = '';
+        wrap.style.right = '';
+        wrap.style.bottom = '';
+        wrap.style.width = '';
+        wrap.style.height = '';
+        wrap.style.margin = '';
+        wrap.style.transform = '';
+      }
+      draggedEl = null;
+    });
+  };
+
+  const onDragStart = (e: MouseEvent) => {
+    draggedEl = findModalWrap(e.target as HTMLElement) || queryModalWrap();
+    if (!draggedEl) return;
+
+    isDragging = true;
+    dragStartX = e.clientX;
+    dragStartY = e.clientY;
+
+    const rect = draggedEl.getBoundingClientRect();
+    modalStartLeft = rect.left;
+    modalStartTop = rect.top;
+
+    draggedEl.style.position = 'fixed';
+    draggedEl.style.left = `${rect.left}px`;
+    draggedEl.style.top = `${rect.top}px`;
+    draggedEl.style.margin = '0';
+    draggedEl.style.transform = 'none';
+
+    document.addEventListener('mousemove', onDragMove);
+    document.addEventListener('mouseup', onDragEnd);
+  };
+
+  const onDragMove = (e: MouseEvent) => {
+    if (!isDragging || !draggedEl) return;
+    draggedEl.style.left = `${modalStartLeft + (e.clientX - dragStartX)}px`;
+    draggedEl.style.top = `${modalStartTop + (e.clientY - dragStartY)}px`;
+  };
+
+  const onDragEnd = () => {
+    isDragging = false;
+    document.removeEventListener('mousemove', onDragMove);
+    document.removeEventListener('mouseup', onDragEnd);
+  };
+
+  watch(
+    () => props.visible,
+    (newVisible) => {
+      if (newVisible) {
+        centerModal();
+      }
+    }
+  );
+
+  onMounted(async () => {
+    // fetchSessionList moved to watch(visible) to avoid unnecessary API calls
+  });
+
+  onBeforeUnmount(() => {
+    document.removeEventListener('mousemove', onDragMove);
+    document.removeEventListener('mouseup', onDragEnd);
+  });
+</script>
+
+<style scoped lang="less">
+  .ai-container {
+    display: flex;
+    height: 700px;
+    gap: 10px;
+    overflow: hidden;
+    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+  }
+
+  .chat-panel {
+    flex: 1;
+    display: flex;
+    position: relative;
+    flex-direction: column;
+    background-image: var(--img-chat-panel-bg);
+    background-repeat: no-repeat;
+    background-size: 100% 100%;
+    border-radius: 4px;
+    overflow: hidden;
+    min-width: 0;
+
+    :deep(.chat-header) {
+      display: flex;
+      padding: 12px 20px;
+      flex-shrink: 0;
+      align-items: center;
+
+      .header-icon {
+        width: 35px;
+        height: 28px;
+        background-image: var(--img-chat-header-icon);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+        margin-right: 10px;
+      }
+      .zxm-select-selector {
+        height: 40px;
+        background-image: var(--img-chat-selector-bg);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+        border: none;
+        border-color: unset !important;
+        box-shadow: unset !important;
+        color: #e0e6ed;
+        background-color: unset;
+        border-radius: 0;
+        padding-left: 40px;
+        padding-top: 10px;
+      }
+      .zxm-select-arrow {
+        color: #e0e6ed;
+      }
+
+      .zxm-select-selection-item {
+        color: #e0e6ed;
+      }
+
+      .upload-bg {
+        cursor: pointer;
+        width: 36px;
+        height: 36px;
+        background-image: var(--img-chat-upload-bg);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        margin-left: auto;
+        position: relative;
+      }
+      .download-icon {
+        width: 20px;
+        height: 20px;
+        background-image: var(--img-chat-download-icon);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+      }
+    }
+  }
+
+  .right-panel {
+    width: calc(50% - 140px);
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    background: linear-gradient(to bottom, rgba(10, 132, 255, 0.08), rgba(2, 22, 50, 0.95));
+    border: 2px solid rgba(63, 80, 106, 0.5);
+    border-radius: 4px;
+    flex-shrink: 0;
+  }
+
+  .draggable-title {
+    cursor: grab;
+    user-select: none;
+
+    &:active {
+      cursor: grabbing;
+    }
+  }
+</style>
+
+<style lang="less">
+  .ai-assistant-modal {
+    .zxm-modal-content {
+      background-image: var(--img-chat-modal-bg);
+      background-repeat: no-repeat;
+      background-size: 100% 100%;
+      background-color: unset !important;
+      box-shadow: none;
+      padding: 0;
+      border: none !important;
+    }
+    .zxm-modal-close {
+      background-image: var(--img-chat-close-bg);
+      background-repeat: no-repeat;
+      background-size: 100% 100%;
+      top: -6px;
+      right: -6px;
+      width: 38px;
+      height: 38px;
+      .zxm-modal-close-x {
+        width: 38px;
+        height: 38px;
+      }
+      svg {
+        width: 26px;
+        height: 26px;
+        margin-bottom: 4px;
+      }
+    }
+
+    .zxm-modal-header {
+      background-color: unset !important;
+      background: unset;
+      border: none !important;
+      padding: 8px;
+      backdrop-filter: unset;
+      display: flex;
+      justify-content: center;
+    }
+    .zxm-modal-title {
+      width: 100%;
+      height: 60px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      .draggable-title {
+        color: #fff;
+        font-family: var(--vet-font-family);
+        letter-spacing: 3px;
+        font-size: 24px;
+        font-weight: 100;
+        line-height: 1.4;
+      }
+    }
+    .zxm-modal-body {
+      padding: 12px 24px;
+    }
+  }
+  .ai-task-select-dropdown {
+    background-color: rgba(10, 132, 255, 0.1);
+    .zxm-select-item {
+      color: #e0e6ed;
+    }
+    .zxm-select-item-option-selected {
+      color: #e0e6ed !important;
+      background-color: rgba(10, 132, 255, 0.15) !important;
+    }
+  }
+</style>

+ 224 - 0
src/views/ventAI/manageAssistent/components/chatModal/ChatInputArea.vue

@@ -0,0 +1,224 @@
+<template>
+  <div class="chat-input-area">
+    <div v-if="pendingFile" class="pending-file-display">
+      <div class="pending-file-card">
+        <div class="file-icon-pending"></div>
+        <div class="file-info-pending">
+          <div class="file-name-pending">{{ pendingFile.name }}</div>
+          <div class="file-size-pending">{{ formatFileSize(pendingFile.size) }}</div>
+        </div>
+        <div class="remove-pending" @click="emit('remove-pending-file')">
+          <CloseOutlined />
+        </div>
+      </div>
+    </div>
+
+    <a-textarea
+      :value="modelValue"
+      @update:value="emit('update:modelValue', $event)"
+      :rows="3"
+      class="chat-input"
+      placeholder="请输入你的问题"
+      :auto-size="{ minRows: 3, maxRows: 6 }"
+      @keydown.enter.prevent="handleEnterKey"
+    />
+    <div class="input-actions">
+      <div class="action-left">
+        <a-tooltip title="从本地上传">
+          <div class="btn-upload" @click="emit('file-upload')">
+            <div class="plus-icon"></div>
+          </div>
+        </a-tooltip>
+        <input ref="fileInputRef" type="file" style="display: none" @change="emit('file-change', $event)" accept=".pdf" />
+      </div>
+      <div class="action-right">
+        <div class="btn-send" :class="{ loading, disabled: !modelValue.trim() }" @click="handleSend">
+          <div class="send-icon"></div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref } from 'vue';
+  import { CloseOutlined } from '@ant-design/icons-vue';
+  import { formatFileSize } from './utils';
+  import type { AttachedFile } from './types';
+
+  const props = defineProps<{
+    modelValue: string;
+    pendingFile: AttachedFile | null;
+    loading: boolean;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'update:modelValue', value: string): void;
+    (e: 'send'): void;
+    (e: 'file-upload'): void;
+    (e: 'file-change', event: Event): void;
+    (e: 'remove-pending-file'): void;
+  }>();
+
+  const fileInputRef = ref<HTMLInputElement>();
+
+  const handleEnterKey = (e: KeyboardEvent) => {
+    if (!e.shiftKey && props.modelValue.trim()) {
+      emit('send');
+    }
+  };
+
+  const handleSend = () => {
+    if (props.modelValue.trim() && !props.loading) {
+      emit('send');
+    }
+  };
+
+  defineExpose({ fileInputRef });
+</script>
+
+<style scoped lang="less">
+  .chat-input-area {
+    padding: 5px 10px;
+    flex-shrink: 0;
+    background-color: rgba(10, 132, 255, 0.05);
+    border: 1px solid rgba(63, 80, 106, 0.5);
+    border-radius: 4px;
+    margin: 5px 10px;
+
+    .pending-file-display {
+      margin-bottom: 5px;
+
+      .pending-file-card {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        padding: 10px 12px;
+        background: rgba(10, 132, 255, 0.06);
+        border: 1px solid rgba(63, 80, 106, 0.5);
+        border-radius: 6px;
+        transition: all 0.3s;
+
+        &:hover {
+          background: rgba(10, 132, 255, 0.1);
+        }
+
+        .file-icon-pending {
+          width: 32px;
+          height: 32px;
+          background-image: var(--img-chat-attach-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          flex-shrink: 0;
+        }
+
+        .file-info-pending {
+          flex: 1;
+          min-width: 0;
+
+          .file-name-pending {
+            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-pending {
+            font-size: 11px;
+            font-weight: 400;
+            line-height: 1.3;
+            color: #8b949e;
+          }
+        }
+
+        .remove-pending {
+          cursor: pointer;
+          color: #8b949e;
+          font-size: 14px;
+          padding: 4px;
+          border-radius: 4px;
+          transition: all 0.3s;
+
+          &:hover {
+            color: #ff4d4f;
+            background: rgba(220, 38, 38, 0.1);
+          }
+        }
+      }
+    }
+
+    :deep(.chat-input) {
+      color: #e0e6ed;
+      background-color: transparent !important;
+      border: none !important;
+
+      &::placeholder {
+        color: #8b949e !important;
+        opacity: 1 !important;
+      }
+      &:focus,
+      &:hover,
+      &:active {
+        border: none !important;
+        box-shadow: none !important;
+        outline: none !important;
+      }
+    }
+    .input-actions {
+      display: flex;
+      justify-content: end;
+      align-items: center;
+      margin-top: 12px;
+
+      .action-left,
+      .action-right {
+        display: flex;
+        gap: 8px;
+        .btn-upload {
+          width: 30px;
+          height: 30px;
+          border-radius: 6px;
+          margin-right: 10px;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+        }
+        .plus-icon {
+          width: 20px;
+          height: 20px;
+          background-image: var(--img-chat-input-bg);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          cursor: pointer;
+        }
+        .btn-send {
+          width: 32px;
+          height: 32px;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          cursor: pointer;
+          opacity: 1;
+          transition: opacity 0.2s;
+
+          &.disabled {
+            opacity: 0.4;
+            cursor: not-allowed;
+          }
+
+          .send-icon {
+            width: 24px;
+            height: 24px;
+            background-image: var(--img-chat-send-icon);
+            background-repeat: no-repeat;
+            background-size: 100% 100%;
+          }
+        }
+      }
+    }
+  }
+</style>

+ 1050 - 0
src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue

@@ -0,0 +1,1050 @@
+<template>
+  <div class="chat-messages-container">
+    <div class="chat-messages" ref="messagesRef">
+      <div v-for="(message, index) in messages" :key="index" :class="['message-wrapper', message.type]">
+        <!-- AI消息 -->
+        <div v-if="message.type === 'ai'" class="ai-message">
+          <div class="message-agent">
+            <div class="agent-icon"></div>
+          </div>
+          <div class="message-body">
+            <!-- 思考过程折叠卡片 -->
+            <div v-if="message.thinkingSteps && message.thinkingSteps.length > 0" class="thinking-card">
+              <div class="thinking-header" @click="toggleThinking(index)">
+                <div class="thinking-header-left">
+                  <div class="thinking-icon"></div>
+                  <span class="thinking-title">思考过程</span>
+                  <span class="thinking-count">{{ getAgentCount(message.thinkingSteps) }} 个子任务</span>
+                  <span v-if="getDurationText(message)" class="thinking-duration">{{ getDurationText(message) }}</span>
+                </div>
+                <div :class="['thinking-arrow', { expanded: expandedThinking[index] }]">
+                  <img :src="arrowIcon" width="12" height="12" />
+                </div>
+              </div>
+              <div v-show="expandedThinking[index]" class="thinking-body">
+                <div v-for="(group, groupIdx) in groupStepsByAgent(message.thinkingSteps)" :key="groupIdx" class="agent-group">
+                  <div class="agent-group-header" @click="toggleAgent(index + '-' + group.agent)">
+                    <div class="agent-group-header-left">
+                      <div class="agent-group-icon"></div>
+                      <span class="agent-group-name">{{ group.agent }}</span>
+                      <span class="agent-group-count">{{ group.steps.length }} 步</span>
+                    </div>
+                    <div :class="['thinking-arrow', { expanded: expandedAgents[index + '-' + group.agent] }]">
+                      <img :src="arrowIcon" width="10" height="10" />
+                    </div>
+                  </div>
+                  <div v-show="expandedAgents[index + '-' + group.agent]" class="agent-group-body">
+                    <div v-for="(step, stepIdx) in group.steps" :key="stepIdx" class="thinking-step">
+                      <!-- agent_start -->
+                      <div v-if="step.type === 'agent_start'" class="step-item step-agent-start">
+                        <div class="step-icon agent-icon-start"></div>
+                        <div class="step-content">
+                          <span class="step-desc">{{ step.message }}</span>
+                        </div>
+                      </div>
+                      <!-- tool_call -->
+                      <div v-else-if="step.type === 'tool_call'" class="step-item step-tool-call">
+                        <div class="step-icon tool-icon"></div>
+                        <div class="step-content">
+                          <span class="step-label">调用工具:</span>
+                          <span class="step-tool-name">{{ step.tool }}</span>
+                          <span v-if="step.message" class="step-desc">{{ step.message }}</span>
+                        </div>
+                      </div>
+                      <!-- executing -->
+                      <div v-else-if="step.type === 'executing'" class="step-item step-executing">
+                        <div class="step-icon executing-icon"></div>
+                        <div class="step-content">
+                          <span class="step-desc">{{ step.message || '正在执行...' }}</span>
+                          <span v-if="step.tool" class="step-tool-name">{{ step.tool }}</span>
+                        </div>
+                      </div>
+                      <!-- updated_todo_list -->
+                      <div v-else-if="step.type === 'updated_todo_list'" class="step-item step-todo">
+                        <div class="step-icon todo-icon"></div>
+                        <div class="step-content">
+                          <div class="todo-list">
+                            <div v-for="(todo, todoIdx) in step.todos" :key="todoIdx" class="todo-item">
+                              <span :class="['todo-status', todo.status]">
+                                {{ todo.status === 'completed' ? '✓' : todo.status === 'in_progress' ? '●' : '○' }}
+                              </span>
+                              <span class="todo-text">{{ todo.content }}</span>
+                            </div>
+                          </div>
+                        </div>
+                      </div>
+                      <!-- tool_result -->
+                      <div v-else-if="step.type === 'tool_result'" class="step-item step-tool-result">
+                        <div class="step-icon result-icon"></div>
+                        <div class="step-content">
+                          <span class="step-label">工具结果:</span>
+                          <span class="step-tool-name">{{ step.tool }}</span>
+                          <span class="step-desc">{{ step.message }}</span>
+                        </div>
+                      </div>
+                      <!-- agent_done -->
+                      <div v-else-if="step.type === 'agent_done'" class="step-item step-agent-done">
+                        <div class="step-icon done-icon"></div>
+                        <div class="step-content">
+                          <span class="step-desc">{{ step.message }}</span>
+                        </div>
+                      </div>
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            <div v-if="message.content" class="message-text" v-html="renderMarkdown(message.content)"></div>
+            <div v-if="message.wordDownloadUrl" class="word-download-area">
+              <a-button type="link" @click="emit('download-word', message.wordDownloadUrl)"> 下载报告文件 (.docx) </a-button>
+            </div>
+            <div v-if="message.isLoading" class="loading-dots">
+              <span class="dot"></span>
+              <span class="dot"></span>
+              <span class="dot"></span>
+            </div>
+          </div>
+          <!-- AI消息操作栏 -->
+          <div class="message-actions">
+            <div class="action-btn" @click="copyMessageContent(message.content)" title="复制">
+              <img :src="copyIcon" width="14" height="14" />
+            </div>
+            <span v-if="message.createdAt" class="action-time">{{ formatFullTime(message.createdAt) }}</span>
+          </div>
+        </div>
+
+        <!-- 用户消息 -->
+        <div v-else class="user-message">
+          <div class="message-body">
+            <div class="message-text" v-html="renderMarkdown(message.content)"></div>
+            <div v-if="message.attachedFile" class="attached-file-card" @click="emit('file-preview', message.attachedFile)">
+              <div class="file-icon-card"></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>
+            </div>
+          </div>
+          <!-- 用户消息操作栏 -->
+          <div class="message-actions">
+            <div class="action-btn" @click="copyMessageContent(message.content)" title="复制">
+              <img :src="copyIcon" width="14" height="14" />
+            </div>
+            <span v-if="message.createdAt" class="action-time">{{ formatFullTime(message.createdAt) }}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 回到底部按钮 -->
+    <div v-if="!isAtBottom" class="scroll-to-bottom" @click="scrollToBottom">
+      <div class="scroll-to-bottom-icon"></div>
+    </div>
+
+    <!-- 表格预览弹窗 -->
+    <div v-if="showPreviewModal" class="table-preview-overlay" @click.self="closePreview">
+      <div class="table-preview-modal">
+        <div class="table-preview-header">
+          <span class="table-preview-title">表格预览</span>
+          <div class="table-preview-close" @click="closePreview">
+            <img :src="closeIcon" width="16" height="16" />
+          </div>
+        </div>
+        <div class="table-preview-body" v-html="previewTableHtml"></div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, reactive, onMounted, onBeforeUnmount } from 'vue';
+  import dayjs from 'dayjs';
+  import { renderMarkdown, formatFileSize, tableHtmlToMarkdown, tableHtmlToCsv } from './utils';
+  import type { Message, AttachedFile, ThinkingStep } from './types';
+  import arrowIcon from '@/assets/images/ventAI/chatModalBlue/4-2.svg';
+  import copyIcon from '@/assets/images/ventAI/chatModalBlue/4-3.svg';
+  import closeIcon from '@/assets/images/ventAI/chatModalBlue/4-4.svg';
+
+  // 全局计时器 - 每秒刷新一次,用于实时显示思考耗时
+  const nowTs = ref(Date.now());
+  let timerInterval: ReturnType<typeof setInterval> | null = null;
+
+  const getDurationText = (message: Message): string => {
+    // 优先使用后端返回的 duration_ms
+    if (message.durationMs) {
+      const totalSec = Math.round(message.durationMs / 1000);
+      const min = Math.floor(totalSec / 60);
+      const sec = totalSec % 60;
+      return `已工作 ${min}分${sec.toString().padStart(2, '0')}秒`;
+    }
+    // 前端实时计时
+    if (message.generateStartTime && message.isLoading) {
+      const elapsed = Math.floor((nowTs.value - message.generateStartTime) / 1000);
+      const min = Math.floor(elapsed / 60);
+      const sec = elapsed % 60;
+      return `已工作 ${min}分${sec.toString().padStart(2, '0')}秒`;
+    }
+    return '';
+  };
+
+  defineProps<{
+    messages: Message[];
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'file-preview', file: AttachedFile): void;
+    (e: 'download-word', url: string): void;
+  }>();
+
+  const messagesRef = ref<HTMLElement>();
+  const expandedThinking = reactive<Record<number, boolean>>({});
+  const expandedAgents = reactive<Record<string, boolean>>({});
+  const isAtBottom = ref(true);
+
+  // 表格预览状态
+  const previewTableHtml = ref('');
+  const showPreviewModal = ref(false);
+
+  const handleTableAction = (e: Event) => {
+    const target = (e.target as HTMLElement).closest('.table-action-btn') as HTMLElement;
+    if (!target) return;
+
+    const action = target.dataset.action;
+    const encodedTable = target.dataset.table;
+    if (!action || !encodedTable) return;
+
+    const tableHtml = decodeURIComponent(encodedTable);
+
+    switch (action) {
+      case 'copy': {
+        const markdown = tableHtmlToMarkdown(tableHtml);
+        navigator.clipboard.writeText(markdown).catch(() => {
+          const ta = document.createElement('textarea');
+          ta.value = markdown;
+          ta.style.position = 'fixed';
+          ta.style.opacity = '0';
+          document.body.appendChild(ta);
+          ta.select();
+          document.execCommand('copy');
+          document.body.removeChild(ta);
+        });
+        break;
+      }
+      case 'csv': {
+        const csv = tableHtmlToCsv(tableHtml);
+        const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
+        const url = URL.createObjectURL(blob);
+        const a = document.createElement('a');
+        a.href = url;
+        a.download = `table_${Date.now()}.csv`;
+        a.click();
+        URL.revokeObjectURL(url);
+        break;
+      }
+      case 'preview': {
+        previewTableHtml.value = tableHtml;
+        showPreviewModal.value = true;
+        break;
+      }
+    }
+  };
+
+  const closePreview = () => {
+    showPreviewModal.value = false;
+    previewTableHtml.value = '';
+  };
+
+  const checkScrollBottom = () => {
+    const el = messagesRef.value;
+    if (!el) return;
+    const threshold = 50;
+    isAtBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
+  };
+
+  const scrollToBottom = () => {
+    const el = messagesRef.value;
+    if (el) {
+      el.scrollTop = el.scrollHeight;
+    }
+  };
+
+  onMounted(() => {
+    messagesRef.value?.addEventListener('click', handleTableAction);
+    messagesRef.value?.addEventListener('scroll', checkScrollBottom);
+    timerInterval = setInterval(() => {
+      nowTs.value = Date.now();
+    }, 1000);
+  });
+
+  onBeforeUnmount(() => {
+    messagesRef.value?.removeEventListener('click', handleTableAction);
+    messagesRef.value?.removeEventListener('scroll', checkScrollBottom);
+    if (timerInterval) {
+      clearInterval(timerInterval);
+      timerInterval = null;
+    }
+  });
+
+  const toggleThinking = (index: number) => {
+    expandedThinking[index] = !expandedThinking[index];
+  };
+
+  const toggleAgent = (key: string) => {
+    expandedAgents[key] = !expandedAgents[key];
+  };
+
+  interface AgentGroup {
+    agent: string;
+    steps: ThinkingStep[];
+  }
+
+  const groupStepsByAgent = (steps: ThinkingStep[]): AgentGroup[] => {
+    const groupMap = new Map<string, ThinkingStep[]>();
+    for (const step of steps) {
+      const agentName = step.agent || '系统';
+      if (!groupMap.has(agentName)) {
+        groupMap.set(agentName, []);
+      }
+      groupMap.get(agentName)!.push(step);
+    }
+    const result: AgentGroup[] = [];
+    for (const [agent, agentSteps] of groupMap) {
+      result.push({ agent, steps: agentSteps });
+    }
+    return result;
+  };
+
+  const getAgentCount = (steps: ThinkingStep[]): number => {
+    const agents = new Set(steps.map((s) => s.agent || '系统'));
+    return agents.size;
+  };
+
+  const formatFullTime = (createdAt: string) => {
+    return dayjs(createdAt).format('YYYY-MM-DD HH:mm:ss');
+  };
+
+  const copyMessageContent = async (content: string) => {
+    if (!content) return;
+    try {
+      await navigator.clipboard.writeText(content);
+    } catch {
+      const textarea = document.createElement('textarea');
+      textarea.value = content;
+      textarea.style.position = 'fixed';
+      textarea.style.opacity = '0';
+      document.body.appendChild(textarea);
+      textarea.select();
+      document.execCommand('copy');
+      document.body.removeChild(textarea);
+    }
+  };
+
+  defineExpose({ messagesRef, isAtBottom });
+</script>
+
+<style scoped lang="less">
+  .chat-messages-container {
+    flex: 1;
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+  }
+
+  .chat-messages {
+    flex: 1;
+    overflow-y: auto;
+    padding: 20px;
+
+    .message-wrapper {
+      margin-bottom: 20px;
+
+      .ai-message {
+        display: flex;
+        flex-wrap: wrap;
+        .message-agent {
+          display: flex;
+          margin-bottom: 8px;
+          opacity: 0;
+
+          .agent-icon {
+            width: 40px;
+            height: 40px;
+            background-image: var(--img-chat-ai-avatar);
+            background-repeat: no-repeat;
+            background-size: 100% 100%;
+          }
+        }
+
+        .message-body {
+          padding: 12px;
+          border-radius: 6px;
+          width: calc(100% - 50px);
+
+          /* 思考过程卡片 */
+          .thinking-card {
+            margin-bottom: 12px;
+            border: 1px solid rgba(63, 80, 106, 0.4);
+            border-radius: 8px;
+            background: rgba(10, 132, 255, 0.04);
+            overflow: hidden;
+
+            .thinking-header {
+              display: flex;
+              align-items: center;
+              justify-content: space-between;
+              padding: 10px 14px;
+              cursor: pointer;
+              user-select: none;
+              transition: background 0.2s;
+
+              &:hover {
+                background: rgba(10, 132, 255, 0.08);
+              }
+
+              .thinking-header-left {
+                display: flex;
+                align-items: center;
+                gap: 8px;
+              }
+
+              .thinking-icon {
+                width: 16px;
+                height: 16px;
+                background-image: var(--img-chat-thinking-icon, none);
+                background-repeat: no-repeat;
+                background-size: 100% 100%;
+                /* fallback: 用一个 brain/lightbulb emoji 风格的 SVG */
+                background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M8 1.5a4.5 4.5 0 0 1 3.5 7.35V11a1 1 0 0 1-1 1H5.5a1 1 0 0 1-1-1v-2.15A4.5 4.5 0 0 1 8 1.5Z' stroke='%230a84ff' stroke-width='1.2'/%3E%3Cpath d='M6 13.5h4M6.5 15h3' stroke='%230a84ff' stroke-width='1.2' stroke-linecap='round'/%3E%3C/svg%3E");
+              }
+
+              .thinking-title {
+                font-size: 13px;
+                font-weight: 500;
+                color: #0a84ff;
+              }
+
+              .thinking-count {
+                font-size: 12px;
+                color: #8b949e;
+                background: rgba(10, 132, 255, 0.1);
+                padding: 1px 6px;
+                border-radius: 10px;
+              }
+
+              .thinking-duration {
+                font-size: 12px;
+                color: #8b949e;
+                margin-left: 4px;
+              }
+            }
+
+            .thinking-arrow {
+              color: #8b949e;
+              display: flex;
+              align-items: center;
+              transition: transform 0.2s;
+
+              &.expanded {
+                transform: rotate(180deg);
+              }
+            }
+
+            .thinking-body {
+              border-top: 1px solid rgba(63, 80, 106, 0.3);
+              padding: 8px 14px;
+              max-height: 400px;
+              overflow-y: auto;
+
+              .agent-group {
+                margin-bottom: 8px;
+                border: 1px solid rgba(63, 80, 106, 0.25);
+                border-radius: 6px;
+                overflow: hidden;
+
+                &:last-child {
+                  margin-bottom: 0;
+                }
+
+                .agent-group-header {
+                  display: flex;
+                  align-items: center;
+                  justify-content: space-between;
+                  padding: 8px 12px;
+                  cursor: pointer;
+                  user-select: none;
+                  background: rgba(10, 132, 255, 0.06);
+                  transition: background 0.2s;
+
+                  &:hover {
+                    background: rgba(10, 132, 255, 0.1);
+                  }
+
+                  .agent-group-header-left {
+                    display: flex;
+                    align-items: center;
+                    gap: 8px;
+                  }
+
+                  .agent-group-icon {
+                    width: 14px;
+                    height: 14px;
+                    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 14' fill='none'%3E%3Ccircle cx='7' cy='5' r='2.5' stroke='%230a84ff' stroke-width='1'/%3E%3Cpath d='M2.5 12.5c0-2.49 2.01-4.5 4.5-4.5s4.5 2.01 4.5 4.5' stroke='%230a84ff' stroke-width='1' stroke-linecap='round'/%3E%3C/svg%3E");
+                    background-repeat: no-repeat;
+                    background-size: 100% 100%;
+                    flex-shrink: 0;
+                  }
+
+                  .agent-group-name {
+                    font-size: 13px;
+                    font-weight: 500;
+                    color: #0a84ff;
+                  }
+
+                  .agent-group-count {
+                    font-size: 11px;
+                    color: #8b949e;
+                    background: rgba(10, 132, 255, 0.08);
+                    padding: 1px 5px;
+                    border-radius: 8px;
+                  }
+                }
+
+                .agent-group-body {
+                  padding: 4px 12px;
+                }
+              }
+
+              .thinking-step {
+                .step-item {
+                  display: flex;
+                  align-items: flex-start;
+                  gap: 8px;
+                  padding: 6px 0;
+                  border-bottom: 1px solid rgba(63, 80, 106, 0.15);
+
+                  &:last-child {
+                    border-bottom: none;
+                  }
+
+                  .step-icon {
+                    width: 18px;
+                    height: 18px;
+                    flex-shrink: 0;
+                    margin-top: 1px;
+                    background-repeat: no-repeat;
+                    background-size: 100% 100%;
+                  }
+
+                  .step-content {
+                    flex: 1;
+                    font-size: 13px;
+                    line-height: 1.5;
+                    color: #b0b8c4;
+
+                    .step-agent-name {
+                      color: #0a84ff;
+                      font-weight: 500;
+                    }
+
+                    .step-tool-name {
+                      color: #79c0ff;
+                      font-family: monospace;
+                      font-size: 12px;
+                      background: rgba(10, 132, 255, 0.1);
+                      padding: 1px 5px;
+                      border-radius: 3px;
+                    }
+
+                    .step-label {
+                      color: #8b949e;
+                    }
+
+                    .step-desc {
+                      color: #b0b8c4;
+                    }
+                  }
+                }
+
+                /* agent_start */
+                .step-agent-start .step-icon {
+                  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18' fill='none'%3E%3Ccircle cx='9' cy='6' r='3' stroke='%230a84ff' stroke-width='1.2'/%3E%3Cpath d='M3 16c0-3.31 2.69-6 6-6s6 2.69 6 6' stroke='%230a84ff' stroke-width='1.2' stroke-linecap='round'/%3E%3C/svg%3E");
+                }
+
+                /* tool_call */
+                .step-tool-call .step-icon {
+                  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18' fill='none'%3E%3Cpath d='M10.5 2.5l5 5-8 8H2.5v-5.5l8-8Z' stroke='%23d29922' stroke-width='1.2' stroke-linejoin='round'/%3E%3Cpath d='M7 7l4 4' stroke='%23d29922' stroke-width='1.2'/%3E%3C/svg%3E");
+                }
+
+                /* executing */
+                .step-executing .step-icon {
+                  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18' fill='none'%3E%3Ccircle cx='9' cy='9' r='7' stroke='%230a84ff' stroke-width='1.2' stroke-dasharray='4 2'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 9 9' to='360 9 9' dur='2s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/svg%3E");
+                }
+
+                /* updated_todo_list */
+                .step-todo .step-icon {
+                  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18' fill='none'%3E%3Crect x='2' y='2' width='14' height='14' rx='2' stroke='%233fb950' stroke-width='1.2'/%3E%3Cpath d='M5 9l3 3 5-6' stroke='%233fb950' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+
+                  .todo-list {
+                    .todo-item {
+                      display: flex;
+                      align-items: flex-start;
+                      gap: 6px;
+                      padding: 2px 0;
+
+                      .todo-status {
+                        flex-shrink: 0;
+                        width: 16px;
+                        text-align: center;
+                        font-size: 12px;
+
+                        &.completed {
+                          color: #3fb950;
+                        }
+                        &.in_progress {
+                          color: #0a84ff;
+                        }
+                        &.pending {
+                          color: #8b949e;
+                        }
+                      }
+
+                      .todo-text {
+                        font-size: 13px;
+                        color: #b0b8c4;
+                      }
+                    }
+                  }
+                }
+
+                /* tool_result */
+                .step-tool-result .step-icon {
+                  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18' fill='none'%3E%3Ccircle cx='9' cy='9' r='7' stroke='%233fb950' stroke-width='1.2'/%3E%3Cpath d='M6 9l2 2 4-4' stroke='%233fb950' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+                }
+
+                /* agent_done */
+                .step-agent-done .step-icon {
+                  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18' fill='none'%3E%3Ccircle cx='9' cy='9' r='7' stroke='%233fb950' stroke-width='1.2'/%3E%3Cpath d='M5.5 9l2.5 2.5 4.5-5' stroke='%233fb950' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+                }
+              }
+            }
+          }
+
+          .message-text {
+            font-size: 14px;
+            font-weight: 400;
+            line-height: 1.6;
+            color: #e0e6ed;
+
+            :deep(h1),
+            :deep(h2),
+            :deep(h3),
+            :deep(h4),
+            :deep(h5),
+            :deep(h6) {
+              color: #fff;
+            }
+            :deep(p) {
+              margin: 0;
+            }
+            :deep(code) {
+              background: rgba(10, 132, 255, 0.1);
+              padding: 1px 4px;
+              border-radius: 3px;
+              font-size: 12px;
+            }
+            :deep(pre) {
+              background: rgba(10, 132, 255, 0.1);
+              padding: 8px;
+              border-radius: 4px;
+              overflow-x: auto;
+              margin: 6px 0;
+              white-space: pre-wrap;
+            }
+            :deep(pre code) {
+              background: transparent;
+              padding: 0;
+            }
+            :deep(ul),
+            :deep(ol) {
+              padding-left: 18px;
+              margin: 4px 0;
+            }
+            :deep(blockquote) {
+              border-left: 3px solid rgba(10, 132, 255, 0.3);
+              padding-left: 10px;
+              margin: 6px 0;
+              color: #8b949e;
+            }
+            :deep(table) {
+              border-collapse: collapse;
+              margin: 6px 0;
+            }
+            :deep(th),
+            :deep(td) {
+              border: 1px solid rgba(63, 80, 106, 0.5);
+              padding: 4px 8px;
+            }
+            :deep(th) {
+              background: rgba(10, 132, 255, 0.1);
+            }
+            :deep(strong) {
+              font-weight: 600;
+            }
+
+            :deep(.markdown-table-wrapper) {
+              position: relative;
+              margin: 6px 0;
+
+              .table-actions {
+                position: absolute;
+                top: 4px;
+                right: 4px;
+                display: flex;
+                gap: 2px;
+                opacity: 0;
+                transition: opacity 0.2s;
+                z-index: 1;
+              }
+
+              &:hover .table-actions {
+                opacity: 1;
+              }
+
+              .table-action-btn {
+                width: 26px;
+                height: 26px;
+                display: flex;
+                align-items: center;
+                justify-content: center;
+                border-radius: 4px;
+                color: #8b949e;
+                cursor: pointer;
+                background: rgba(22, 27, 34, 0.8);
+                backdrop-filter: blur(4px);
+                transition: all 0.2s;
+
+                &:hover {
+                  color: #e0e6ed;
+                  background: rgba(10, 132, 255, 0.3);
+                }
+              }
+            }
+          }
+        }
+        .loading-dots {
+          display: flex;
+          align-items: center;
+          gap: 6px;
+          padding: 4px 0;
+
+          .dot {
+            width: 8px;
+            height: 8px;
+            border-radius: 50%;
+            background: rgba(10, 132, 255, 0.4);
+            animation: dot-bounce 1.4s ease-in-out infinite both;
+
+            &:nth-child(1) {
+              animation-delay: 0s;
+            }
+            &:nth-child(2) {
+              animation-delay: 0.2s;
+            }
+            &:nth-child(3) {
+              animation-delay: 0.4s;
+            }
+          }
+        }
+
+        @keyframes dot-bounce {
+          0%,
+          80%,
+          100% {
+            transform: scale(0.6);
+            opacity: 0.4;
+          }
+          40% {
+            transform: scale(1);
+            opacity: 1;
+          }
+        }
+      }
+
+      .user-message {
+        display: flex;
+        flex-wrap: wrap;
+        justify-content: flex-end;
+        align-items: flex-start;
+
+        .message-agent {
+          display: flex;
+          margin-bottom: 8px;
+          margin-left: 5px;
+
+          .user-icon {
+            width: 40px;
+            height: 40px;
+            background-image: var(--img-chat-collapse-bg);
+            background-repeat: no-repeat;
+            background-size: 100% 100%;
+          }
+        }
+
+        .message-body {
+          background: rgba(10, 132, 255, 0.15);
+          padding: 12px;
+          border-radius: 6px 0 6px 6px;
+          max-width: 700px;
+
+          .message-text {
+            font-size: 14px;
+            font-weight: 400;
+            line-height: 1.6;
+            color: #e0e6ed;
+
+            :deep(h1, h2, h3, h4, h5, h6) {
+              color: #fff;
+            }
+            :deep(p) {
+              margin: 0;
+            }
+            :deep(code) {
+              background: rgba(0, 0, 0, 0.2);
+              padding: 1px 4px;
+              border-radius: 3px;
+              font-size: 12px;
+            }
+            :deep(pre) {
+              background: rgba(0, 0, 0, 0.2);
+              padding: 8px;
+              border-radius: 4px;
+              overflow-x: auto;
+              margin: 6px 0;
+              white-space: pre-wrap;
+            }
+            :deep(pre code) {
+              background: transparent;
+              padding: 0;
+            }
+          }
+
+          .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;
+              }
+            }
+          }
+        }
+      }
+
+      .ai-message,
+      .user-message {
+        .message-actions {
+          display: flex;
+          align-items: center;
+          gap: 8px;
+          width: 100%;
+          opacity: 0;
+          transition: opacity 0.2s;
+
+          .action-btn {
+            width: 26px;
+            height: 26px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            border-radius: 4px;
+            color: #8b949e;
+            cursor: pointer;
+            transition: all 0.2s;
+
+            &:hover {
+              color: #e0e6ed;
+              background: rgba(10, 132, 255, 0.15);
+            }
+          }
+
+          .action-time {
+            font-size: 12px;
+            color: #8b949e;
+          }
+        }
+
+        &:hover .message-actions {
+          opacity: 1;
+        }
+      }
+
+      .ai-message .message-actions {
+        padding: 4px 0 0 50px;
+      }
+
+      .user-message .message-actions {
+        padding: 4px 0 0 0;
+        justify-content: flex-end;
+      }
+    }
+  }
+
+  /* 表格预览弹窗 */
+  .table-preview-overlay {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background: rgba(0, 0, 0, 0.6);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    z-index: 1000;
+  }
+
+  .table-preview-modal {
+    background: #1a1f2e;
+    border: 1px solid rgba(63, 80, 106, 0.5);
+    border-radius: 8px;
+    width: 90%;
+    max-width: 1200px;
+    max-height: 700px;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+
+    .table-preview-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 12px 16px;
+      border-bottom: 1px solid rgba(63, 80, 106, 0.3);
+      flex-shrink: 0;
+
+      .table-preview-title {
+        font-size: 15px;
+        font-weight: 500;
+        color: #e0e6ed;
+      }
+
+      .table-preview-close {
+        width: 32px;
+        height: 32px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        border-radius: 4px;
+        color: #8b949e;
+        cursor: pointer;
+        transition: all 0.2s;
+
+        &:hover {
+          color: #e0e6ed;
+          background: rgba(10, 132, 255, 0.15);
+        }
+      }
+    }
+
+    :deep(.table-preview-body) {
+      padding: 20px;
+      overflow: auto;
+      flex: 1;
+
+      table {
+        width: 100%;
+        border-collapse: collapse;
+      }
+
+      th,
+      td {
+        border: 1px solid #3f506a;
+        padding: 10px 14px;
+        text-align: left;
+        color: #e0e6ed;
+        font-size: 14px;
+      }
+
+      th {
+        background: #1e2538;
+        font-weight: 600;
+      }
+
+      tbody tr {
+        &:nth-child(even) td {
+          background: rgba(10, 132, 255, 0.04);
+        }
+
+        &:hover td {
+          background: rgba(10, 132, 255, 0.1);
+        }
+      }
+    }
+  }
+
+  .scroll-to-bottom {
+    position: absolute;
+    bottom: 20px;
+    left: 50%;
+    transform: translateX(-50%);
+    z-index: 10;
+
+    .scroll-to-bottom-icon {
+      width: 40px;
+      height: 40px;
+      border-radius: 50%;
+      background: rgba(255, 255, 255, 0.15);
+      backdrop-filter: blur(4px);
+      background-image: var(--img-chat-scroll-bottom);
+      background-repeat: no-repeat;
+      background-size: 20px 20px;
+      background-position: center;
+      cursor: pointer;
+      transition: all 0.2s;
+
+      &:hover {
+        background-color: rgba(255, 255, 255, 0.25);
+      }
+    }
+  }
+</style>

+ 133 - 0
src/views/ventAI/manageAssistent/components/chatModal/FileListPanel.vue

@@ -0,0 +1,133 @@
+<template>
+  <div v-if="visible" class="file-list-panel">
+    <div class="file-list-header">
+      <span>当前对话附件</span>
+      <CloseOutlined class="close-icon" @click="emit('close')" />
+    </div>
+    <div class="file-list-content">
+      <div v-for="file in files" :key="file.id" class="file-list-item" @click="emit('file-click', file)">
+        <div class="file-icon-small"></div>
+        <div class="file-info-small">
+          <div class="file-name-small">{{ file.name }}</div>
+          <div class="file-size-small">{{ formatFileSize(file.size) }}</div>
+        </div>
+      </div>
+      <div v-if="files.length === 0" class="empty-file-list">暂无附件</div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { CloseOutlined } from '@ant-design/icons-vue';
+  import { formatFileSize } from './utils';
+  import type { AttachedFile } from './types';
+
+  defineProps<{
+    visible: boolean;
+    files: AttachedFile[];
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'close'): void;
+    (e: 'file-click', file: AttachedFile): void;
+  }>();
+</script>
+
+<style scoped lang="less">
+  .file-list-panel {
+    position: absolute;
+    top: 60px;
+    right: 20px;
+    width: 280px;
+    max-height: 400px;
+    background: rgba(10, 132, 255, 0.08);
+    border: 1px solid rgba(63, 80, 106, 0.5);
+    border-radius: 4px;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+    z-index: 1000;
+    display: flex;
+    flex-direction: column;
+
+    .file-list-header {
+      padding: 12px 16px;
+      border-bottom: 1px solid rgba(63, 80, 106, 0.5);
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      font-size: 13px;
+      font-weight: 500;
+      line-height: 1.4;
+      color: #e0e6ed;
+
+      .close-icon {
+        cursor: pointer;
+        font-size: 14px;
+        color: #8b949e;
+
+        &:hover {
+          color: #e0e6ed;
+        }
+      }
+    }
+
+    .file-list-content {
+      flex: 1;
+      overflow-y: auto;
+      padding: 8px;
+
+      .file-list-item {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        padding: 10px;
+        cursor: pointer;
+        border-radius: 4px;
+        transition: all 0.3s;
+        margin-bottom: 4px;
+
+        &:hover {
+          background: rgba(10, 132, 255, 0.1);
+        }
+
+        .file-icon-small {
+          width: 32px;
+          height: 32px;
+          background-image: var(--img-chat-attach-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          flex-shrink: 0;
+        }
+
+        .file-info-small {
+          flex: 1;
+          min-width: 0;
+
+          .file-name-small {
+            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-small {
+            font-size: 11px;
+            font-weight: 400;
+            line-height: 1.3;
+            color: #8b949e;
+          }
+        }
+      }
+
+      .empty-file-list {
+        padding: 20px;
+        text-align: center;
+        color: #8b949e;
+        font-size: 12px;
+      }
+    }
+  }
+</style>

+ 141 - 0
src/views/ventAI/manageAssistent/components/chatModal/FilePreviewPanel.vue

@@ -0,0 +1,141 @@
+<template>
+  <div class="file-preview-panel">
+    <div class="panel-header">
+      <span class="panel-title">{{ file.name }}</span>
+      <div class="close-btn" @click="emit('close')">
+        <div class="btn-close-icon"></div>
+      </div>
+    </div>
+    <div class="panel-content file-preview-content">
+      <div v-if="isImageFile(file.name)" class="image-preview">
+        <img :src="file.preview" alt="预览图片" />
+      </div>
+      <div v-else-if="isPdfFile(file.name)" class="pdf-preview">
+        <iframe v-if="file.preview" :src="file.preview" frameborder="0"></iframe>
+        <div v-else class="pdf-no-preview">无法加载PDF预览</div>
+      </div>
+      <div v-else class="text-preview">
+        <pre>{{ file.content || '文件内容加载中...' }}</pre>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { isImageFile, isPdfFile } from './utils';
+  import type { AttachedFile } from './types';
+
+  defineProps<{
+    file: AttachedFile;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'close'): void;
+  }>();
+</script>
+
+<style scoped lang="less">
+  .file-preview-panel {
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+  }
+
+  .panel-header {
+    padding: 10px 10px 5px 10px;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    flex-shrink: 0;
+
+    .panel-title {
+      font-size: 15px;
+      font-weight: 600;
+      line-height: 1.4;
+      color: #e0e6ed;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      flex: 1;
+      margin-right: 10px;
+    }
+
+    .close-btn {
+      cursor: pointer;
+      width: 36px;
+      height: 36px;
+      background-image: var(--img-chat-upload-bg);
+      background-repeat: no-repeat;
+      background-size: 100% 100%;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      flex-shrink: 0;
+      transition: all 0.3s;
+      margin-bottom: auto;
+
+      .btn-close-icon {
+        width: 20px;
+        height: 20px;
+        background-image: var(--img-chat-close-icon);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+      }
+    }
+  }
+
+  .panel-content {
+    flex: 1;
+    overflow-y: auto;
+    padding: 16px;
+  }
+
+  .file-preview-content {
+    height: 100%;
+
+    .image-preview {
+      width: 100%;
+      height: 100%;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      img {
+        width: 100%;
+        height: 100%;
+        object-fit: contain;
+        border-radius: 4px;
+      }
+    }
+
+    .pdf-preview {
+      width: 100%;
+      height: 100%;
+
+      iframe {
+        width: 100%;
+        height: 100%;
+        border: none;
+      }
+
+      .pdf-no-preview {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        height: 100%;
+        color: #8b949e;
+        font-size: 14px;
+      }
+    }
+
+    .text-preview {
+      pre {
+        white-space: pre-wrap;
+        word-wrap: break-word;
+        font-family: monospace;
+        font-size: 13px;
+        color: #e0e6ed;
+      }
+    }
+  }
+</style>

+ 290 - 0
src/views/ventAI/manageAssistent/components/chatModal/TaskListPanel.vue

@@ -0,0 +1,290 @@
+<template>
+  <div class="task-list-panel">
+    <!-- 搜索模式 -->
+    <div v-if="activeOption === 'search'" class="search-header">
+      <a-input v-model:value="searchKeyword" placeholder="搜索任务名称..." allow-clear size="small" class="search-input" />
+    </div>
+    <!-- 历史记录模式 -->
+    <div v-else class="task-header">
+      <span class="task-title">历史记录</span>
+    </div>
+
+    <div class="task-list">
+      <div
+        v-for="(task, index) in filteredTaskList"
+        :key="index"
+        :class="['task-item', { active: currentTaskId === task.id }]"
+        @click="emit('switch-task', task.id)"
+      >
+        <span class="task-name">{{ task.name }}</span>
+        <div class="task-delete" @click.stop="emit('delete-task', task, index)">
+          <DeleteOutlined />
+        </div>
+      </div>
+      <div v-if="activeOption === 'search' && searchKeyword && filteredTaskList.length === 0" class="empty-hint"> 未找到匹配的任务 </div>
+    </div>
+
+    <div class="options-area">
+      <div
+        v-for="item in optionItems"
+        :key="item.type"
+        :class="['options-area-item', `options-${item.type}`, { active: activeOption === item.type }]"
+        @click="handleOptionClick(item.type)"
+      >
+        <div class="options-img">
+          <div class="options-icon"></div>
+        </div>
+        <a-button size="small" type="primary">{{ item.label }}</a-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, computed } from 'vue';
+  import { DeleteOutlined } from '@ant-design/icons-vue';
+  import type { Task } from './types';
+
+  const props = defineProps<{
+    taskList: Task[];
+    currentTaskId: string;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'switch-task', taskId: string): void;
+    (e: 'create-task'): void;
+    (e: 'delete-task', task: Task, index: number): void;
+  }>();
+
+  type OptionType = 'search' | 'history' | 'add';
+
+  const activeOption = ref<OptionType>('history');
+  const searchKeyword = ref('');
+
+  const optionItems = [
+    { type: 'search' as OptionType, label: '内容搜索' },
+    { type: 'history' as OptionType, label: '历史记录' },
+    { type: 'add' as OptionType, label: '新增任务' },
+  ];
+
+  const filteredTaskList = computed(() => {
+    if (activeOption.value !== 'search' || !searchKeyword.value.trim()) {
+      return props.taskList;
+    }
+    const keyword = searchKeyword.value.trim().toLowerCase();
+    return props.taskList.filter((task) => task.name.toLowerCase().includes(keyword));
+  });
+
+  const handleAddTask = () => {
+    emit('create-task');
+  };
+
+  const handleOptionClick = (type: OptionType) => {
+    activeOption.value = type;
+    if (type === 'add') {
+      emit('create-task');
+    } else {
+      searchKeyword.value = '';
+    }
+  };
+</script>
+
+<style scoped lang="less">
+  .task-list-panel {
+    width: 260px;
+    display: flex;
+    flex-direction: column;
+    background-image: var(--img-chat-task-panel-bg);
+    background-repeat: no-repeat;
+    background-size: 100% 100%;
+    border-radius: 8px;
+    flex-shrink: 0;
+    padding: 10px;
+
+    .search-header {
+      padding-bottom: 10px;
+
+      .search-input {
+        height: 32px;
+        background: rgba(10, 132, 255, 0.1);
+        border: 1px solid rgba(63, 80, 106, 0.5);
+        color: #e0e6ed;
+
+        :deep(.zxm-input) {
+          background: transparent;
+          color: #e0e6ed;
+        }
+      }
+    }
+
+    .task-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding-bottom: 10px;
+
+      .task-title {
+        font-size: 15px;
+        line-height: 1.4;
+        color: #0a84ff;
+      }
+      .task-add-btn {
+        background-color: #0a84ff;
+
+        &:hover {
+          background-color: #0077e6;
+        }
+      }
+    }
+
+    .task-list {
+      flex: 1;
+      overflow-y: auto;
+
+      .task-item {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        padding: 4px;
+        cursor: pointer;
+        transition: all 0.3s;
+        margin-bottom: 4px;
+        color: #e0e6ed;
+
+        &:hover {
+          background: rgba(10, 132, 255, 0.1);
+          box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+        }
+
+        &.active {
+          background: rgba(10, 132, 255, 0.1);
+          box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+          color: #e0e6ed;
+        }
+
+        .task-icon {
+          width: 16px;
+          height: 16px;
+          flex-shrink: 0;
+          background-image: var(--img-chat-task-add-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+        }
+
+        .task-name {
+          font-size: 14px;
+          font-weight: 400;
+          line-height: 1.4;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+          flex: 1;
+        }
+
+        .task-delete {
+          flex-shrink: 0;
+          width: 28px;
+          height: 28px;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          border-radius: 4px;
+          color: #8b949e;
+          font-size: 14px;
+          visibility: hidden;
+          transition: all 0.2s;
+
+          &:hover {
+            color: #ff4d4f;
+            background: rgba(220, 38, 38, 0.1);
+          }
+        }
+
+        &:hover .task-delete {
+          visibility: visible;
+        }
+      }
+
+      .empty-hint {
+        text-align: center;
+        color: #8b949e;
+        font-size: 13px;
+        padding: 20px 0;
+      }
+    }
+
+    .options-area {
+      display: flex;
+      gap: 6px;
+      padding-top: 8px;
+      border-top: 1px solid rgba(63, 80, 106, 0.3);
+
+      .options-area-item {
+        flex: 1;
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        gap: 6px;
+        cursor: pointer;
+        border-radius: 6px;
+        transition: all 0.3s;
+
+        .options-img {
+          width: 60px;
+          height: 60px;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          background-image: var(--img-chat-options-bg);
+          background-repeat: no-repeat;
+          background-size: 45px 45px;
+          background-position: center;
+        }
+
+        &.active .options-img {
+          background-image: var(--img-chat-options-active-bg);
+          background-size: 60px 60px;
+        }
+
+        .options-icon {
+          width: 16px;
+          height: 16px;
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          background-position: center;
+        }
+
+        &.options-search .options-icon {
+          background-image: var(--img-chat-options-search-icon);
+        }
+        &.options-search.active .options-icon {
+          background-image: var(--img-chat-options-search-active-icon);
+        }
+
+        &.options-history .options-icon {
+          background-image: var(--img-chat-options-history-icon);
+        }
+        &.options-history.active .options-icon {
+          background-image: var(--img-chat-options-history-active-icon);
+        }
+
+        &.options-add .options-icon {
+          background-image: var(--img-chat-options-add-icon);
+        }
+        &.options-add.active .options-icon {
+          background-image: var(--img-chat-options-add-active-icon);
+        }
+
+        .zxm-btn {
+          font-size: 14px;
+          padding: 2px 6px;
+          height: auto;
+          background: transparent !important;
+          border: none;
+          color: #0a84ff;
+          box-shadow: none;
+        }
+      }
+    }
+  }
+</style>

+ 397 - 0
src/views/ventAI/manageAssistent/components/chatModal/ThinkingPanel.vue

@@ -0,0 +1,397 @@
+<template>
+  <div class="thinking-panel">
+    <div class="panel-header">
+      <div class="panel-left">
+        <div class="agent-avatar-small"></div>
+        <div class="panel-title-group">
+          <div class="panel-title">{{ currentDisplayAgent?.agentName || '智能分析' }}</div>
+        </div>
+      </div>
+      <div class="close-btn" @click="emit('close')">
+        <div class="btn-close-icon"></div>
+      </div>
+    </div>
+
+    <div class="thinking-section">
+      <div class="section-content thinking-content" ref="thinkingContentRef">
+        <div v-if="!thinkingSections.length" class="empty-thinking">暂无思考过程</div>
+        <div v-for="(section, idx) in thinkingSections" :key="idx" :class="['timeline-item', `timeline-${section.type}`]">
+          <div class="timeline-marker">
+            <div class="timeline-dot"></div>
+            <div v-if="idx < thinkingSections.length - 1" class="timeline-line"></div>
+          </div>
+          <div class="timeline-body">
+            <div class="timeline-header">
+              <span class="timeline-label">{{ section.label }}</span>
+            </div>
+            <div class="timeline-text" v-html="renderMarkdown(section.content)"></div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="agent-horizontal-list" v-if="selectedAgentParentMsg?.agentAnswers?.length">
+      <div
+        v-for="agent in selectedAgentParentMsg!.agentAnswers"
+        :key="agent.id"
+        :class="['agent-chip', { active: currentDisplayAgent?.id === agent.id }]"
+        @click="emit('select-agent', agent)"
+      >
+        <div class="chip-body">
+          <div class="chip-icon"></div>
+          <div class="chip-name">
+            {{ agent.agentName || '智能分析' }}
+          </div>
+        </div>
+        <div class="chip-status">
+          <span v-if="agent.status === 'thinking'">正在连接</span>
+          <span v-else>已完成</span>
+        </div>
+        <div class="chip-border"></div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref } from 'vue';
+  import { renderMarkdown } from './utils';
+  import type { AgentAnswer, Message, ThinkingSection } from './types';
+
+  defineProps<{
+    currentDisplayAgent: AgentAnswer | null;
+    selectedAgentParentMsg: Message | null;
+    thinkingSections: ThinkingSection[];
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'select-agent', answer: AgentAnswer): void;
+    (e: 'close'): void;
+  }>();
+
+  const thinkingContentRef = ref<HTMLElement | null>(null);
+
+  defineExpose({ thinkingContentRef });
+</script>
+
+<style scoped lang="less">
+  .thinking-panel {
+    height: 100%;
+    width: 100%;
+    display: flex;
+    flex-direction: column;
+    padding: 12px 16px;
+
+    .panel-header {
+      display: flex;
+      padding: 0;
+      justify-content: space-between;
+      align-items: center;
+      margin-bottom: 6px;
+      flex-shrink: 0;
+
+      .panel-left {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+      }
+
+      .agent-avatar-small {
+        width: 35px;
+        height: 36px;
+        border-radius: 8px;
+        background-image: var(--img-chat-panel-header);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+        flex-shrink: 0;
+      }
+
+      .panel-title-group {
+        display: flex;
+        flex-direction: column;
+        line-height: 1;
+      }
+
+      .panel-title {
+        font-size: 15px;
+        font-weight: 600;
+        line-height: 1.4;
+        color: #0a84ff;
+      }
+      .close-btn {
+        cursor: pointer;
+        width: 36px;
+        height: 36px;
+        background-image: var(--img-chat-upload-bg);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        flex-shrink: 0;
+        transition: all 0.3s;
+        margin-bottom: auto;
+
+        .btn-close-icon {
+          width: 20px;
+          height: 20px;
+          background-image: var(--img-chat-close-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+        }
+      }
+    }
+
+    .thinking-section {
+      flex: 1;
+      height: 0;
+      display: flex;
+      flex-direction: column;
+      border: 1px solid #0a84ff;
+      border-radius: 4px 4px 0 0;
+
+      .thinking-content {
+        flex: 1;
+        background: rgba(10, 132, 255, 0.05);
+        border: 1px solid rgba(63, 80, 106, 0.5);
+        border-radius: 8px;
+        padding: 20px 16px;
+        overflow-y: auto;
+        font-size: 12px;
+        font-weight: 400;
+        color: #e0e6ed;
+        line-height: 1.6;
+
+        .empty-thinking {
+          color: #8b949e;
+          font-style: italic;
+          text-align: center;
+          padding: 32px 0;
+        }
+
+        .timeline-item {
+          display: flex;
+          gap: 0;
+          position: relative;
+
+          .timeline-marker {
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            width: 28px;
+            flex-shrink: 0;
+            position: relative;
+          }
+
+          .timeline-dot {
+            width: 12px;
+            height: 12px;
+            border-radius: 50%;
+            border: 2px solid #fff;
+            flex-shrink: 0;
+            margin-top: 6px;
+            position: relative;
+            z-index: 1;
+          }
+
+          .timeline-line {
+            flex: 1;
+            width: 0;
+            min-height: 16px;
+            margin: 4px 0;
+            border-left: 2px dashed #8b949e;
+          }
+
+          &.timeline-select {
+            .timeline-dot {
+              background: #0a84ff;
+              box-shadow: 0 0 0 3px rgba(10, 132, 255, 0.15);
+            }
+            .timeline-header {
+              color: #e0e6ed;
+            }
+          }
+          &.timeline-params {
+            display: none;
+            .timeline-dot {
+              background: #faad14;
+              box-shadow: 0 0 0 3px rgba(223, 91, 12, 0.15);
+            }
+            .timeline-header {
+              color: #e0e6ed;
+            }
+          }
+          &.timeline-result {
+            .timeline-dot {
+              background: #52c41a;
+              box-shadow: 0 0 0 3px rgba(46, 125, 50, 0.15);
+            }
+          }
+
+          .timeline-body {
+            flex: 1;
+            min-width: 0;
+            padding-bottom: 16px;
+            padding-left: 12px;
+          }
+
+          .timeline-header {
+            font-size: 13px;
+            font-weight: 500;
+            margin-bottom: 8px;
+            line-height: 1.4;
+          }
+
+          .timeline-text {
+            font-size: 14px;
+            font-weight: 400;
+            color: #e0e6ed;
+            line-height: 1.6;
+
+            :deep(p) {
+              margin: 0 0 6px;
+            }
+            :deep(code) {
+              background: rgba(10, 132, 255, 0.1);
+              padding: 2px 6px;
+              border-radius: 3px;
+              font-family: monospace;
+              font-size: 12px;
+            }
+            :deep(pre) {
+              background: rgba(10, 132, 255, 0.1);
+              padding: 10px;
+              border-radius: 4px;
+              overflow-x: auto;
+              margin: 8px 0;
+            }
+            :deep(pre code) {
+              background: transparent;
+              padding: 0;
+            }
+            :deep(ul),
+            :deep(ol) {
+              padding-left: 20px;
+              margin: 4px 0;
+            }
+            :deep(li) {
+              margin-bottom: 2px;
+            }
+            :deep(h1),
+            :deep(h2),
+            :deep(h3),
+            :deep(h4) {
+              margin: 8px 0 4px;
+              font-weight: 600;
+            }
+            :deep(blockquote) {
+              border-left: 3px solid rgba(63, 80, 106, 0.5);
+              padding-left: 12px;
+              margin: 8px 0;
+              color: #8b949e;
+            }
+            :deep(table) {
+              border-collapse: collapse;
+              width: 100%;
+              margin: 8px 0;
+            }
+            :deep(th),
+            :deep(td) {
+              border: 1px solid rgba(63, 80, 106, 0.5);
+              padding: 6px 10px;
+              text-align: left;
+            }
+            :deep(th) {
+              background: rgba(10, 132, 255, 0.1);
+              font-weight: 600;
+            }
+            :deep(strong) {
+              font-weight: 600;
+            }
+          }
+        }
+      }
+    }
+
+    .agent-horizontal-list {
+      display: flex;
+      gap: 10px;
+      padding: 8px 0;
+      overflow-x: auto;
+      align-items: center;
+      flex-shrink: 0;
+      border-radius: 0 0 4px 4px;
+      border-top: none;
+      background-color: rgba(10, 132, 255, 0.05);
+    }
+
+    .agent-chip {
+      display: flex;
+      align-items: center;
+      gap: 10px;
+      padding: 8px 12px 8px 20px;
+      background: rgba(10, 132, 255, 0.08);
+      border-radius: 10px;
+      border: 1px solid rgba(63, 80, 106, 0.5);
+      min-width: 140px;
+      cursor: pointer;
+      transition: all 0.2s;
+      position: relative;
+
+      .chip-body {
+        display: flex;
+        align-items: start;
+        gap: 8px;
+        flex-direction: column;
+      }
+
+      .chip-border {
+        position: absolute;
+        left: 10px;
+        top: 16px;
+        width: 5px;
+        height: 35px;
+        border: 2px solid #0a84ff;
+        pointer-events: none;
+        border-right: none;
+      }
+      .chip-icon {
+        width: 24px;
+        height: 24px;
+        background-image: var(--img-chat-thinking-bg);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+      }
+      .chip-name {
+        font-size: 13px;
+        font-weight: 500;
+        line-height: 1.4;
+        color: #e0e6ed;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        width: 100px;
+      }
+
+      .chip-status {
+        font-size: 11px;
+        font-weight: 400;
+        line-height: 1.3;
+        color: #8b949e;
+        position: absolute;
+        top: 10px;
+        right: 10px;
+      }
+
+      &.active {
+        background-color: rgba(10, 132, 255, 0.12);
+        box-shadow: 0 6px 18px rgba(10, 132, 255, 0.06);
+        border-color: #0a84ff;
+
+        .chip-status {
+          color: #0a84ff;
+        }
+      }
+    }
+  }
+</style>

+ 64 - 0
src/views/ventAI/manageAssistent/components/chatModal/types.ts

@@ -0,0 +1,64 @@
+export interface AttachedFile {
+  id: string;
+  name: string;
+  size: number;
+  type: string;
+  preview?: string;
+  content?: string;
+  uploadTime: string;
+  messageId?: string;
+  originalFile?: File;
+}
+
+export interface AgentAnswer {
+  id: string;
+  agentName: string;
+  toolName: string;
+  thinking: string;
+  result: string;
+  status: 'thinking' | 'completed';
+  timestamp: number;
+}
+
+export interface ThinkingStep {
+  type: 'agent_start' | 'agent_done' | 'tool_call' | 'tool_result' | 'executing' | 'updated_todo_list' | 'token';
+  agent?: string;
+  tool?: string;
+  message?: string;
+  todos?: Array<{ content: string; status: string }>;
+  content?: string;
+  timestamp: number;
+}
+
+export interface Message {
+  type: 'user' | 'ai';
+  content: string;
+  time: string;
+  createdAt?: string;
+  isTerminal?: boolean;
+  isLoading?: boolean;
+  attachedFile?: AttachedFile;
+  agentAnswers?: AgentAnswer[];
+  thinkingSteps?: ThinkingStep[];
+  sessionId?: string;
+  wordDownloadUrl?: string;
+  durationMs?: number;
+  generateStartTime?: number;
+}
+
+export interface Task {
+  id: string;
+  name: string;
+  sessionId: string;
+  messages: Message[];
+  attachedFiles: AttachedFile[];
+  wordUrl?: string;
+}
+
+export type SectionType = 'select' | 'params' | 'result';
+
+export interface ThinkingSection {
+  label: string;
+  content: string;
+  type: SectionType;
+}

+ 90 - 0
src/views/ventAI/manageAssistent/components/chatModal/utils.ts

@@ -0,0 +1,90 @@
+import { marked } from 'marked';
+
+export const renderMarkdown = (text: string): string => {
+  if (!text) return '';
+  const processed = text.replace(/<br\s*\/?>/gi, '  \n');
+  const html = marked.parse(processed) as string;
+  return wrapTables(html);
+};
+
+const wrapTables = (html: string): string => {
+  return html.replace(/<table>([\s\S]*?)<\/table>/g, (match) => {
+    const encodedTable = encodeURIComponent(match);
+    return `<div class="markdown-table-wrapper">
+      <div class="table-actions">
+        <span class="table-action-btn" data-action="copy" data-table="${encodedTable}" title="复制Markdown">
+          <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="4.5" y="4.5" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2"/><path d="M9.5 4.5V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v5.5a1 1 0 0 0 1 1h1.5" stroke="currentColor" stroke-width="1.2"/></svg>
+        </span>
+        <span class="table-action-btn" data-action="csv" data-table="${encodedTable}" title="下载CSV">
+          <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 2v7M4 7l3 3 3-3M3 12h8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
+        </span>
+        <span class="table-action-btn" data-action="preview" data-table="${encodedTable}" title="预览表格">
+          <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="12" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M1 5h12M1 9h12M5 1v12M9 1v12" stroke="currentColor" stroke-width="1.2"/></svg>
+        </span>
+      </div>
+      ${match}
+    </div>`;
+  });
+};
+
+export const tableHtmlToMarkdown = (tableHtml: string): string => {
+  const parser = new DOMParser();
+  const doc = parser.parseFromString(tableHtml, 'text/html');
+  const table = doc.querySelector('table');
+  if (!table) return tableHtml;
+
+  const rows: string[][] = [];
+  table.querySelectorAll('tr').forEach((tr) => {
+    const cells: string[] = [];
+    tr.querySelectorAll('th, td').forEach((cell) => {
+      cells.push(cell.textContent?.trim() || '');
+    });
+    rows.push(cells);
+  });
+
+  if (rows.length === 0) return tableHtml;
+
+  const colCount = Math.max(...rows.map((r) => r.length));
+  const normalized = rows.map((r) => {
+    while (r.length < colCount) r.push('');
+    return r;
+  });
+
+  const lines: string[] = [];
+  lines.push('| ' + normalized[0].join(' | ') + ' |');
+  lines.push('| ' + normalized[0].map(() => '---').join(' | ') + ' |');
+  for (let i = 1; i < normalized.length; i++) {
+    lines.push('| ' + normalized[i].join(' | ') + ' |');
+  }
+  return lines.join('\n');
+};
+
+export const tableHtmlToCsv = (tableHtml: string): string => {
+  const parser = new DOMParser();
+  const doc = parser.parseFromString(tableHtml, 'text/html');
+  const table = doc.querySelector('table');
+  if (!table) return '';
+
+  const rows: string[] = [];
+  table.querySelectorAll('tr').forEach((tr) => {
+    const cells: string[] = [];
+    tr.querySelectorAll('th, td').forEach((cell) => {
+      const text = (cell.textContent?.trim() || '').replace(/"/g, '""');
+      cells.push(`"${text}"`);
+    });
+    rows.push(cells.join(','));
+  });
+
+  return '' + rows.join('\n');
+};
+
+export const formatFileSize = (bytes: number): string => {
+  if (bytes < 1024) return bytes + ' B';
+  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
+  return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
+};
+
+export const isImageFile = (filename: string): boolean =>
+  /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(filename);
+
+export const isPdfFile = (filename: string): boolean => /\.pdf$/i.test(filename);

+ 1 - 0
types/config.d.ts

@@ -160,6 +160,7 @@ export interface GlobConfig {
   sysOrgCode: string;
   sysDataType: string;
   // isSimulate: boolean
+  is2DModel: boolean;
 }
 export interface GlobEnvConfig {
   // Site title

+ 9 - 0
types/global.d.ts

@@ -10,8 +10,17 @@ declare global {
     safetyCrlPlatformUrl: string;
     gasControlMock: boolean;
     historyIsMultiple: boolean;
+    is2DModel: boolean;
   };
 
+  interface GlobalConfig {
+    simulatedPassword: string;
+    History_Type: string;
+    pageType: string;
+    showReport: boolean;
+    is2DModel: boolean;
+  }
+
   const __APP_INFO__: {
     pkg: {
       name: string;

+ 11 - 11
vite.config.ts

@@ -54,7 +54,7 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
           find: /@\//,
           replacement: pathResolve('src') + '/',
         },
-        // /#/xxxx => types/xxxx
+        // #/xxxx => types/xxxx
         {
           find: /#\//,
           replacement: pathResolve('types') + '/',
@@ -92,11 +92,17 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
             // 将 Lodash 库的代码单独打包 (lodash 已废弃,仅保留 lodash-es)
             'lodash-es-vendor': ['lodash-es'],
             'html2canvas-vendor': ['html2canvas'],
+            'd3-vendor': ['d3'],
+            'datav-vendor': ['@kjgl77/datav-vue3'],
             // vue vue-router合并打包
             vue: ['vue', 'vue-router'],
             'cron-parser-vendor': ['cron-parser'],
             // MiniChat 重型依赖分包——Office 预览
-            'office-preview': ['@vue-office/pdf', '@vue-office/docx', '@vue-office/excel'],
+            'office-preview': [
+              '@vue-office/pdf/lib/v3/vue-office-pdf.mjs',
+              '@vue-office/docx/lib/v3/vue-office-docx.mjs',
+              '@vue-office/excel/lib/v3/vue-office-excel.mjs',
+            ],
             // MiniChat 重型依赖分包——Markdown/LaTeX/代码高亮
             'markdown-vendor': ['marked', 'katex', 'highlight.js'],
           },
@@ -105,8 +111,8 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
 
       // 关闭brotliSize显示可以稍微减少打包时间
       reportCompressedSize: false,
-      // 提高超大静态资源警告大小
-      chunkSizeWarningLimit: 2500,
+      // 超大静态资源警告阈值
+      chunkSizeWarningLimit: 500,
     },
     esbuild: {
       //清除全局的console.log和debug
@@ -134,10 +140,7 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
       esbuildOptions: {
         target: 'es2020',
       },
-      exclude: [
-        //升级vite4后,需要排除online依赖
-        '@jeecg/online',
-      ],
+      exclude: [],
       // 提前预加载依赖,缩短首屏访问时间
       include: [
         '@vue/runtime-core',
@@ -189,9 +192,7 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
         'echarts/core',
         'echarts/renderers',
         'emoji-mart-vue-fast/src',
-        'intro.js',
         'lodash-es',
-        'md5',
         'nprogress',
         'path-to-regexp',
         'pinia',
@@ -199,7 +200,6 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
         'qrcode',
         'qs',
         'resize-observer-polyfill',
-        'showdown',
         'sortablejs',
         'tinymce/icons/default/icons',
         'tinymce/plugins/advlist',

Some files were not shown because too many files changed in this diff