52 Commity f4d67e8379 ... a8fb1ef35c

Autor SHA1 Wiadomość Data
  lizuo a8fb1ef35c Merge upstream/master into fix/base-agent-token(同步至 e3d4295c:43+ 提交/重构成 api\/ 与 chat\/ 目录;冲突按「团队已重写等价实现」取 master 版——我方 M1/M2/M3 已被 master 吸收,仅保留 docs/ai-changes 我方说明) 3 tygodni temu
  wangkeyi e3d4295caf [Feat 0000]AI助手增加打开网页链接面板 3 tygodni temu
  wangkeyi 53d5bd59cf [Feat 0000]AI助手增加对dwg文件的支持 3 tygodni temu
  wangkeyi 5f5c245843 [Feat 0000]修复设备数据点选弹框有多个滚动条的的问题 3 tygodni temu
  wangkeyi 3ac4bf8b27 [Mod 0000]修改对话中出现重复多条AI消息时的错误折叠问题 3 tygodni temu
  houzekong b9b7aae018 [Fix 0000] 关联控制无数据时初始化错误修复 3 tygodni temu
  wangkeyi 8b5d4188e3 [Mod 0000]修改AI助手在对话过程中点击停止对话后的逻辑 3 tygodni temu
  houzekong 0b0fffd90e [Feat 0000] 二位抽采泵模型动画对接 3 tygodni temu
  houzekong 863327b705 [Feat 0000] 瓦斯抽采泵二维模型对接 3 tygodni temu
  houzekong 890c3c6694 [Feat 0000] 注浆svg开发 1 miesiąc temu
  wangkeyi 5c6aca75ca [Mod 0000]AI助手新对话时增加删除按钮 3 tygodni temu
  lxh 0ed32fcf2f Merge branch 'master' of http://39.97.59.228:8013/hrx/mky-vent-base 3 tygodni temu
  lxh d952dfee45 通风预警-绿色版本优化-提交 3 tygodni temu
  bobo04052021@163.com 65743bff0b [Fix 0000]色谱仪报表问题修复 3 tygodni temu
  bobo04052021@163.com 40883e5434 [Feat 0000]布尔台色谱仪报表 3 tygodni temu
  wangkeyi dd65c94a2c [Feat 0000]AI助手调整子智能体等面板中筛选下拉框样式 3 tygodni temu
  wangkeyi 75f8c31508 [Mod 0000]AI助手增加全局统一的空提示组件 3 tygodni temu
  wangkeyi 9b128e34ba [Mod 0000] AI助手思考面板增加管理员权限 3 tygodni temu
  wangkeyi cb15ad1e27 [Mod 0000] AI助手技能和子智能体面板增加非admin禁止修改 4 tygodni temu
  bobo04052021@163.com 776db336bd [Feat 0000]光纤测温折线图组件 1 miesiąc temu
  wangkeyi f9002a5cbb [Mod 0000] AI助手切换会话补加载态:历史拉取期间显示居中加载指示器,不再闪空会话 1 miesiąc temu
  wangkeyi 4762ae67a5 [Mod 0000] AI助手接口层收敛:三个流式函数合一 + 响应类型化,并按实测校正接口契约 1 miesiąc temu
  wangkeyi e7aa2357a1 [Feat 0000] AI助手对话输入框添加附件可拖动上传以及上下键回溯消息 1 miesiąc temu
  lxh 32bf2ea7a0 Merge branch 'master' of http://39.97.59.228:8013/hrx/mky-vent-base 1 miesiąc temu
  wangkeyi 1a00383cba [Docs] M3 路径校正的注释/文档同步:AiAssistantModal 注释与交付说明统一为 /ventAI/api/model/me 1 miesiąc temu
  wangkeyi ce8a86cd08 [Fix R4-M3] /me 路径校正为 /ventAI/api/model/me(浏览器实测 + 部署 OpenAPI 核对) 1 miesiąc temu
  wangkeyi 50f1b66141 [Fix R4-R4-8] 合并冲突取值更正:useChatScroll 卸载取消 rAF 取审计分支版本 1 miesiąc temu
  wangkeyi d3a0700378 [Docs] 第四轮审计交付说明追记 §⑨:合并新 master 的冲突解决与复验证据 1 miesiąc temu
  wangkeyi e774cd5c9a [Merge] 合并 origin/master(+5 提交)到 fix/audit-r4-20260929 1 miesiąc temu
  wangkeyi 64cfbb1abf [Bugfix 0000] 修复ai助手不能预览文件的bug 1 miesiąc temu
  wangkeyi 5f41a3beec [Feat 0000]AI助手新增dxf格式支持 1 miesiąc temu
  wangkeyi 6de78411ed [Mod 0000]AI助手代码规范化:接口按域拆分、重复实现与类型收敛,修复竞态与错误提示丢失等缺陷 1 miesiąc temu
  lizuo 6f478e0d61 [Fix R4-R4-5] 复核 FR-1 收口:缓存失效源并入 props.messages.length(删除 AI 消息场景),同步注释与交付说明 1 miesiąc temu
  lizuo 4e804715a0 [Docs] 第四轮审计整改交付说明(M1/M2/M3 重放 + R4-2/R4-4/R4-5)+ ai-changes README 1 miesiąc temu
  lizuo 60c576a4b0 [Fix R4-R4-7/8] markdownSafe 注释旧路径更正;useChatScroll 卸载时 cancelAnimationFrame 1 miesiąc temu
  lizuo 51803698f9 [Fix R4-R4-5] ConversationNav 滚动跟随缓存 wrappers 列表,替代每帧 querySelectorAll(rAF 合并不变) 1 miesiąc temu
  lizuo 3b45e485be [Fix R4-R4-2] extractMarkdownLinks 加 MD_LINK_CACHE_MAX=200 LRU,流式期胶囊链接提取不再每 token 全量 lexer 1 miesiąc temu
  lizuo 23fc0f9653 [Fix R4-R4-4] 删除 Modal/index.less 与 antCss.less 双份定义的关闭按钮宽度规则,保留全局层一份(渲染等价) 1 miesiąc temu
  lizuo e1b33e82e4 [Fix R4-M3] /ventAI/api/me + getMe() + 按 is_admin 隐藏技能/子智能体/思考级别入口(重放 fix/base-agent-token M3,保留失败置 false 不缓存语义) 1 miesiąc temu
  lizuo 75f6c19815 [Fix R4-M2] 点选解读复用 throwStreamHttpError/StreamError,429 带 retryAfter 可读文案(重放 fix/base-agent-token M2) 1 miesiąc temu
  lizuo dce7abdd65 [Fix R4-M1] 点选解读 SSE 补 X-Access-Token(重放 fix/base-agent-token M1,保留令牌可选语义) 1 miesiąc temu
  wangkeyi e7e89214b6 [Mod 0000]AI助手部分警告代码修复以及对话导航条逻辑修改 1 miesiąc temu
  wangkeyi 83eb0a1e94 [Mod 0000] AI助手消息来源卡片默认折叠 1 miesiąc temu
  wangkeyi 46df977e0a [Mod 0000] 对AI助手代码进行文件层面的调整,封装组件、抽取方法、规范代码 1 miesiąc temu
  houzekong 76e5926a93 [Fix 0000] 修复抽采泵左侧泵站动画缺失的问题 1 miesiąc temu
  houzekong 2a33f82444 [Fix 0000] 修复鉴权死循环bug 1 miesiąc temu
  houzekong a7da001e22 [Fix 0000] 模拟、自动登录和客制化首页的query冲突问题修复 1 miesiąc temu
  houzekong 71145ac4b4 [Mod 0000] 瓦斯抽采泵站监测细则修改 1 miesiąc temu
  wangkeyi eccff637b5 [Mod 0000]AI助手sse事件逻辑修改 1 miesiąc temu
  wangkeyi c562d84e3f [Mod 0000]AI助手消息导航增加滚动跟随 1 miesiąc temu
  wangkeyi f4a47a3c44 [Bugfix 0000]修复生产环境下设备编辑弹框按钮显示错位的问题 1 miesiąc temu
  lxh b81834d20e 对摄像头间歇性变暗及摄像头配置传参问题进行修改-提交 2 miesięcy temu
100 zmienionych plików z 15006 dodań i 7295 usunięć
  1. 219 0
      docs/ai-changes/20260929-audit-r4.md
  2. 1 0
      docs/ai-changes/README.md
  3. 3 0
      pnpm-workspace.yaml
  4. BIN
      src/assets/images/fire.png
  5. 7 0
      src/assets/images/warning.svg
  6. 6 8
      src/components/Modal/src/index.less
  7. 16 2
      src/components/vent/camera/LivePlayerWrapper.vue
  8. 9 0
      src/design/vent/antCss.less
  9. 44 0
      src/design/vent/comment.less
  10. 13 13
      src/hooks/system/useCamera.ts
  11. 13 13
      src/hooks/system/useCameraPianation.ts
  12. 11 4
      src/layouts/default/index.vue
  13. 4 2
      src/router/guard/index.ts
  14. 57 6
      src/router/guard/permissionGuard.ts
  15. 3 1
      src/router/routes/index.ts
  16. 24 0
      src/utils/homeStyle.ts
  17. 5 2
      src/utils/markdownSafe.ts
  18. 3 3
      src/utils/ventutil.ts
  19. 177 0
      src/views/vent/bundleSpy/bundleSpyTable/bundleSpy-table.data.ts
  20. 93 76
      src/views/vent/bundleSpy/bundleSpyTable/index.vue
  21. 2 2
      src/views/vent/deviceManager/comment/cameraTabel/camera.data.ts
  22. 125 123
      src/views/vent/deviceManager/comment/warningTabel/ctrlTable.vue
  23. 38 7
      src/views/vent/home/configurable/components/detail/CustomChart.vue
  24. 82 90
      src/views/vent/home/configurable/components/green-right-tag.vue
  25. 1 0
      src/views/vent/home/configurable/configurable.data.ts
  26. 11 0
      src/views/vent/home/configurable/green/components/CustomTable-green.vue
  27. 28 11
      src/views/vent/home/configurable/green/components/MiniBoard-green.vue
  28. 6 4
      src/views/vent/home/configurable/green/components/ModuleOriginal-green.vue
  29. 64 92
      src/views/vent/home/configurable/green/components/center-area-green.vue
  30. 10 22
      src/views/vent/home/configurable/green/components/content-green.vue
  31. 231 217
      src/views/vent/home/configurable/green/components/dz-chart.vue
  32. 40 16
      src/views/vent/home/configurable/green/components/dz-list.vue
  33. 162 155
      src/views/vent/home/configurable/green/components/dz-risk.vue
  34. 146 138
      src/views/vent/home/configurable/green/components/green-nav.vue
  35. 5 3
      src/views/vent/home/configurable/green/components/module-green.vue
  36. 6 2
      src/views/vent/home/configurable/green/components/moduleBottom-green.vue
  37. 5 3
      src/views/vent/home/configurable/green/components/moduleDual-green.vue
  38. 6 2
      src/views/vent/home/configurable/green/components/moduleLeft-green.vue
  39. 113 133
      src/views/vent/home/configurable/green/dust-green.vue
  40. 155 120
      src/views/vent/home/configurable/green/fire-green.vue
  41. 117 106
      src/views/vent/home/configurable/green/fusion-warn-green.vue
  42. 81 39
      src/views/vent/home/configurable/green/vent-green.vue
  43. 6 5
      src/views/vent/home/homeAI/index.vue
  44. 3 0
      src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHome.vue
  45. 3 0
      src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHomeBD.vue
  46. 3 0
      src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHomeBet.vue
  47. 3 0
      src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHomeCC.vue
  48. 3 0
      src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHomeCopy.vue
  49. 4334 0
      src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpSVG.vue
  50. 1 1
      src/views/vent/monitorManager/gasPumpMonitor/gasPump.data.bet.ts
  51. 69 0
      src/views/vent/monitorManager/gasPumpMonitor/gasPump.data.ts
  52. 7 27
      src/views/vent/monitorManager/gasPumpMonitor/gasPump.threejs.under.ts
  53. 48 11
      src/views/vent/monitorManager/gasPumpMonitor/index.vue
  54. 12 2
      src/views/vent/monitorManager/groutMonitor/components/groutHome.vue
  55. 12 2
      src/views/vent/monitorManager/groutMonitor/components/groutHomeHjt.vue
  56. 13 2
      src/views/vent/monitorManager/groutMonitor/components/groutHomeJj.vue
  57. 13 2
      src/views/vent/monitorManager/groutMonitor/components/groutHomehlg.vue
  58. 13 2
      src/views/vent/monitorManager/groutMonitor/components/groutHomelt.vue
  59. 1501 0
      src/views/vent/monitorManager/groutMonitor/components/zhijiangSVG.vue
  60. 29 0
      src/views/vent/monitorManager/groutMonitor/grout.data.ts
  61. 11 3
      src/views/vent/monitorManager/groutMonitor/index.vue
  62. 361 0
      src/views/vent/testChart/FiberTempChart.vue
  63. 46 0
      src/views/vent/testChart/index.vue
  64. 16 73
      src/views/ventAI/dataPicker/api.ts
  65. 9 3
      src/views/ventAI/dataPicker/components/DataPickerModal.vue
  66. 10 10
      src/views/ventAI/dataPicker/useDataPicker.ts
  67. 0 597
      src/views/ventAI/manageAssistent/api.ts
  68. 91 0
      src/views/ventAI/manageAssistent/api/cad.ts
  69. 115 0
      src/views/ventAI/manageAssistent/api/chat.ts
  70. 72 0
      src/views/ventAI/manageAssistent/api/errors.ts
  71. 15 0
      src/views/ventAI/manageAssistent/api/index.ts
  72. 26 0
      src/views/ventAI/manageAssistent/api/model.ts
  73. 60 0
      src/views/ventAI/manageAssistent/api/report.ts
  74. 84 0
      src/views/ventAI/manageAssistent/api/schedule.ts
  75. 144 0
      src/views/ventAI/manageAssistent/api/session.ts
  76. 49 0
      src/views/ventAI/manageAssistent/api/skill.ts
  77. 161 0
      src/views/ventAI/manageAssistent/api/sse.ts
  78. 70 0
      src/views/ventAI/manageAssistent/api/streamPost.ts
  79. 72 0
      src/views/ventAI/manageAssistent/api/subagent.ts
  80. 29 0
      src/views/ventAI/manageAssistent/api/usage.ts
  81. 28 0
      src/views/ventAI/manageAssistent/api/user.ts
  82. 287 542
      src/views/ventAI/manageAssistent/components/AiAssistantModal.vue
  83. 394 0
      src/views/ventAI/manageAssistent/components/chat/AskUserCard.vue
  84. 5 2
      src/views/ventAI/manageAssistent/components/chat/ChatHeader.vue
  85. 685 0
      src/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue
  86. 1309 0
      src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue
  87. 211 0
      src/views/ventAI/manageAssistent/components/chat/CitationsCard.vue
  88. 481 0
      src/views/ventAI/manageAssistent/components/chat/ConversationNav.vue
  89. 212 0
      src/views/ventAI/manageAssistent/components/chat/DownloadFiles.vue
  90. 242 0
      src/views/ventAI/manageAssistent/components/chat/Input/ContextRing.vue
  91. 209 0
      src/views/ventAI/manageAssistent/components/chat/Input/ModePopup.vue
  92. 186 0
      src/views/ventAI/manageAssistent/components/chat/Input/SessionPopup.vue
  93. 171 0
      src/views/ventAI/manageAssistent/components/chat/Input/SkillPopup.vue
  94. 211 0
      src/views/ventAI/manageAssistent/components/chat/Input/ThinkLevelPopup.vue
  95. 266 0
      src/views/ventAI/manageAssistent/components/chat/Input/UsageRing.vue
  96. 127 0
      src/views/ventAI/manageAssistent/components/chat/MessageActions.vue
  97. 325 0
      src/views/ventAI/manageAssistent/components/chat/ThinkingCard.vue
  98. 1 5
      src/views/ventAI/manageAssistent/components/chat/TodoListBar.vue
  99. 0 2145
      src/views/ventAI/manageAssistent/components/chatModal/ChatInputArea.vue
  100. 0 2446
      src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue

+ 219 - 0
docs/ai-changes/20260929-audit-r4.md

@@ -0,0 +1,219 @@
+# 第四轮审计整改 · mky-vent-base(M1/M2/M3 重放 + R4-2/R4-4/R4-5)
+
+## ① 头部
+
+- **对照网址**:`http://39.97.59.228:8013/hrx/mky-vent-base/compare/master...fix/audit-r4-20260929`
+- **依据文档**:
+  - `tools/verify/代码审核-第四轮-mky-vent-base-20260929.md`(§1 R4-1/R4-2/R4-4/R4-5,§2 M1/M2/M3 重放落点)
+  - `tools/verify/第四轮-施工任务书-20260929.md` 第二章(工作令)
+  - `AGENTS.md` §七(§7.2 基座口径)/§九(改动说明随分支提交)
+- **分支**:`fix/audit-r4-20260929`(worktree `/root/VentAnaly60/.wt/mky-r4`)
+- **基线**:`upstream/master` = `46df977e`(团队 AI 助手文件层面重构后的新结构)
+- **日期**:2026-09-29(Asia/Shanghai)
+- **交付版本**:以分支 `git log` 为准(**不写死自指 hash**)
+- **状态**:已交付,已推送,**已 merge 更新后的 master 并通过全量构建**(2026-09-30,见 §⑨);未部署,未浏览器实测。
+
+## ② 提交清单
+
+一项一提交,提交信息均带编号(以 `git log 46df977e..HEAD` 为准):
+
+| # | 提交 | 内容 |
+|---|---|---|
+| 1 | `[Fix R4-M1] …` | 点选解读 SSE 补 `X-Access-Token`(重放 fix/base-agent-token M1,保留令牌可选语义) |
+| 2 | `[Fix R4-M2] …` | `throwStreamHttpError` 加 export;点选链路复用它替换裸 `throw new Error`;`formatClickError`(重放 M2) |
+| 3 | `[Fix R4-M3] …` | `/ventAI/api/me` + `getMe()` + 按 `is_admin` 隐藏技能/子智能体/思考级别入口(重放 M3;**该路径 2026-09-30 实测校正为 `/ventAI/api/model/me`,见 §⑩**) |
+| 4 | `[Fix R4-R4-4] …` | 删除 `Modal/src/index.less` 与 `antCss.less` 双份定义的关闭按钮宽度规则 |
+| 5 | `[Fix R4-R4-2] …` | `extractMarkdownLinks` 加 `MD_LINK_CACHE_MAX=200` LRU |
+| 6 | `[Fix R4-R4-5] …` | `ConversationNav` 滚动跟随缓存 wrappers 列表 |
+| 7 | `[Fix R4-R4-7/8] …` | `markdownSafe` 注释旧路径更正;`useChatScroll` 卸载时 `cancelAnimationFrame` |
+| 8 | `[Docs] …` | 本说明 + `docs/ai-changes/README.md` |
+| 9 | `[Fix R4-R4-5] …(复核 FR-1 收口)` | 缓存失效源补 `props.messages.length`(删 AI 消息场景)+ 注释/文档同步 |
+| 10 | `[Merge] …` | 合并上游 `origin/master`(5 个新提交,见 §⑨):3 处冲突解决 + 复验 |
+
+## ③ 改动面
+
+| 文件 | 改了什么 |
+|---|---|
+| `src/views/ventAI/dataPicker/api.ts` | M1:import `getToken`;`ssePost` headers 增 `'X-Access-Token': getToken()`。M2:import `throwStreamHttpError`;`!response.ok` 分支改 `await throwStreamHttpError(response)` |
+| `src/views/ventAI/dataPicker/useDataPicker.ts` | M2:import `StreamError`;新增 `formatClickError(e)`(429 带 retryAfter→分钟、413/404 兜底文案);catch 分支改 `error.value = formatClickError(e)` |
+| `src/views/ventAI/manageAssistent/api/client.ts` | M2:`throwStreamHttpError` 加 `export`(唯一硬阻塞点,函数体零改动)。M3:Api 枚举加 `me = '/ventAI/api/me'`;新增 `MeInfo` 接口与 `getMe()`(`errorMessageMode: 'none'`) |
+| `src/views/ventAI/manageAssistent/components/AiAssistantModal.vue` | M3:import `getMe`;新增 `isAdmin` ref(默认 false);新增 `fetchMe()`(失败置 false 不缓存);visible watch 的 fetch 组加 `fetchMe()`;`TaskListPanel`/`ChatInputArea` 各透传 `:isAdmin="isAdmin"` |
+| `src/views/ventAI/manageAssistent/components/panels/TaskListPanel.vue` | M3:props 加 `isAdmin?: boolean`;`optionItems` 由普通数组转 computed,按 `!isAdmin` 过滤 `skill`/`sub-agents` |
+| `src/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue` | M3:props 加 `isAdmin?: boolean`(默认 false);`ThinkLevelPopup` **连同前面的 `.action-divider`** 包 `v-if="isAdmin"`。未改 `ThinkLevelPopup` 组件内部 |
+| `src/components/Modal/src/index.less` | R4-4:删除与 `antCss.less` 重复的 `.zxm-modal-close-x` 96px 规则块;保留本块独有的 `font-weight/outline` |
+| `src/views/ventAI/manageAssistent/lib/markdown.ts` | R4-2:`extractMarkdownLinks` 加 `MD_LINK_CACHE_MAX=200` FIFO LRU(与 `renderMarkdownCached` 同型) |
+| `src/views/ventAI/manageAssistent/components/chat/ConversationNav.vue` | R4-5:新增 `getWrappers()` 缓存(失效源=`qaPairs.length` + `props.messages.length` + O(1) `container.contains` 存活校验),`updateActivePairByScroll`/`scrollToPair` 改用它,rAF 合并不变 |
+| `src/utils/markdownSafe.ts` | R4-7(顺手):头部注释的旧路径 `chatModal/utils.ts` 更正为 `lib/markdown.ts`(纯注释) |
+| `src/views/ventAI/manageAssistent/hooks/useChatScroll.ts` | R4-8(顺手):`onBeforeUnmount` 时 `cancelAnimationFrame(scrollRafId)` |
+
+**冻结面零触碰**:`permissionGuard.ts` / `useAutoLogin.ts` / mock-login 相关、`.env`、`public/js/config.js` 均未改(`git diff 46df977e..HEAD --name-only` 已核,无命中)。
+
+## ④ 逐项说明
+
+### M1 点选解读 SSE 补 `X-Access-Token`(重放自我方 fix/base-agent-token M1)
+
+- **原来的行为**:`dataPicker/api.ts` 的 `ssePost` 只带 `Content-Type`,是基座唯一一条匿名调 TfAgents 的路径;消耗记到兜底账号 click-anonymous,无法按登录人记账。
+- **现在的行为**:headers 增 `'X-Access-Token': getToken()`,与对话链路(`client.ts:217/266/317`)同名同源。
+- **为什么这么改**:恢复按登录人记账归因(TfAgents 决策 D5 / B1-20)。
+- **与原来的差别**:仅多一个请求头。**令牌是「可选」语义:空令牌照发请求**(TfAgents 有 click-anonymous 兜底),**未加任何「未登录拦截/跳登录」判断**——这是用户已定的 D5 决策,若改成强制登录会把未登录场景直接变成不可用。
+- 注意:新 master 已把 SSE 读流收敛为 `consumeSse`(`api/sse.ts`),本项只补头,**没有**把旧分支的手写 SSE 循环带回来。
+
+### M2 复用 `throwStreamHttpError`/`StreamError`,429 变可读文案(重放 M2)
+
+- **原来的行为**:`!response.ok` 时裸 `throw new Error('HTTP错误: 429')`;`useDataPicker.ts` 一行 `e?.message || '请求失败'`,用户看不懂 429/413/404。
+- **现在的行为**:
+  1. `client.ts:70` 的 `throwStreamHttpError` 加 `export`(新结构导致的**唯一硬阻塞点**,函数体零改动;`StreamError` 本就导出);
+  2. `dataPicker/api.ts` 改 `await throwStreamHttpError(response)`——解析响应体 `detail`(字符串或 `{code,message,retry_after,limit_tokens,spent_tokens}`),抛出带 `status/retryAfter` 的 `StreamError`;
+  3. `useDataPicker.ts` 新增 `formatClickError(e)`:429 带 `retryAfter` 换算分钟(不足 1 分钟显示「不到 1 分钟」);413 服务端无文案时兜底「附件体积超过上限…」;404 兜底「会话不存在或无权访问」;普通 Error 一律走原 fallback,行为不回归。
+- **为什么这么改**:TfAgents 已返回结构化错误体(D6/B1-15/B1-19/B1-08),且与对话链路共用一套解析器,避免两份实现各自漂移。
+- **与原来的差别**:错误展示从 `HTTP错误: xxx` 变为可读文案;其余链路(consumeSse 流处理、成功路径)不变。
+
+### M3 `/ventAI/api/model/me` + `getMe()` + 按 `is_admin` 隐藏全局变更入口(重放 M3;路径经 2026-09-30 实测校正,见 §⑩)
+
+- **原来的行为**:所有角色都能看到并点击「技能」「子智能体」管理入口与「思考级别」切换;非管理员点了才被后端 403。
+- **现在的行为**:
+  1. `client.ts`:Api 枚举加 `me`;新增 `MeInfo` + `getMe()`(走 defHttp,axios 拦截器自动带令牌;`errorMessageMode:'none'` 静默失败,防接口未上线时每次开弹框都弹红错)。
+  2. `AiAssistantModal.vue`:新增 `isAdmin` ref(默认 false)与 `fetchMe()`;visible watch(`immediate: true`)的 fetch 组每次打开都调 `fetchMe()`;向 `TaskListPanel`、`ChatInputArea` 透传 `:isAdmin`。
+  3. `TaskListPanel.vue`:`optionItems` 转 computed,`!isAdmin` 时过滤 `skill`/`sub-agents`;「新建任务/定时任务」保持开放(属个人功能)。
+  4. `ChatInputArea.vue`:`ThinkLevelPopup` **连同前面的 `.action-divider`** 一起包 `v-if="isAdmin"`(不连分隔线会残留竖线);思考级别的**读取**(getModelMsg)不受影响,对所有人可用。
+- **为什么这么改**:TfAgents 已把这三类全局变更端点收归管理员(用户定案方案 B:后端返回 is_admin,前端隐藏入口)。
+- **与原来的差别**:仅入口可见性收敛;**`fetchMe` 失败置 `isAdmin=false` 且不缓存**(每次打开重新拉取,管理员名单可能在服务端变更)——这是历史浏览器验收踩过的坑(失败停留 false 导致管理员看不到入口),已原样保留。**未改 `ThinkLevelPopup` 组件内部**(可能被别处复用)。旧分支的 `adminConfigured` ref 在模板中从未消费(死状态),本次未搬运。
+
+### R4-4 关闭按钮规则去重
+
+- **原来的行为**:`Modal/src/index.less:77-87` 与 `design/vent/antCss.less:201-209` 双份定义同一条 `.zxm-modal-close-x { 96px }` 规则(f4a47a3c 为修生产错位写了两遍),加载顺序不同会互相覆盖。
+- **现在的行为**:删组件层重复块,保留 `antCss.less` 一份(其注释说明了用后代选择器提升优先级的意图);`index.less` 该块独有的 `font-weight: normal; outline: none;` 保留。
+- **为什么这么改**:单一事实源,避免将来改一处漏一处。
+- **与原来的差别**:编译产物 CSS 少一份同规则;选择器与值不变,渲染行为等价(less 编译实测见 §⑥)。
+
+### R4-2 `extractMarkdownLinks` 加 LRU
+
+- **原来的行为**:每次调用都 `marked.lexer` 全量解析;流式期间胶囊 computed 每个 token 全量重扫所有 AI 消息,CPU 随会话长度线性放大。
+- **现在的行为**:加 `MD_LINK_CACHE_MAX=200` 的 FIFO Map(与 `renderMarkdownCached` 同型),以原文为 key;唯一消费方 `lib/capsule.ts:61` 只读遍历,返回数组被缓存共享安全。
+- **为什么这么改 / 差别**:流式期每 token 的链接提取从 O(全部 AI 消息全文) 降为 O(1)(未变化消息全部命中);缓存有上限,内存代价 ≤ 几十 KB。
+
+### R4-5 滚动跟随 wrappers 缓存
+
+- **原来的行为**:`updateActivePairByScroll` 在滚动 rAF 回调里每帧 `querySelectorAll('.message-wrapper')` 全量查询(>100 条时每帧 O(n))。
+- **现在的行为**:查询结果缓存为数组,失效源=**「消息对数量 + 消息条数」**(`watch([qaPairs.length, props.messages.length])`);另加 O(1) 存活校验(`container.contains` 首个 wrapper),防 DOM 整批替换(如切换任务同问答对数)后用到游离节点。`scrollToPair` 的点击路径也复用。rAF 合并不变。
+- **与原来的差别**:滚动期每帧省一次 O(n) DOM 查询;判定逻辑与选中行为不变。
+- **复核后修正(FR-1)**:初版只 watch `qaPairs.length`,而它只随 **user** 消息数变化——删除 AI 消息(`ChatMessages.vue` 的 delete 按钮)不会改它,缓存会陈旧(外观级、可自愈)。已把 `props.messages.length` 并入失效源并同步注释。
+
+### R4-7 / R4-8(顺手项)
+
+- R4-7:`utils/markdownSafe.ts` 头部注释引用的 `chatModal/utils.ts` 已不存在(重构迁至 `lib/markdown.ts`),纯注释更正,无行为变化。
+- R4-8:`useChatScroll` 的 `scrollRafId` 原仅在重入时 cancel 自身,卸载后不 cancel(残留回调全 optional chaining,无崩溃);现 `onBeforeUnmount` 对称 cancel。该 composable 仅在 `AiAssistantModal` setup 内调用,钩子等价。
+
+## ⑤ 验收证据
+
+- **静态解析/ Lint**:全部通过,见 §⑥。
+- **less 编译行为等价实测**:对改动后的 `Modal/src/index.less` 用仓库 `less@4.7.0` 编译(缺失的全局变量按 `generateModifyVars` 语义打桩),输出 CSS 中 `.zxm-modal-close-x` 规则已消失、`font-weight: normal` 保留 → 删除的是与 antCss.less 完全相同的选择器+值,渲染等价。
+- **引用完整性自查**:`getToken`(`/@/utils/auth`)、`throwStreamHttpError`/`StreamError`/`getMe`(`/@/views/ventAI/manageAssistent/api/client`)均为真实导出;`AiAssistantModal → TaskListPanel/ChatInputArea` 的 `:isAdmin` 透传与两侧 props 声明一一对应;`formatClickError` 在 `handleClick` 之前定义(函数声明提升亦成立)。
+- **未验证项(如实)**:
+  - **未跑 `vite build`**(NAS 16G 悬崖边,硬性禁止;由 Lead 统一走远程构建)。
+  - **未浏览器实测**:M1 令牌头实际到达 TfAgents、M2 的 429/413/404 真实文案、M3 管理员/非管理员入口显隐、R4-5 滚动跟随手感——均为静态推演 + 旧分支既有验收结论外推(旧分支 M1/M2/M3 曾浏览器验收通过)。
+  - `extractMarkdownLinks` 缓存命中收益未跑 profiler 量化(结构推导可靠)。
+
+## ⑥ 静态检查
+
+在 `.wt/mky-r4` 执行(node_modules 为指向主检出的只读软链):
+
+```bash
+# 1) .ts 解析(仓库自带 @babel/parser@7.29.7,经由 .pnpm 路径 createRequire)
+node /tmp/parse-ts.mjs <changed .ts files>
+# 结果:6 个 .ts 全部 OK(exit 0)
+
+# 2) .vue 解析(仓库自带 @vue/compiler-sfc@3.5.40 的 parse + compileScript)
+node /tmp/parse-vue.mjs AiAssistantModal.vue TaskListPanel.vue ChatInputArea.vue ConversationNav.vue
+# 结果:4 个 .vue 全部 OK(exit 0)
+
+# 3) ESLint(eslint@8.57.1,仓库 .eslintrc.js)
+node_modules/.bin/eslint --ext .ts,.vue <全部 10 个改动文件>
+# 结果:exit 0,0 error / 0 warning(-f json 复核:10 文件 messages=0)
+
+# 4) less 编译抽查(R4-4 行为等价)
+less.render(index.less, modifyVars 打桩)  # 输出无 modal-close-x、保留 font-weight → OK
+```
+
+- 既有问题说明:本次 eslint 目标文件零告警,**未发现需要标注的既有问题**;`--ext` 参数与直接文件列表混用时 eslint 仅处理匹配文件,已用 `-f json` 复核确认真处理了目标文件。
+- 未跑 `vite build`(任务书明令禁止,8092 构建属 Lead 收口步骤)。
+
+## ⑦ 已知取舍
+
+1. **M3 未搬运 `adminConfigured`**:旧分支该 ref 在模板中从未消费(死状态),按最小改动原则未重放。
+2. **R4-5 存活校验**:除 `qaPairs.length` 失效外,额外保留 O(1) 的 `container.contains` 校验,覆盖「切换任务后问答对数恰好相同」的边角——比审计建议略保守,无行为风险。
+3. **R4-2 只做了 `extractMarkdownLinks` LRU**:胶囊 computed 每 token 全量重扫 messages 的**外层循环**仍在(收益主要在免除最重的 lexer);按消息对象缓存 links 的更大改动未做(改动面与回归面变大,收益递减)。
+4. **未做项(仅记录,不属本批)**:R4-3(ThinkingCard 每卡 1s interval 空转)、R4-6(TaskListPanel `:key="index"`)——审计定级 🟢 且明确"非本轮施工/可延后"。
+5. **未推送/未部署**:按任务书,push / deliver / 部署由 Lead 复核后统一执行。
+
+## ⑧ 交付状态与上游同步
+
+- **分支**:`fix/audit-r4-20260929`(worktree `/root/VentAnaly60/.wt/mky-r4`),基线 `upstream/master = 46df977e`,无分叉冲突项。
+- **提交**:7 个功能提交 + 1 个 `[Docs]` 提交(清单见 §②,hash 以 `git log` 为准)。
+- **上游同步**:分支自 `upstream/master` 新起(`46df977e`);**2026-09-30 已 merge 更新后的 `origin/master`**(+5 提交,含 API 按域拆分重构、dxf 支持、文件预览修复、导航条缓存),冲突与复验见 §⑨。
+- **推送/部署**:未 push、未 deliver、未部署(任务书 §〇.2,由 Lead 收口)。
+- **对照页**:`http://39.97.59.228:8013/hrx/mky-vent-base/compare/master...fix/audit-r4-20260929`(推送后由 Lead 核验 HTTP 200)。
+
+## ⑨ 合并新 master(2026-09-30)与复验
+
+审计分支交付后,上游 `origin/master` 又前进 5 个提交(`83eb0a1e` 来源卡片默认折叠、`e7e89214` 导航条逻辑+警告修复、
+`6de78411` API 按域拆分重构、`5f41a3be` dxf 支持、`64cfbb1a` 文件预览修复)。本次将 `origin/master` 合入分支,
+逐项核对「审计修改在新结构上是否仍然成立」。
+
+**取值原则**:AI 助手相关代码冲突时**以审计分支为主**(M1/M2/M3/R4-* 的审计语义一律保留);
+上游若已删除/重构掉载体文件,则把审计逻辑**平移**到新结构(见下表 `api/client.ts` 一行),不改审计行为。
+核对方式:对每个审计改动文件比对 `git grep -c "审计-第四轮"` 在审计分支与合并结果上的计数,10 个文件全部一致。
+
+### 冲突解决(3 处)
+
+| 文件 | 冲突 | 解决 |
+|---|---|---|
+| `api/client.ts` | 上游删除(拆分成 `api/*.ts`)vs 本分支修改(M2 加 export + M3 `getMe`) | 接受删除。M2 的「加 export」在新结构中**天然满足**(`throwStreamHttpError`/`StreamError` 已是 `api/errors.ts` 的导出);M3 的 `MeInfo`/`getMe()` 迁到新建 `api/user.ts`(按 barrel 约定 `export * from './user'`) |
+| `components/AiAssistantModal.vue` | 双方改同一 import 行 | 取上游的 barrel 路径并补 `getMe`:`import { …, StreamError, getMe } from '.../api'` |
+| `hooks/useChatScroll.ts` | 双方各加了 `onBeforeUnmount` 取消 rAF(R4-8 与上游重构撞车) | 两边等价(上游为 destroyOnClose 语义,本分支为 R4-8),按**冲突以审计分支为主**取审计版本(`if (scrollRafId !== null)` + R4-8 注释) |
+
+### 其他适配(非冲突但必须改)
+
+- `dataPicker/api.ts`、`dataPicker/useDataPicker.ts`:`throwStreamHttpError`/`StreamError` 的 import 由已删除的
+  `api/client` 改为 `api/errors`;注释中的旧路径一并更正(与 R4-7 同类)。
+- 自动合并文件复核结论:`ConversationNav.vue`(R4-5 的 `getWrappers` 缓存与上游 `toPlainTextCached` 共存,
+  失效源仍为「qaPairs.length + props.messages.length」FR-1 收口版)、`ChatInputArea.vue`/`TaskListPanel.vue`
+  (M3 的 `isAdmin` 过滤/隐藏完整)、`lib/markdown.ts`(R4-2 LRU 完整)。
+
+### 复验证据
+
+- **ESLint**:11 个审计改动文件,关闭 `prettier/prettier` 后 **0 error / 0 warning**。
+  (prettier 报的 3422 处 `Delete ␍` 是 Windows 检出 CRLF 环境噪声——对照组:本次未触碰的 `api/chat.ts` 同样报 161 处。)
+- **TypeScript**:`.tmp-tsconfig.json`(scoped:`ventAI/**` + `dataPicker/**` + `utils/markdownSafe.ts`)编译,
+  **ventAI 相关 0 error**;其余 61 处均为既有问题(`store/`、`router/`、`utils/http/` 等,与本次无关)。
+- **`vite build`**:exit 0(全量生产构建)。
+- **产物核对**:`/ventAI/api/me`(M3)、`X-Access-Token`(M1,`useDataPicker` chunk)、
+  `formatClickError` 的 429/413/404 分支文案(M2)、`ConversationNav` chunk(R4-5)均出现在 dist;
+  R4-4 在编译后 CSS 中 `.zxm-modal-close-x{…width:96px…}` **仅剩 1 处**(`antCss.less` 一份,组件层重复已消失)。
+- **未做(如实)**:浏览器实测与连后端联调(无 TfAgents 环境);M1 令牌头到达后端、M2 真实 429/413/404
+  文案、M3 管理员/非管理员入口显隐仍待浏览器验收。
+
+## ⑩ M3 接口路径校正与浏览器实测(2026-09-30)
+
+在 dev(`:3100`)+ 部署后端上做了浏览器实测,发现 **M3 的 me 路径与部署不一致,已修正**:
+
+- **原**:`api/user.ts` 用 `/ventAI/api/me`(审计文档 §④ 当时写的契约是 "GET /api/me")。
+- **实测**:dev 代理会剥掉 `/ventAI` 前缀,实际请求 `/api/me` —— 部署后端返回 **404**;
+  该后端 OpenAPI 中实际提供的是 **`/api/model/me`**(描述与 `MeInfo` 契约一致:
+  `{"username": str, "is_admin": bool, "admin_configured": bool}`),带令牌请求 **200**。
+- **修正**:`me = '/ventAI/api/model/me'`,`getMe()` 注释同步更新(补 admin_configured 语义)。
+
+**实测结果(当前部署)**:
+
+- `/api/model/me` → `{"username":"admin","is_admin":false,"admin_configured":false}`(管理员账号也是 false);
+- 管理端点 `POST /api/subagents/{name}/toggle` → **403 `admin_not_configured`**:
+  "管理员未配置(TFAGENTS_ADMIN_USERNAMES 为空),全局变更端点对所有人拒绝;请运维在 .env 配置
+  TFAGENTS_ADMIN_USERNAMES 后重启"。
+
+即:**当前部署下前端隐藏入口与后端"对所有人拒绝"是一致的**,不是前端过严;要让管理员看到
+「技能 / 子智能体 / 思考级别」入口,需 TfAgents 侧配置 `TFAGENTS_ADMIN_USERNAMES` 并重启。
+
+**其余实测(隐藏分支)**:左侧仅「新建任务 / 定时任务」、输入区无思考级别控件、打开弹框无红色报错
+(`errorMessageMode:'none'` 生效);对话 SSE 流式、链接胶囊(R4-2)、导航条点击跳转与滚动跟随(R4-5)、
+关闭弹框(R4-4/R4-8)均已在浏览器中跑通。M1/M2 的点选解读因该环境主风机页无设备数据、无行可点,
+UI 触发未能实测(运行模块经 dev server 取源码确认已含 M1/M2 改动)。

+ 1 - 0
docs/ai-changes/README.md

@@ -41,3 +41,4 @@ docs/ai-changes/<YYYYMMDD>-<简短主题>.md
 | 日期 | 文件 | 分支 | 主题 |
 | 日期 | 文件 | 分支 | 主题 |
 |---|---|---|---|
 |---|---|---|---|
 | 2026-09-15 | `20260915-mky-vent-base-agent-token.md` | `fix/base-agent-token` | M1 点选补令牌 / M2 结构化错误 / M3 管理入口按 `is_admin` 收敛 |
 | 2026-09-15 | `20260915-mky-vent-base-agent-token.md` | `fix/base-agent-token` | M1 点选补令牌 / M2 结构化错误 / M3 管理入口按 `is_admin` 收敛 |
+| 2026-09-29 | `20260929-audit-r4.md` | `fix/audit-r4-20260929` | 第四轮审计整改:M1/M2/M3 重放 + R4-2/R4-4/R4-5 + R4-7/R4-8 顺手项(2026-09-30 追记 §⑨:合并新 master 的冲突解决与复验) |

+ 3 - 0
pnpm-workspace.yaml

@@ -1,3 +1,6 @@
+packages:
+  - '.'
+
 allowBuilds:
 allowBuilds:
   '@swc/core': true
   '@swc/core': true
   '@vue-office/docx': true
   '@vue-office/docx': true

BIN
src/assets/images/fire.png


+ 7 - 0
src/assets/images/warning.svg

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="116.151" height="98.266" viewBox="0 0 116.151 98.266">
+  <g id="组_16762" data-name="组 16762" transform="translate(-1300.513 -641.814)">
+    <path id="路径_58007" data-name="路径 58007" d="M87.69,80.753H5.16a5.132,5.132,0,0,1-4.475-2.54,4.977,4.977,0,0,1,.05-5.088C3.262,68.62,39.74,6.393,42.02,2.588a5.151,5.151,0,0,1,8.932,0c3.114,5.29,39.387,67.271,41.19,70.379a5.005,5.005,0,0,1,.139,5.206,5.161,5.161,0,0,1-4.592,2.58Z" transform="translate(1312.108 652.496)" fill="none" stroke="#fe3430" stroke-width="1" opacity="0.3"/>
+    <path id="路径_58006" data-name="路径 58006" d="M76.989,71.967H4.532a4.493,4.493,0,0,1-3.885-6.8C2.865,61.154,34.891,5.7,36.894,2.307a4.489,4.489,0,0,1,7.842,0C47.469,7.021,79.316,62.259,80.9,65.028a4.518,4.518,0,0,1-3.909,6.939ZM40.826,21.607a4.8,4.8,0,0,0-4.8,4.8l2.4,21.583h4.8l2.4-21.583a4.8,4.8,0,0,0-4.8-4.8Zm0,31.175a4.8,4.8,0,1,0,4.8,4.8A4.8,4.8,0,0,0,40.826,52.782Z" transform="translate(1317.989 657.996)" fill="#fe332f"/>
+    <path id="路径_58008" data-name="路径 58008" d="M107.549,96.268H6.326A6.333,6.333,0,0,1,.838,93.241,5.791,5.791,0,0,1,.9,87.175C4,81.8,48.738,7.62,51.536,3.084a6.41,6.41,0,0,1,10.955,0c3.819,6.307,48.308,80.2,50.519,83.9a5.825,5.825,0,0,1,.171,6.206,6.37,6.37,0,0,1-5.632,3.076Z" transform="translate(1301.583 642.811)" fill="none" stroke="#fe3430" stroke-width="2" opacity="0.7"/>
+  </g>
+</svg>

+ 6 - 8
src/components/Modal/src/index.less

@@ -75,14 +75,12 @@
   &-close {
   &-close {
     font-weight: normal;
     font-weight: normal;
     outline: none;
     outline: none;
-  }
-
-  &-close-x {
-    display: inline-block;
-    width: 96px;
-    /*    width: auto;*/
-    height: 56px;
-    line-height: 56px;
+    // [审计-第四轮 R4-4] 重复的关闭按钮宽度规则已删除(原 79-84 行的 .zxm-modal-close-x 96px 规则)。
+    // 原行为:同一条「关闭按钮宽 96px」规则在本文件与 src/design/vent/antCss.less 双份定义,
+    //         值相同但两处维护,加载顺序不同还会互相覆盖(f4a47a3c 正是为此修过生产错位)。
+    // 现行为:只保留 antCss.less 一份(其注释说明了用后代选择器提升优先级的意图),此处删除。
+    // 为什么:单一事实源,避免将来改一处漏一处;font-weight/outline 为本块独有属性,予以保留。
+    //         删除后选择器与值均不变(两处原本完全相同),渲染行为等价。
   }
   }
 
 
   &-confirm-body {
   &-confirm-body {

+ 16 - 2
src/components/vent/camera/LivePlayerWrapper.vue

@@ -1,5 +1,5 @@
 <template>
 <template>
-  <div ref="videoEl" style="width: 100%; height: 100%">
+  <div ref="videoEl" class="live-player-wrapper" style="width: 100%; height: 100%">
     <live-player ref="player" :video-url="src" live stretch autoplay muted controls style="width: 100%; height: 100%" @error="handleError" />
     <live-player ref="player" :video-url="src" live stretch autoplay muted controls style="width: 100%; height: 100%" @error="handleError" />
   </div>
   </div>
 </template>
 </template>
@@ -57,5 +57,19 @@
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>
-  /* 添加scoped样式避免影响其他组件 */
+  /* 断流重连时避免 video.js 黑色 poster/modal 突然压暗画面 */
+  .live-player-wrapper :deep(.vjs-modal-dialog),
+  .live-player-wrapper :deep(.vjs-loading-spinner),
+  .live-player-wrapper :deep(.vjs-big-play-button),
+  .live-player-wrapper :deep(.vjs-poster) {
+    display: none !important;
+    opacity: 0 !important;
+    visibility: hidden !important;
+    pointer-events: none !important;
+  }
+
+  .live-player-wrapper :deep(video),
+  .live-player-wrapper :deep(.video-js) {
+    opacity: 1 !important;
+  }
 </style>
 </style>

+ 9 - 0
src/design/vent/antCss.less

@@ -198,6 +198,15 @@ tr.@{ventSpace}-table-expanded-row:hover > td {
 }
 }
 .@{ventSpace}-modal-close {
 .@{ventSpace}-modal-close {
   color: #ffffff !important;
   color: #ffffff !important;
+
+  // 关闭按钮需容纳“全屏+关闭”两个图标,宽于 antd 默认 56px;
+  // 用后代选择器提升优先级,避免生产构建中与 antd 规则加载顺序不同导致宽度被覆盖、按钮溢出弹框
+  .@{ventSpace}-modal-close-x {
+    display: inline-block;
+    width: 96px;
+    height: 56px;
+    line-height: 56px;
+  }
 }
 }
 .@{ventSpace}-modal-footer {
 .@{ventSpace}-modal-footer {
   border-color: #ffffff22 !important;
   border-color: #ffffff22 !important;

+ 44 - 0
src/design/vent/comment.less

@@ -237,6 +237,27 @@
     white-space: nowrap;
     white-space: nowrap;
     text-overflow: ellipsis;
     text-overflow: ellipsis;
   }
   }
+
+  // 避免断流/缓冲/重连时播放器遮罩突然压暗画面
+  .xgplayer-enter,
+  .xgplayer-loading,
+  .xgplayer-start,
+  .xgplayer-error,
+  .xgplayer-poster,
+  .xgplayer-replay,
+  .vjs-modal-dialog,
+  .vjs-loading-spinner,
+  .vjs-big-play-button,
+  .vjs-poster {
+    display: none !important;
+    opacity: 0 !important;
+    visibility: hidden !important;
+    pointer-events: none !important;
+  }
+
+  video {
+    opacity: 1 !important;
+  }
 }
 }
 
 
 #LivePlayerBox {
 #LivePlayerBox {
@@ -263,6 +284,29 @@
     //     background-size: cover;
     //     background-size: cover;
     //   }
     //   }
   }
   }
+
+  // 避免断流/缓冲/重连时播放器遮罩突然压暗画面
+  .xgplayer-enter,
+  .xgplayer-loading,
+  .xgplayer-start,
+  .xgplayer-error,
+  .xgplayer-poster,
+  .xgplayer-replay,
+  .vjs-modal-dialog,
+  .vjs-loading-spinner,
+  .vjs-big-play-button,
+  .vjs-poster {
+    display: none !important;
+    opacity: 0 !important;
+    visibility: hidden !important;
+    pointer-events: none !important;
+  }
+
+  video,
+  .xgplayer,
+  .video-js {
+    opacity: 1 !important;
+  }
 }
 }
 
 
 @keyframes breathe {
 @keyframes breathe {

+ 13 - 13
src/hooks/system/useCamera.ts

@@ -128,7 +128,7 @@ export function useCamera() {
                   videoDom.volume = 0;
                   videoDom.volume = 0;
                   videoDom.setAttribute('class', 'rtspVideo');
                   videoDom.setAttribute('class', 'rtspVideo');
                   videoDom.setAttribute('muted', 'muted');
                   videoDom.setAttribute('muted', 'muted');
-                  videoDom.setAttribute('poster', '/src/assets/images/vent/noSinge.png');
+                  // 不设置 poster,避免断流重连时暗色封面闪现压暗画面
                   videoDom.autoplay = true;
                   videoDom.autoplay = true;
 
 
                   try {
                   try {
@@ -201,7 +201,7 @@ export function useCamera() {
                 url: videoParent[1].addr,
                 url: videoParent[1].addr,
                 width: 294,
                 width: 294,
                 height: 188,
                 height: 188,
-                poster: '/src/assets/images/vent/noSinge.png',
+                poster: '',
                 plugins: [FlvPlugin],
                 plugins: [FlvPlugin],
                 fluid: true,
                 fluid: true,
                 autoplay: true,
                 autoplay: true,
@@ -209,7 +209,7 @@ export function useCamera() {
                 playsinline: true,
                 playsinline: true,
                 screenShot: true,
                 screenShot: true,
                 whitelist: [''],
                 whitelist: [''],
-                ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate'],
+                ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate', 'poster', 'start', 'enter', 'loading', 'error', 'replay'],
                 closeVideoClick: true,
                 closeVideoClick: true,
                 customConfig: {
                 customConfig: {
                   isClickPlayBack: false,
                   isClickPlayBack: false,
@@ -247,8 +247,8 @@ export function useCamera() {
                   autoplay: true,
                   autoplay: true,
                   autoplayMuted: true,
                   autoplayMuted: true,
                   cors: true,
                   cors: true,
-                  ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate'],
-                  poster: '/src/assets/images/vent/noSinge.png',
+                  ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate', 'poster', 'start', 'enter', 'loading', 'error', 'replay'],
+                  poster: '',
                   defaultPlaybackRate: videoParent[1].cameraRate || 1,
                   defaultPlaybackRate: videoParent[1].cameraRate || 1,
                   controlsList: ['nodownload', 'nofullscreen', 'noremoteplayback'],
                   controlsList: ['nodownload', 'nofullscreen', 'noremoteplayback'],
                   hls: {
                   hls: {
@@ -276,9 +276,9 @@ export function useCamera() {
                   isLive: true,
                   isLive: true,
                   autoplay: true,
                   autoplay: true,
                   autoplayMuted: true,
                   autoplayMuted: true,
-                  ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate'],
+                  ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate', 'poster', 'start', 'enter', 'loading', 'error', 'replay'],
                   plugins: [HlsPlugin], // 第二步
                   plugins: [HlsPlugin], // 第二步
-                  poster: '/src/assets/images/vent/noSinge.png',
+                  poster: '',
                   defaultPlaybackRate: videoParent[1].cameraRate || 1,
                   defaultPlaybackRate: videoParent[1].cameraRate || 1,
                   controlsList: ['nodownload', 'nofullscreen', 'noremoteplayback'],
                   controlsList: ['nodownload', 'nofullscreen', 'noremoteplayback'],
                   hls: {
                   hls: {
@@ -374,7 +374,7 @@ export function useCamera() {
         url: cameraUrl,
         url: cameraUrl,
         width: option.width,
         width: option.width,
         height: option.height,
         height: option.height,
-        poster: '/src/assets/images/vent/noSinge.png',
+        poster: '',
         plugins: [FlvPlugin],
         plugins: [FlvPlugin],
         fluid: true,
         fluid: true,
         autoplay: true,
         autoplay: true,
@@ -382,7 +382,7 @@ export function useCamera() {
         playsinline: true,
         playsinline: true,
         screenShot: true,
         screenShot: true,
         whitelist: [''],
         whitelist: [''],
-        ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate'],
+        ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate', 'poster', 'start', 'enter', 'loading', 'error', 'replay'],
         closeVideoClick: true,
         closeVideoClick: true,
         customConfig: {
         customConfig: {
           isClickPlayBack: false,
           isClickPlayBack: false,
@@ -419,8 +419,8 @@ export function useCamera() {
           autoplay: true,
           autoplay: true,
           autoplayMuted: true,
           autoplayMuted: true,
           cors: true,
           cors: true,
-          ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate'],
-          poster: '/src/assets/images/vent/noSinge.png',
+          ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate', 'poster', 'start', 'enter', 'loading', 'error', 'replay'],
+          poster: '',
           defaultPlaybackRate: cameraRate || 1,
           defaultPlaybackRate: cameraRate || 1,
           controlsList: ['nodownload', 'nofullscreen', 'noremoteplayback'],
           controlsList: ['nodownload', 'nofullscreen', 'noremoteplayback'],
           hls: {
           hls: {
@@ -448,9 +448,9 @@ export function useCamera() {
           isLive: true,
           isLive: true,
           autoplay: true,
           autoplay: true,
           autoplayMuted: true,
           autoplayMuted: true,
-          ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate'],
+          ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate', 'poster', 'start', 'enter', 'loading', 'error', 'replay'],
           plugins: [HlsPlugin], // 第二步
           plugins: [HlsPlugin], // 第二步
-          poster: '/src/assets/images/vent/noSinge.png',
+          poster: '',
           defaultPlaybackRate: cameraRate || 1,
           defaultPlaybackRate: cameraRate || 1,
           controlsList: ['nodownload', 'nofullscreen', 'noremoteplayback'],
           controlsList: ['nodownload', 'nofullscreen', 'noremoteplayback'],
           hls: {
           hls: {

+ 13 - 13
src/hooks/system/useCameraPianation.ts

@@ -130,7 +130,7 @@ export function useCamera() {
                   videoDom.volume = 0;
                   videoDom.volume = 0;
                   videoDom.setAttribute('class', 'rtspVideo');
                   videoDom.setAttribute('class', 'rtspVideo');
                   videoDom.setAttribute('muted', 'muted');
                   videoDom.setAttribute('muted', 'muted');
-                  videoDom.setAttribute('poster', '/src/assets/images/vent/noSinge.png');
+                  // 不设置 poster,避免断流重连时暗色封面闪现压暗画面
                   videoDom.autoplay = true;
                   videoDom.autoplay = true;
 
 
                   try {
                   try {
@@ -203,7 +203,7 @@ export function useCamera() {
                 url: videoParent[1].addr,
                 url: videoParent[1].addr,
                 width: 294,
                 width: 294,
                 height: 188,
                 height: 188,
-                poster: '/src/assets/images/vent/noSinge.png',
+                poster: '',
                 plugins: [FlvPlugin],
                 plugins: [FlvPlugin],
                 fluid: true,
                 fluid: true,
                 autoplay: true,
                 autoplay: true,
@@ -211,7 +211,7 @@ export function useCamera() {
                 playsinline: true,
                 playsinline: true,
                 screenShot: true,
                 screenShot: true,
                 whitelist: [''],
                 whitelist: [''],
-                ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate'],
+                ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate', 'poster', 'start', 'enter', 'loading', 'error', 'replay'],
                 closeVideoClick: true,
                 closeVideoClick: true,
                 customConfig: {
                 customConfig: {
                   isClickPlayBack: false,
                   isClickPlayBack: false,
@@ -249,8 +249,8 @@ export function useCamera() {
                   autoplay: true,
                   autoplay: true,
                   autoplayMuted: true,
                   autoplayMuted: true,
                   cors: true,
                   cors: true,
-                  ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate'],
-                  poster: '/src/assets/images/vent/noSinge.png',
+                  ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate', 'poster', 'start', 'enter', 'loading', 'error', 'replay'],
+                  poster: '',
                   defaultPlaybackRate: videoParent[1].cameraRate || 1,
                   defaultPlaybackRate: videoParent[1].cameraRate || 1,
                   controlsList: ['nodownload', 'nofullscreen', 'noremoteplayback'],
                   controlsList: ['nodownload', 'nofullscreen', 'noremoteplayback'],
                   hls: {
                   hls: {
@@ -278,9 +278,9 @@ export function useCamera() {
                   isLive: true,
                   isLive: true,
                   autoplay: true,
                   autoplay: true,
                   autoplayMuted: true,
                   autoplayMuted: true,
-                  ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate'],
+                  ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate', 'poster', 'start', 'enter', 'loading', 'error', 'replay'],
                   plugins: [HlsPlugin], // 第二步
                   plugins: [HlsPlugin], // 第二步
-                  poster: '/src/assets/images/vent/noSinge.png',
+                  poster: '',
                   defaultPlaybackRate: videoParent[1].cameraRate || 1,
                   defaultPlaybackRate: videoParent[1].cameraRate || 1,
                   controlsList: ['nodownload', 'nofullscreen', 'noremoteplayback'],
                   controlsList: ['nodownload', 'nofullscreen', 'noremoteplayback'],
                   hls: {
                   hls: {
@@ -376,7 +376,7 @@ export function useCamera() {
         url: cameraUrl,
         url: cameraUrl,
         width: option.width,
         width: option.width,
         height: option.height,
         height: option.height,
-        poster: '/src/assets/images/vent/noSinge.png',
+        poster: '',
         plugins: [FlvPlugin],
         plugins: [FlvPlugin],
         fluid: true,
         fluid: true,
         autoplay: true,
         autoplay: true,
@@ -384,7 +384,7 @@ export function useCamera() {
         playsinline: true,
         playsinline: true,
         screenShot: true,
         screenShot: true,
         whitelist: [''],
         whitelist: [''],
-        ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate'],
+        ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate', 'poster', 'start', 'enter', 'loading', 'error', 'replay'],
         closeVideoClick: true,
         closeVideoClick: true,
         customConfig: {
         customConfig: {
           isClickPlayBack: false,
           isClickPlayBack: false,
@@ -421,8 +421,8 @@ export function useCamera() {
           autoplay: true,
           autoplay: true,
           autoplayMuted: true,
           autoplayMuted: true,
           cors: true,
           cors: true,
-          ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate'],
-          poster: '/src/assets/images/vent/noSinge.png',
+          ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate', 'poster', 'start', 'enter', 'loading', 'error', 'replay'],
+          poster: '',
           defaultPlaybackRate: cameraRate || 1,
           defaultPlaybackRate: cameraRate || 1,
           controlsList: ['nodownload', 'nofullscreen', 'noremoteplayback'],
           controlsList: ['nodownload', 'nofullscreen', 'noremoteplayback'],
           hls: {
           hls: {
@@ -450,9 +450,9 @@ export function useCamera() {
           isLive: true,
           isLive: true,
           autoplay: true,
           autoplay: true,
           autoplayMuted: true,
           autoplayMuted: true,
-          ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate'],
+          ignores: ['time', 'progress', 'play', 'i18n', 'volume', 'fullscreen', 'screenShot', 'playbackRate', 'poster', 'start', 'enter', 'loading', 'error', 'replay'],
           plugins: [HlsPlugin], // 第二步
           plugins: [HlsPlugin], // 第二步
-          poster: '/src/assets/images/vent/noSinge.png',
+          poster: '',
           defaultPlaybackRate: cameraRate || 1,
           defaultPlaybackRate: cameraRate || 1,
           controlsList: ['nodownload', 'nofullscreen', 'noremoteplayback'],
           controlsList: ['nodownload', 'nofullscreen', 'noremoteplayback'],
           hls: {
           hls: {

+ 11 - 4
src/layouts/default/index.vue

@@ -13,9 +13,9 @@
       </Layout>
       </Layout>
     </Layout>
     </Layout>
 
 
-    <!-- AI智能助手悬浮按钮(支持拖动,固定右侧) -->
+    <!-- AI智能助手悬浮按钮(支持拖动,固定右侧);页面自备助手界面时不挂载 -->
     <div
     <div
-      v-if="hasPermission('show:ventAI')"
+      v-if="hasPermission('show:ventAI') && !pageProvidesAssistant"
       class="ai-assistant-float"
       class="ai-assistant-float"
       :style="{ right: aiBtnPosition.right, bottom: aiBtnPosition.bottom }"
       :style="{ right: aiBtnPosition.right, bottom: aiBtnPosition.bottom }"
       @mousedown="handleDragStart"
       @mousedown="handleDragStart"
@@ -23,8 +23,13 @@
       <div class="ai-float-icon" @click="openAiAssistant"></div>
       <div class="ai-float-icon" @click="openAiAssistant"></div>
     </div>
     </div>
 
 
-    <!-- AI助手弹窗 -->
-    <AiAssistantModal :visible="aiAssistantVisible" :get-container="getAiModalContainer" @close="aiAssistantVisible = false" />
+    <!-- AI助手弹窗;页面自备助手界面时不再重复挂载,避免同一助手出现两份实例 -->
+    <AiAssistantModal
+      v-if="!pageProvidesAssistant"
+      :visible="aiAssistantVisible"
+      :get-container="getAiModalContainer"
+      @close="aiAssistantVisible = false"
+    />
   </Layout>
   </Layout>
 </template>
 </template>
 
 
@@ -38,6 +43,7 @@
   import LayoutSideBar from './sider/index.vue';
   import LayoutSideBar from './sider/index.vue';
   import LayoutMultipleHeader from './header/MultipleHeader.vue';
   import LayoutMultipleHeader from './header/MultipleHeader.vue';
   import AiAssistantModal from '/@/views/ventAI/manageAssistent/components/AiAssistantModal.vue';
   import AiAssistantModal from '/@/views/ventAI/manageAssistent/components/AiAssistantModal.vue';
+  import { pageProvidesAssistant } from '/@/views/ventAI/manageAssistent/lib/assistantHost';
 
 
   import { useMenuSetting } from '/@/hooks/setting/useMenuSetting';
   import { useMenuSetting } from '/@/hooks/setting/useMenuSetting';
   import { useDesign } from '/@/hooks/web/useDesign';
   import { useDesign } from '/@/hooks/web/useDesign';
@@ -185,6 +191,7 @@
         openAiAssistant,
         openAiAssistant,
         getAiModalContainer,
         getAiModalContainer,
         handleDragStart,
         handleDragStart,
+        pageProvidesAssistant,
       };
       };
     },
     },
   });
   });

+ 4 - 2
src/router/guard/index.ts

@@ -17,7 +17,7 @@ import { createParamMenuGuard } from './paramMenuGuard';
 import { RootRoute } from '/@/router/routes';
 import { RootRoute } from '/@/router/routes';
 import { useGlobSetting } from '/@/hooks/setting';
 import { useGlobSetting } from '/@/hooks/setting';
 import { PageEnum } from '/@/enums/pageEnum';
 import { PageEnum } from '/@/enums/pageEnum';
-import { getSavedHomeRoute } from '/@/utils/homeStyle';
+import { getSavedHomeRoute, resolveRootRedirect } from '/@/utils/homeStyle';
 // import { unmountMicroApps } from '/@/qiankun';
 // import { unmountMicroApps } from '/@/qiankun';
 
 
 // Don't change the order of creation
 // Don't change the order of creation
@@ -34,7 +34,9 @@ export function setupRouterGuard(router: Router) {
 }
 }
 const glob = useGlobSetting();
 const glob = useGlobSetting();
 
 
-RootRoute.redirect = getSavedHomeRoute() || glob.homePath || PageEnum.BASE_HOME;
+// 根路由 redirect 用"函数 + 合并 query"的形式,否则首页路由自带 query 时会覆盖掉入口地址的 query
+// (如 /?mock-login=1、/?auto-login=1&realname=..&workNo=.. 的参数会丢失),详见 resolveRootRedirect 说明
+RootRoute.redirect = (to) => resolveRootRedirect(getSavedHomeRoute() || glob.homePath || PageEnum.BASE_HOME, to.query);
 /**
 /**
  * Hooks for handling page state
  * Hooks for handling page state
  */
  */

+ 57 - 6
src/router/guard/permissionGuard.ts

@@ -1,4 +1,4 @@
-import type { Router, RouteRecordRaw } from 'vue-router';
+import type { Router, RouteRecordRaw, RouteLocationNormalized } from 'vue-router';
 
 
 import { usePermissionStoreWithOut } from '/@/store/modules/permission';
 import { usePermissionStoreWithOut } from '/@/store/modules/permission';
 
 
@@ -14,10 +14,11 @@ import { OAUTH2_THIRD_LOGIN_TENANT_ID } from '/@/enums/cacheEnum';
 
 
 import { useGlobSetting } from '/@/hooks/setting';
 import { useGlobSetting } from '/@/hooks/setting';
 
 
-import { getSavedHomeRoute } from '/@/utils/homeStyle';
+import { getSavedHomeRoute, resolveRootRedirect } from '/@/utils/homeStyle';
 
 
 import { isEmpty, assign } from 'lodash-es';
 import { isEmpty, assign } from 'lodash-es';
 import { MOCK_LOGIN_URL_QUERY, SKIP_SSO_URL_QUERY } from '../constant';
 import { MOCK_LOGIN_URL_QUERY, SKIP_SSO_URL_QUERY } from '../constant';
+import { MOCK_LOGIN_UESRNAME } from '/@/store/constant';
 import { useSso } from '/@/hooks/web/useSso';
 import { useSso } from '/@/hooks/web/useSso';
 import { useAutoLogin } from '/@/hooks/vent/useAutoLogin';
 import { useAutoLogin } from '/@/hooks/vent/useAutoLogin';
 import { addBrowseLog } from '@/router/helper/menuHelper';
 import { addBrowseLog } from '@/router/helper/menuHelper';
@@ -41,12 +42,44 @@ const whitePathList: PageEnum[] = [LOGIN_PATH, OAUTH2_LOGIN_PAGE_PATH, SYS_FILES
 //update-end---author:wangshuai ---date:20220629  for:[issues/I5BG1I]vue3不支持auth2登录------------
 //update-end---author:wangshuai ---date:20220629  for:[issues/I5BG1I]vue3不支持auth2登录------------
 const glob = useGlobSetting();
 const glob = useGlobSetting();
 
 
+/**
+ * 本次导航是否要求免密(mock)登录。
+ *
+ * 判断只认导航对象(to.query 与 to.redirectedFrom.query),**不能**读 window.location.search:
+ * 地址栏要等本次导航真正完成才会更新,而"mock 登录 → next(目标) → 守卫再次执行"属于同一串导航
+ * (next() 出去的目标带 redirectedFrom 指回同一个 to 对象),这段期间地址栏还是旧地址,
+ * 读它会让每次守卫执行都重新命中这里 —— 表现为 /sys/login 与 /sys/user/getUserInfo 反复请求,
+ * 最终被 vue-router 以 "Detected a possibly infinite redirection in a navigation guard" 中止导航。
+ *
+ * redirectedFrom 这一跳用于覆盖"入口地址的 query 被中间重定向覆盖"(见 utils/homeStyle.ts 的说明),
+ * 这种入口参数只能消费一次(mockLoginEntryConsumed),否则同一串导航里会被反复命中。
+ */
+let mockLoginEntryConsumed = false;
+
+function isMockLoginRequest(to: RouteLocationNormalized): boolean {
+  if (to.query && to.query[MOCK_LOGIN_URL_QUERY.key] === MOCK_LOGIN_URL_QUERY.val) return true;
+  if (mockLoginEntryConsumed) return false;
+  return to.redirectedFrom?.query?.[MOCK_LOGIN_URL_QUERY.key] === MOCK_LOGIN_URL_QUERY.val;
+}
+
+/** 把地址栏里的 mock-login 摘掉:避免刷新页面或后续导航(地址栏更新滞后)再次命中免密登录 */
+function clearMockLoginFromUrl(): void {
+  try {
+    const url = new URL(window.location.href);
+    if (!url.searchParams.has(MOCK_LOGIN_URL_QUERY.key)) return;
+    url.searchParams.delete(MOCK_LOGIN_URL_QUERY.key);
+    window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`);
+  } catch (error) {
+    console.warn('[mock-login] 地址栏参数清理失败,不影响登录流程', error);
+  }
+}
+
 export function createPermissionGuard(router: Router) {
 export function createPermissionGuard(router: Router) {
   const userStore = useUserStoreWithOut();
   const userStore = useUserStoreWithOut();
   const permissionStore = usePermissionStoreWithOut();
   const permissionStore = usePermissionStoreWithOut();
   const { doAutoLogin, doTokenLogin, validateRoute, tokenValidateRoute } = useAutoLogin();
   const { doAutoLogin, doTokenLogin, validateRoute, tokenValidateRoute } = useAutoLogin();
   router.beforeEach(async (to, from, next) => {
   router.beforeEach(async (to, from, next) => {
-    RootRoute.redirect = getSavedHomeRoute() || glob.homePath || PageEnum.BASE_HOME;
+    RootRoute.redirect = (rootTo) => resolveRootRedirect(getSavedHomeRoute() || glob.homePath || PageEnum.BASE_HOME, rootTo.query);
     if (to.query['isNoReverse'] != '1') {
     if (to.query['isNoReverse'] != '1') {
       if (VENT_PARAM['is2DModel']) {
       if (VENT_PARAM['is2DModel']) {
         for (const key in to) {
         for (const key in to) {
@@ -91,10 +124,28 @@ export function createPermissionGuard(router: Router) {
       return next({ path: to.fullPath, replace: true, query: to.query });
       return next({ path: to.fullPath, replace: true, query: to.query });
     }
     }
     // 如果指定了需要模拟登录则执行模拟登录,覆盖原有的登录信息
     // 如果指定了需要模拟登录则执行模拟登录,覆盖原有的登录信息
-    if (to.query[MOCK_LOGIN_URL_QUERY.key] === MOCK_LOGIN_URL_QUERY.val) {
-      await userStore.mockLogin({ goHome: false });
+    // 判断看 to.query 与 to.redirectedFrom 链(见 isMockLoginRequest 说明)
+    if (isMockLoginRequest(to)) {
+      // 参数只消费一次:先摘掉,确保随后 next() 产生的导航不会再命中这里;
+      // 同时清理地址栏,避免刷新页面又触发一遍
+      mockLoginEntryConsumed = true;
       delete to.query[MOCK_LOGIN_URL_QUERY.key];
       delete to.query[MOCK_LOGIN_URL_QUERY.key];
-      return next({ path: to.fullPath, replace: true, query: to.query });
+      clearMockLoginFromUrl();
+      try {
+        // 凭据缺失时直接判失败:避免发一次注定被拒的空用户名/密码请求
+        if (!MOCK_LOGIN_UESRNAME) throw new Error('mock 登录凭据未注入(VITE_MOCK_LOGIN_USERNAME_CHAR_CODE)');
+        await userStore.mockLogin({ goHome: false });
+        // 登录接口 200 但没带回 token 时 afterLoginAction 会静默返回,这里显式判失败
+        if (!userStore.getToken) throw new Error('mock 登录未取得 token');
+      } catch (error) {
+        // 失败不能让守卫抛错:守卫 await 抛错会中断导航,main.ts 的 router.isReady() 一直不 resolve → 白屏
+        console.error('[mock-login] 免密登录失败,转正常登录流程', error);
+        // redirect 用清洗过的 query 重新解析:to.fullPath 里还留着 mock-login,登录成功回跳时会再次触发免密登录
+        const cleanFullPath = router.resolve({ path: to.path, hash: to.hash, query: to.query }).fullPath;
+        return next({ path: LOGIN_PATH, replace: true, query: { redirect: cleanFullPath } });
+      }
+      // 用 path + 清洗后的 query 跳转:to.fullPath 里还留着 mock-login,交给 query 覆盖容易看漏
+      return next({ path: to.path, hash: to.hash, replace: true, query: to.query });
     }
     }
 
 
     const token = userStore.getToken;
     const token = userStore.getToken;

+ 3 - 1
src/router/routes/index.ts

@@ -7,6 +7,7 @@ import { LAYOUT } from '/@/router/constant';
 import { PageEnum } from '/@/enums/pageEnum';
 import { PageEnum } from '/@/enums/pageEnum';
 import { t } from '/@/hooks/web/useI18n';
 import { t } from '/@/hooks/web/useI18n';
 import { useGlobSetting, getRemoteSetting } from '/@/hooks/setting';
 import { useGlobSetting, getRemoteSetting } from '/@/hooks/setting';
+import { resolveRootRedirect } from '/@/utils/homeStyle';
 
 
 // const modules = import.meta.glob('./modules/**/*.ts', { eager: true });
 // const modules = import.meta.glob('./modules/**/*.ts', { eager: true });
 
 
@@ -26,7 +27,8 @@ export const RootRoute: AppRouteRecordRaw = await (async () => {
   return {
   return {
     path: '/',
     path: '/',
     name: 'Root',
     name: 'Root',
-    redirect: glob.homePath || PageEnum.BASE_HOME,
+    // 用"函数 + 合并 query"的形式,避免首页路由自带 query 时覆盖入口地址的 query(详见 resolveRootRedirect 说明)
+    redirect: (to) => resolveRootRedirect(glob.homePath || PageEnum.BASE_HOME, to.query),
     meta: {
     meta: {
       title: 'Root',
       title: 'Root',
     },
     },

+ 24 - 0
src/utils/homeStyle.ts

@@ -1,5 +1,7 @@
+import type { LocationQueryRaw } from 'vue-router';
 import { ThemeEnum } from '/@/enums/appEnum';
 import { ThemeEnum } from '/@/enums/appEnum';
 import { HOME_STYLE_KEY } from '/@/enums/cacheEnum';
 import { HOME_STYLE_KEY } from '/@/enums/cacheEnum';
+import { PageEnum } from '/@/enums/pageEnum';
 
 
 /**
 /**
  * 首页风格选择:localStorage 的存放与读取工具。
  * 首页风格选择:localStorage 的存放与读取工具。
@@ -43,3 +45,25 @@ export function saveHomeStyle(option: HomeStyleOption | null): void {
 export function getSavedHomeRoute(): string {
 export function getSavedHomeRoute(): string {
   return getSavedHomeStyle()?.route || '';
   return getSavedHomeStyle()?.route || '';
 }
 }
+
+/**
+ * 构造根路由('/')的 redirect 目标:把"目标路径自带的 query"和"入口地址的 query"合并后返回。
+ *
+ * 为什么不能直接用字符串(如 '/xxx?a=1'):
+ * vue-router 会在所有导航守卫之前解析路由记录上的 redirect(内部 handleRedirectRecord),
+ * 当 redirect 是自身带 '?' 的字符串时,它解析出的 query 会**覆盖**原始地址的 query。
+ * 因此当首页路由(后端 systemHome 或本地保存的首页风格)形如
+ * '/monitorChannel/monitor-firedoor?type=tunMonitor&deviceType=door' 时,从根路径进来的
+ * `/?mock-login=1`、`/?auto-login=1&realname=..&workNo=..`、`/?token=..` 等参数会在守卫里完全看不到。
+ * 返回对象并显式合并 query 即可保住入口参数;同名参数以入口为准。
+ *
+ * @param target 首页路由(可自带 query)
+ * @param query 入口地址的 query(通常是守卫里的 to.query)
+ */
+export function resolveRootRedirect(target: string, query?: LocationQueryRaw): { path: string; query: LocationQueryRaw } {
+  const [path, search = ''] = String(target || '').split('?');
+  return {
+    path: path || PageEnum.BASE_HOME,
+    query: { ...Object.fromEntries(new URLSearchParams(search)), ...(query || {}) },
+  };
+}

+ 5 - 2
src/utils/markdownSafe.ts

@@ -1,7 +1,10 @@
 // [perf-base-v1] 卡6: 公共 Markdown 渲染 + XSS 消毒管线
 // [perf-base-v1] 卡6: 公共 Markdown 渲染 + XSS 消毒管线
-// 实现源自 src/views/ventAI/manageAssistent/components/chatModal/utils.ts 的既有消毒配置
+// [审计-第四轮 R4-7] 注释路径更正(纯注释,无行为变化):原注释引用的
+// src/views/ventAI/manageAssistent/components/chatModal/utils.ts 已在本轮上游重构
+// (46df977e)中迁至 src/views/ventAI/manageAssistent/lib/markdown.ts。
+// 实现源自 lib/markdown.ts 的既有消毒配置
 // (marked 渲染 + DOMPurify.sanitize,ADD_TAGS:['img']),迁移为公共 util;
 // (marked 渲染 + DOMPurify.sanitize,ADD_TAGS:['img']),迁移为公共 util;
-// ventAI chatModal/utils.ts 转调本文件,避免多处实现漂移。
+// ventAI lib/markdown.ts 转调本文件,避免多处实现漂移。
 // 所有 v-html 绑定的 markdown/HTML 内容必须先经过本模块消毒。
 // 所有 v-html 绑定的 markdown/HTML 内容必须先经过本模块消毒。
 import { marked } from 'marked';
 import { marked } from 'marked';
 import DOMPurify from 'dompurify';
 import DOMPurify from 'dompurify';

+ 3 - 3
src/utils/ventutil.ts

@@ -141,7 +141,7 @@ export function deviceCameraInit(cameraAddrs, player: HTMLElement, webRtcServerL
                 videoDom.volume = 0;
                 videoDom.volume = 0;
                 videoDom.setAttribute('class', 'rtspVideo');
                 videoDom.setAttribute('class', 'rtspVideo');
                 videoDom.setAttribute('muted', 'muted');
                 videoDom.setAttribute('muted', 'muted');
-                videoDom.setAttribute('poster', '/src/assets/images/vent/noSinge.png');
+                // 不设置 poster,避免断流重连时暗色封面闪现压暗画面
                 videoDom.autoplay = true;
                 videoDom.autoplay = true;
                 videoParentDom.appendChild(videoDom);
                 videoParentDom.appendChild(videoDom);
                 cameraNameDom = document.createElement('div');
                 cameraNameDom = document.createElement('div');
@@ -218,7 +218,7 @@ export function deviceCameraInit(cameraAddrs, player: HTMLElement, webRtcServerL
               videoUrl: videoParentDom[1].addr,
               videoUrl: videoParentDom[1].addr,
               videoTitle: videoParentDom[1].name,
               videoTitle: videoParentDom[1].name,
               alt: '无信号',
               alt: '无信号',
-              poster: '/src/assets/images/vent/noSinge.png',
+              poster: '',
             }),
             }),
             videoDom
             videoDom
           );
           );
@@ -232,7 +232,7 @@ export function deviceCameraInit(cameraAddrs, player: HTMLElement, webRtcServerL
               videoUrl: videoParentDom[1].addr,
               videoUrl: videoParentDom[1].addr,
               videoTitle: videoParentDom[1].name,
               videoTitle: videoParentDom[1].name,
               alt: '无信号',
               alt: '无信号',
-              poster: '/src/assets/images/vent/noSinge.png',
+              poster: '',
             }),
             }),
             videoDom
             videoDom
           );
           );

+ 177 - 0
src/views/vent/bundleSpy/bundleSpyTable/bundleSpy-table.data.ts

@@ -1299,3 +1299,180 @@ export const Ltcolumns: BasicColumn[] = [
     align: 'center',
     align: 'center',
   },
   },
 ];
 ];
+
+export const Betcolumns: BasicColumn[] = [
+  {
+    title: '序号',
+    width: 60,
+    align: 'center',
+    dataIndex: 'xh',
+    key: 'xh',
+  },
+  {
+    title: '采样地点',
+    dataIndex: 'jcdd',
+    key: 'jcdd',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'CO(%)',
+    dataIndex: 'co_ave',
+    key: 'co_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'CO₂(%)',
+    dataIndex: 'co2_ave',
+    key: 'co2_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'CH₄(%)',
+    dataIndex: 'ch4_ave',
+    key: 'ch4_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'O₂(%)',
+    dataIndex: 'o2_ave',
+    key: 'o2_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'C₂H₆(%)',
+    dataIndex: 'c2h6_ave',
+    key: 'c2h6_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'C₂H₂(%)',
+    dataIndex: 'c2h2_ave',
+    key: 'c2h2_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: '取样分析时间',
+    dataIndex: 'qyfxsj',
+    key: 'qyfxsj',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: '煤自燃阶段',
+    dataIndex: 'internalFireWarnLevel',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: '操作',
+    dataIndex: 'action',
+    width: 100,
+    align: 'center',
+  },
+];
+
+export const Betcolumns2: BasicColumn[] = [
+  {
+    title: '序号',
+    width: 60,
+    align: 'center',
+    dataIndex: 'xh',
+    key: 'xh',
+  },
+  {
+    title: '采样地点',
+    dataIndex: 'jcdd',
+    key: 'jcdd',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: '采空区',
+    dataIndex: 'ckq',
+    key: 'ckq',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'CO(%)',
+    dataIndex: 'co_ave',
+    key: 'co_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'CO₂(%)',
+    dataIndex: 'co2_ave',
+    key: 'co2_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'CH₄(%)',
+    dataIndex: 'ch4_ave',
+    key: 'ch4_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'O₂(%)',
+    dataIndex: 'o2_ave',
+    key: 'o2_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'C₂H₆(%)',
+    dataIndex: 'c2h6_ave',
+    key: 'c2h6_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'C₂H₄(%)',
+    dataIndex: 'c2h4_ave',
+    key: 'c2h4_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: 'C₂H₂(%)',
+    dataIndex: 'c2h2_ave',
+    key: 'c2h2_ave',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: '取样时间',
+    dataIndex: 'qysj',
+    key: 'qysj',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: '分析时间',
+    dataIndex: 'qyfxsj',
+    key: 'qyfxsj',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: '煤自燃阶段',
+    dataIndex: 'internalFireWarnLevel',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: '操作',
+    dataIndex: 'action',
+    width: 100,
+    align: 'center',
+  },
+];

Plik diff jest za duży
+ 93 - 76
src/views/vent/bundleSpy/bundleSpyTable/index.vue


+ 2 - 2
src/views/vent/deviceManager/comment/cameraTabel/camera.data.ts

@@ -1,7 +1,7 @@
 import { BasicColumn } from '/@/components/Table';
 import { BasicColumn } from '/@/components/Table';
 
 
-const MANUFACTURER_HIKVISION = 'rtsp://admin:admin123@192.168.0.64:554/Streaming/Channels/1';
-const MANUFACTURER_DAHUA = 'http://0.0.0.0:8080/live/';
+const MANUFACTURER_HIKVISION = 'haikang';
+const MANUFACTURER_DAHUA = 'dahua';
 
 
 const manufacturerOptions = [
 const manufacturerOptions = [
   {
   {

+ 125 - 123
src/views/vent/deviceManager/comment/warningTabel/ctrlTable.vue

@@ -32,155 +32,157 @@
 </template>
 </template>
 
 
 <script lang="ts" setup>
 <script lang="ts" setup>
-import { ref, onMounted } from 'vue';
-import { ctrlColumns, ctrlFormSchemas } from './warning.data';
-import { getCtrlDevicePointList, addCtrlDevicePoint, editCtrlDevicePoint, getCtrlDevicePointPage, deleteDevicePonit } from './warning.api';
-import BaseModal from './BaseModalCtrl.vue';
-import { useModal } from '/@/components/Modal';
+  import { ref, onMounted } from 'vue';
+  import { ctrlColumns, ctrlFormSchemas } from './warning.data';
+  import { getCtrlDevicePointList, addCtrlDevicePoint, editCtrlDevicePoint, getCtrlDevicePointPage, deleteDevicePonit } from './warning.api';
+  import BaseModal from './BaseModalCtrl.vue';
+  import { useModal } from '/@/components/Modal';
 
 
-const emit = defineEmits(['register']);
+  const emit = defineEmits(['register']);
 
 
-const props = defineProps({
-  deviceId: { type: String },
-  deviceType: { type: String },
-});
+  const props = defineProps({
+    deviceId: { type: String },
+    deviceType: { type: String },
+  });
 
 
-const formSchemas = ctrlFormSchemas;
-const activeID = ref('');
-const dataSource = ref<any[]>([]);
-const ctrlList = ref<any[]>([]);
-const deviceType = ref('');
-const editRecord = ref<any>(null);
-function selectWarning(id) {
-  activeID.value = id;
-  getTableData(id);
-}
-async function getControlDevicePointList() {
-  const param = {
-    pageNo: 1,
-    pageSize: 1000,
-    devicetype: props.deviceType,
-    valuetype: 10,
-  };
-  const res = await getCtrlDevicePointList(param);
-  ctrlList.value = res.records || [];
-}
-const [register, { openModal, closeModal }] = useModal();
+  const formSchemas = ctrlFormSchemas;
+  const activeID = ref('');
+  const dataSource = ref<any[]>([]);
+  const ctrlList = ref<any[]>([]);
+  const deviceType = ref('');
+  const editRecord = ref<any>(null);
+  function selectWarning(id) {
+    activeID.value = id;
+    getTableData(id);
+  }
+  async function getControlDevicePointList() {
+    const param = {
+      pageNo: 1,
+      pageSize: 1000,
+      devicetype: props.deviceType,
+      valuetype: 10,
+    };
+    const res = await getCtrlDevicePointList(param);
+    ctrlList.value = res.records || [];
+  }
+  const [register, { openModal, closeModal }] = useModal();
 
 
-function handleOpen(flag?, record?) {
-  console.log(record, 'record');
-  deviceType.value = props.deviceType;
-  editRecord.value = flag == 'update' ? record : null;
-  if (record) {
-    if (flag == 'update') {
-      openModal(true, {
-        isUpdate: true,
-        record,
-      });
+  function handleOpen(flag?, record?) {
+    console.log(record, 'record');
+    deviceType.value = props.deviceType;
+    editRecord.value = flag == 'update' ? record : null;
+    if (record) {
+      if (flag == 'update') {
+        openModal(true, {
+          isUpdate: true,
+          record,
+        });
+      } else {
+        openModal(true, {
+          isUpdate: false,
+          record,
+        });
+      }
     } else {
     } else {
       openModal(true, {
       openModal(true, {
         isUpdate: false,
         isUpdate: false,
         record,
         record,
       });
       });
     }
     }
-  } else {
-    openModal(true, {
-      isUpdate: false,
-      record,
-    });
   }
   }
-}
 
 
-async function onSubmit(flag, values) {
-  const currentItem = ctrlList.value.find((item) => item.id == activeID.value);
-  if (currentItem) {
-    values.monitorCode = currentItem.valuecode;
-    values.monitorId = currentItem.id;
-    values.monitorName = currentItem.valuename;
-  }
-  if (flag == 'update') {
-    await editCtrlDevicePoint({ id: editRecord.value?.id, ...editRecord.value, ...values });
-  } else {
-    await addCtrlDevicePoint({ ...values });
+  async function onSubmit(flag, values) {
+    const currentItem = ctrlList.value.find((item) => item.id == activeID.value);
+    if (currentItem) {
+      values.monitorCode = currentItem.valuecode;
+      values.monitorId = currentItem.id;
+      values.monitorName = currentItem.valuename;
+    }
+    if (flag == 'update') {
+      await editCtrlDevicePoint({ id: editRecord.value?.id, ...editRecord.value, ...values });
+    } else {
+      await addCtrlDevicePoint({ ...values });
+    }
+    editRecord.value = null;
+    closeModal();
+    await getTableData(activeID.value);
   }
   }
-  editRecord.value = null;
-  closeModal();
-  await getTableData(activeID.value);
-}
 
 
-async function handleDelete(record) {
-  await deleteDevicePonit({ id: record.id });
-  getTableData(activeID.value);
-}
+  async function handleDelete(record) {
+    await deleteDevicePonit({ id: record.id });
+    getTableData(activeID.value);
+  }
 
 
-async function getTableData(id?: string) {
-  const param = {
-    pageNo: 1,
-    pageSize: 1000,
-    monitorId: id,
-    deviceId: props.deviceId,
-  };
-  const res = await getCtrlDevicePointPage(param);
-  dataSource.value = res.records || [];
-}
+  async function getTableData(id?: string) {
+    if (!id) return;
 
 
-onMounted(async () => {
-  await getControlDevicePointList();
-  if (ctrlList.value.length > 0) {
-    activeID.value = ctrlList.value[0].id;
+    const param = {
+      pageNo: 1,
+      pageSize: 1000,
+      monitorId: id,
+      deviceId: props.deviceId,
+    };
+    const res = await getCtrlDevicePointPage(param);
+    dataSource.value = res.records || [];
   }
   }
-  await getTableData(activeID.value);
-});
+
+  onMounted(async () => {
+    await getControlDevicePointList();
+    if (ctrlList.value.length > 0) {
+      activeID.value = ctrlList.value[0].id;
+    }
+    await getTableData(activeID.value);
+  });
 </script>
 </script>
 
 
 <style lang="less" scoped>
 <style lang="less" scoped>
-@ventSpace: zxm;
+  @ventSpace: zxm;
 
 
-.warning-device-box {
-  width: 100%;
-  height: 600px;
-  overflow-y: auto;
-  display: flex;
-  .warning-box {
-    width: 200px;
-    height: 100%;
+  .warning-device-box {
+    width: 100%;
+    height: 600px;
     overflow-y: auto;
     overflow-y: auto;
-    background: #ffffff11;
-    padding: 5px;
-    border-radius: 5px;
+    display: flex;
+    .warning-box {
+      width: 200px;
+      height: 100%;
+      overflow-y: auto;
+      background: #ffffff11;
+      padding: 5px;
+      border-radius: 5px;
 
 
-    .link-item {
-      position: relative;
-      cursor: pointer;
-      line-height: 30px;
-      padding-left: 30px;
-      color: #fff;
-      span:hover {
-        color: #89ffff;
+      .link-item {
+        position: relative;
+        cursor: pointer;
+        line-height: 30px;
+        padding-left: 30px;
+        color: #fff;
+        span:hover {
+          color: #89ffff;
+        }
+        &::after {
+          content: '';
+          position: absolute;
+          display: block;
+          width: 8px;
+          height: 8px;
+          top: 12px;
+          left: 10px;
+          transform: rotateZ(45deg) skew(10deg, 10deg);
+          background: #45d3fd;
+        }
       }
       }
-      &::after {
-        content: '';
-        position: absolute;
-        display: block;
-        width: 8px;
-        height: 8px;
-        top: 12px;
-        left: 10px;
-        transform: rotateZ(45deg) skew(10deg, 10deg);
-        background: #45d3fd;
+      .active-device-title {
+        color: aqua;
       }
       }
     }
     }
-    .active-device-title {
-      color: aqua;
+    .device-box {
+      flex: 1;
+      margin-left: 10px;
     }
     }
-  }
-  .device-box {
-    flex: 1;
-    margin-left: 10px;
-  }
 
 
-  .action-link {
-    color: #00e7ff;
+    .action-link {
+      color: #00e7ff;
+    }
   }
   }
-}
-</style>
+</style>

+ 38 - 7
src/views/vent/home/configurable/components/detail/CustomChart.vue

@@ -82,6 +82,8 @@
         end: e.end,
         end: e.end,
         height: 15,
         height: 15,
         bottom: 15,
         bottom: 15,
+        // 配置 showDetail: false 时隐藏缩放组件内的明细文字
+        showDetail: e.showDetail !== false,
       };
       };
     });
     });
 
 
@@ -531,8 +533,7 @@
             dataZoom: baseDataZoom,
             dataZoom: baseDataZoom,
             type: 'category',
             type: 'category',
             axisLabel: {
             axisLabel: {
-              // color: textStyle.color,
-              color: '#ccfbff',
+              color: textStyle.color,
               interval: 0,
               interval: 0,
               width: baseDataZoom.length ? 100 : 800 / get(baseSeries, '[0].data.length', 1),
               width: baseDataZoom.length ? 100 : 800 / get(baseSeries, '[0].data.length', 1),
               overflow: 'break',
               overflow: 'break',
@@ -548,9 +549,11 @@
                 opacity: i === 0 ? 0.1 : 0,
                 opacity: i === 0 ? 0.1 : 0,
               },
               },
             },
             },
+            nameTextStyle: {
+              color: textStyle.color,
+            },
             axisLabel: {
             axisLabel: {
-              // color: textStyle.color,
-              color: '#ffffffcc',
+              color: textStyle.color,
             },
             },
           };
           };
         }),
         }),
@@ -621,8 +624,7 @@
             ...e,
             ...e,
             type: 'category',
             type: 'category',
             axisLabel: {
             axisLabel: {
-              // color: textStyle.color,
-              color: '#ccfbff',
+              color: textStyle.color,
               width: 100,
               width: 100,
               overflow: 'break',
               overflow: 'break',
             },
             },
@@ -636,8 +638,11 @@
                 opacity: i === 0 ? 0.1 : 0,
                 opacity: i === 0 ? 0.1 : 0,
               },
               },
             },
             },
+            nameTextStyle: {
+              color: textStyle.color,
+            },
             axisLabel: {
             axisLabel: {
-              color: '#ffffffcc',
+              color: textStyle.color,
             },
             },
           };
           };
         }),
         }),
@@ -679,6 +684,7 @@
             ...e,
             ...e,
             type: 'category',
             type: 'category',
             axisLabel: {
             axisLabel: {
+              color: textStyle.color,
               width: 100,
               width: 100,
               overflow: 'break',
               overflow: 'break',
             },
             },
@@ -698,6 +704,10 @@
                 opacity: i === 0 ? 0.1 : 0,
                 opacity: i === 0 ? 0.1 : 0,
               },
               },
             },
             },
+            axisLabel: {
+              ...(e as any).axisLabel,
+              color: textStyle.color,
+            },
           };
           };
         }),
         }),
         series: baseSeries.map((serie) => {
         series: baseSeries.map((serie) => {
@@ -782,6 +792,7 @@
             type: 'category',
             type: 'category',
             boundaryGap: false,
             boundaryGap: false,
             axisLabel: {
             axisLabel: {
+              color: textStyle.color,
               width: 100,
               width: 100,
               overflow: 'break',
               overflow: 'break',
             },
             },
@@ -790,6 +801,12 @@
         yAxis: yAxis.map((e, i) => {
         yAxis: yAxis.map((e, i) => {
           return {
           return {
             ...e,
             ...e,
+            nameTextStyle: {
+              color: textStyle.color,
+            },
+            axisLabel: {
+              color: textStyle.color,
+            },
             splitLine: {
             splitLine: {
               lineStyle: {
               lineStyle: {
                 color: '#fff',
                 color: '#fff',
@@ -946,6 +963,7 @@
             ...e,
             ...e,
             type: 'category',
             type: 'category',
             axisLabel: {
             axisLabel: {
+              color: textStyle.color,
               interval: 0,
               interval: 0,
               width: (domWidth - 100) / get(baseSeries, '[0].data.length', 1),
               width: (domWidth - 100) / get(baseSeries, '[0].data.length', 1),
               overflow: 'break',
               overflow: 'break',
@@ -955,6 +973,12 @@
         yAxis: yAxis.map((e, i) => {
         yAxis: yAxis.map((e, i) => {
           return {
           return {
             ...e,
             ...e,
+            nameTextStyle: {
+              color: textStyle.color,
+            },
+            axisLabel: {
+              color: textStyle.color,
+            },
             splitLine: {
             splitLine: {
               lineStyle: {
               lineStyle: {
                 opacity: i === 0 ? 0.1 : 0,
                 opacity: i === 0 ? 0.1 : 0,
@@ -1049,6 +1073,7 @@
             ...e,
             ...e,
             type: 'category',
             type: 'category',
             axisLabel: {
             axisLabel: {
+              color: textStyle.color,
               interval: 0,
               interval: 0,
               width: (domWidth - 100) / get(baseSeries, '[0].data.length', 1),
               width: (domWidth - 100) / get(baseSeries, '[0].data.length', 1),
               overflow: 'break',
               overflow: 'break',
@@ -1058,6 +1083,12 @@
         yAxis: yAxis.map((e, i) => {
         yAxis: yAxis.map((e, i) => {
           return {
           return {
             ...e,
             ...e,
+            nameTextStyle: {
+              color: textStyle.color,
+            },
+            axisLabel: {
+              color: textStyle.color,
+            },
             splitLine: {
             splitLine: {
               lineStyle: {
               lineStyle: {
                 opacity: i === 0 ? 0.1 : 0,
                 opacity: i === 0 ? 0.1 : 0,

+ 82 - 90
src/views/vent/home/configurable/components/green-right-tag.vue

@@ -1,121 +1,113 @@
 <!-- eslint-disable vue/multi-word-component-names -->
 <!-- eslint-disable vue/multi-word-component-names -->
 <template>
 <template>
   <div class="greenRightTag">
   <div class="greenRightTag">
-    <div class="wind-tag" v-for="(item, index) in tagList">
-      <div class=wind-tag-icon></div>
-      <div class="wind-tag-label">{{ item.label }}</div>
+    <div class="wind-tag" v-for="(item, index) in tagList" :key="index">
+      <div class="wind-tag-icon"></div>
+      <div class="wind-tag-label" :title="item.label">{{ item.label }}</div>
       <div class="wind-tag-val">{{ item.value }}</div>
       <div class="wind-tag-val">{{ item.value }}</div>
     </div>
     </div>
   </div>
   </div>
 </template>
 </template>
 <script lang="ts" setup>
 <script lang="ts" setup>
-import { onMounted, onUnmounted, ref, watch, computed } from 'vue';
-
-let tagList = ref<any[]>([
-  { label: '总回风量(m³/min)', value: '25870' },
-  { label: '总进风量(m³/min)', value: '24989' },
-  { label: '计划风量(m³/min)', value: '348' },
-  { label: '有效风量率', value: '87.3%' },
-
-])
-
+  import { ref } from 'vue';
+
+  const tagList = ref<any[]>([
+    { label: '总回风量(m³/min)', value: '25870' },
+    { label: '总进风量(m³/min)', value: '24989' },
+    { label: '计划风量(m³/min)', value: '348' },
+    { label: '有效风量率', value: '87.3%' },
+  ]);
 </script>
 </script>
 <style lang="less" scoped>
 <style lang="less" scoped>
-@import '/@/design/theme.less';
-
-@font-face {
-  font-family: 'douyuFont';
-  src: url('/@/assets/font/douyuFont.otf');
-}
+  @import '/@/design/theme.less';
 
 
-.greenRightTag {
-  position: relative;
-  width: 100%;
-  height: 100%;
-  display: flex;
-  flex-direction: column;
-  justify-content: space-between;
-  align-items: center;
+  @font-face {
+    font-family: 'douyuFont';
+    src: url('/@/assets/font/douyuFont.otf');
+  }
 
 
-  .wind-tag {
+  .greenRightTag {
     position: relative;
     position: relative;
-    width: 110px;
-    height: 124px;
-    background: url('../../../../../assets//images/home-green/green-wind-tag.png') no-repeat;
-    background-size: 100% 100%;
-
-    &:nth-child(1) {
-      .wind-tag-icon {
-        position: absolute;
-        width: 37px;
-        height: 37px;
-        background: url(/src/assets/images/home-green/green-zhfl.png) no-repeat;
-        left: 50%;
-        top: 16px;
-        transform: translate(-50%, 0);
+    width: 100%;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    align-items: center;
+    box-sizing: border-box;
+    overflow: hidden;
+
+    .wind-tag {
+      position: relative;
+      width: 92px;
+      height: 104px;
+      flex-shrink: 0;
+      background: url('/@/assets/images/home-green/green-wind-tag.png') no-repeat;
+      background-size: 100% 100%;
+      pointer-events: auto;
+
+      &:nth-child(1) {
+        .wind-tag-icon {
+          background: url('/@/assets/images/home-green/green-zhfl.png') no-repeat center;
+          background-size: contain;
+        }
+      }
 
 
+      &:nth-child(2) {
+        .wind-tag-icon {
+          background: url('/@/assets/images/home-green/green-zjfl.png') no-repeat center;
+          background-size: contain;
+        }
       }
       }
-    }
 
 
-    &:nth-child(2) {
-      .wind-tag-icon {
-        position: absolute;
-        width: 37px;
-        height: 37px;
-        background: url(/src/assets/images/home-green/green-zjfl.png) no-repeat;
-        left: 50%;
-        top: 16px;
-        transform: translate(-50%, 0);
+      &:nth-child(3) {
+        .wind-tag-icon {
+          background: url('/@/assets/images/home-green/green-jhfl.png') no-repeat center;
+          background-size: contain;
+        }
+      }
 
 
+      &:nth-child(4) {
+        .wind-tag-icon {
+          background: url('/@/assets/images/home-green/green-yxfl.png') no-repeat center;
+          background-size: contain;
+        }
       }
       }
-    }
 
 
-    &:nth-child(3) {
       .wind-tag-icon {
       .wind-tag-icon {
         position: absolute;
         position: absolute;
-        width: 37px;
-        height: 37px;
-        background: url(/src/assets/images/home-green/green-jhfl.png) no-repeat;
+        width: 30px;
+        height: 30px;
         left: 50%;
         left: 50%;
-        top: 16px;
+        top: 12px;
         transform: translate(-50%, 0);
         transform: translate(-50%, 0);
-
       }
       }
-    }
 
 
-    &:nth-child(4) {
-      .wind-tag-icon {
+      .wind-tag-label {
         position: absolute;
         position: absolute;
-        width: 37px;
-        height: 37px;
-        background: url(/src/assets/images/home-green/green-yxfl.png) no-repeat;
-        left: 50%;
-        top: 16px;
-        transform: translate(-50%, 0);
-
+        left: 0;
+        bottom: 28px;
+        width: 100%;
+        padding: 0 2px;
+        box-sizing: border-box;
+        text-align: center;
+        font-size: 11px;
+        color: #fff;
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
       }
       }
-    }
-
-    .wind-tag-label {
-      position: absolute;
-      left: 0;
-      bottom: 34px;
-      width: 100%;
-      text-align: center;
-      font-size: 12px;
-    }
 
 
-    .wind-tag-val {
-      position: absolute;
-      left: 0;
-      bottom: 5px;
-      width: 100%;
-      text-align: center;
-      font-size: 14px;
-      font-family: 'douyuFont';
-      color: #39f4e0;
+      .wind-tag-val {
+        position: absolute;
+        left: 0;
+        bottom: 4px;
+        width: 100%;
+        text-align: center;
+        font-size: 13px;
+        font-family: 'douyuFont';
+        color: #39f4e0;
+      }
     }
     }
-
   }
   }
-}
 </style>
 </style>

+ 1 - 0
src/views/vent/home/configurable/configurable.data.ts

@@ -780,6 +780,7 @@ export const testConfigVent: Config[] = [
             {
             {
               show: true,
               show: true,
               end: 20,
               end: 20,
+              showDetail: false,
             },
             },
           ],
           ],
           series: [
           series: [

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

@@ -118,6 +118,17 @@
       padding: 5px 0;
       padding: 5px 0;
       box-sizing: border-box;
       box-sizing: border-box;
       overflow-y: auto;
       overflow-y: auto;
+      overflow-x: hidden;
+      // 隐藏滚动条,仍可滚动(开采煤层自燃发火特性等)
+      scrollbar-width: none;
+      -ms-overflow-style: none;
+
+      &::-webkit-scrollbar {
+        width: 0 !important;
+        height: 0 !important;
+        display: none;
+      }
+
       .table__content_list_row {
       .table__content_list_row {
         width: 100%;
         width: 100%;
         display: flex;
         display: flex;

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

@@ -127,10 +127,17 @@ defineEmits(['click']);
 }
 }
 
 
 .mini-board_B {
 .mini-board_B {
-  width: 115px;
+  width: 122px;
+  min-width: 118px;
   height: 50px;
   height: 50px;
+  padding: 2px 8px 4px;
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  line-height: 1;
   background-image: var(--image-value-bg);
   background-image: var(--image-value-bg);
-  background-size: auto 40px;
   background-position: center bottom;
   background-position: center bottom;
   background-repeat: no-repeat;
   background-repeat: no-repeat;
   background-size: 100% 100%;
   background-size: 100% 100%;
@@ -143,8 +150,16 @@ defineEmits(['click']);
 }
 }
 
 
 .mini-board_D {
 .mini-board_D {
-  // width: 105px;
+  width: 120px;
+  min-width: 118px;
   height: 50px;
   height: 50px;
+  padding: 2px 8px 4px;
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  line-height: 1;
   background-image: var(--image-mini-board-1);
   background-image: var(--image-mini-board-1);
   background-position: center bottom;
   background-position: center bottom;
   background-size: 100% 100%;
   background-size: 100% 100%;
@@ -268,13 +283,14 @@ defineEmits(['click']);
   color: #3ecca7;
   color: #3ecca7;
   font-size: 12px;
   font-size: 12px;
   font-family: 'douyuFont';
   font-family: 'douyuFont';
-  height: 28px;
-  line-height: 28px;
+  height: 18px;
+  line-height: 18px;
+  margin-bottom: 0;
 }
 }
 
 
 .mini-board__label_B {
 .mini-board__label_B {
-  line-height: 16px;
-  height: 16px;
+  line-height: 14px;
+  height: 14px;
   font-size: 12px;
   font-size: 12px;
 }
 }
 
 
@@ -288,15 +304,16 @@ defineEmits(['click']);
 
 
 .mini-board__value_D {
 .mini-board__value_D {
   font-size: 12px;
   font-size: 12px;
-  height: 28px;
-  line-height: 32px;
+  height: 18px;
+  line-height: 18px;
   color: #3ecca7;
   color: #3ecca7;
   font-family: 'douyuFont';
   font-family: 'douyuFont';
+  margin-bottom: 0;
 }
 }
 
 
 .mini-board__label_D {
 .mini-board__label_D {
-  line-height: 18px;
-  height: 18px;
+  line-height: 14px;
+  height: 14px;
   font-size: 12px;
   font-size: 12px;
 }
 }
 
 

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

@@ -5,7 +5,7 @@
     :style="style"
     :style="style"
     :title="moduleName"
     :title="moduleName"
     :visible="visible"
     :visible="visible"
-    :class="{ 'cursor-pointer': !!moduleData.to }"
+    :clickable="!!moduleData.to"
     @close="$emit('close')"
     @close="$emit('close')"
     @click="redirectTo"
     @click="redirectTo"
   >
   >
@@ -43,9 +43,11 @@
   const selectedData = ref();
   const selectedData = ref();
 
 
   const style = computed(() => {
   const style = computed(() => {
-    const size = props.showStyle.size;
-    const position = props.showStyle.position;
-    return size + position;
+    const size = (props.showStyle.size || '').trim();
+    const position = (props.showStyle.position || '').trim();
+    const sizePart = size && !size.endsWith(';') ? `${size};` : size;
+    const positionPart = position && !position.endsWith(';') ? `${position};` : position;
+    return sizePart + positionPart;
   });
   });
 
 
   // 根据配置里的定位判断应该使用哪个module组件
   // 根据配置里的定位判断应该使用哪个module组件

+ 64 - 92
src/views/vent/home/configurable/green/components/center-area-green.vue

@@ -3,70 +3,30 @@
     <div class="container">
     <div class="container">
       <div class="risk-score">
       <div class="risk-score">
         <div class="item-label">风险得分</div>
         <div class="item-label">风险得分</div>
-        <div class="item-val">{{ paramDatas.riskLevel !== undefined && paramDatas.riskLevel !== null ? paramDatas.riskLevel : '-' }}</div>
+        <div class="item-val">{{
+          paramDatas.riskLevel !== undefined && paramDatas.riskLevel !== null ? paramDatas.riskLevel : '-'
+        }}</div>
       </div>
       </div>
       <div class="dust-warn">
       <div class="dust-warn">
         <div class="dust-icon"></div>
         <div class="dust-icon"></div>
-        <div class="item-content" @click="showModal('dust')">
+        <div class="item-content is-clickable" @click="showModal('dust')">
           <div class="ite-label">粉尘监测预警</div>
           <div class="ite-label">粉尘监测预警</div>
-          <div class="ite-val">{{
-            paramDatas.fc == 0
-              ? '低风险'
-              : paramDatas.fc == 101
-                ? '低风险'
-                : paramDatas.fc == 102
-                  ? '一般风险'
-                  : paramDatas.fc == 103
-                    ? '较大风险'
-                    : paramDatas.fc == 104
-                      ? '重大风险'
-                      : paramDatas.fc == 201
-                        ? '报警'
-                        : paramDatas.fc
-          }}</div>
+          <div class="ite-val">{{ formatWarnLevel(paramDatas.fc) }}</div>
         </div>
         </div>
       </div>
       </div>
       <div class="vent-warn">
       <div class="vent-warn">
         <div class="vent-icon"></div>
         <div class="vent-icon"></div>
-        <div class="item-content" @click="showModal('vent')">
+        <div class="item-content is-clickable" @click="showModal('vent')">
           <div class="ite-label">通风监测预警</div>
           <div class="ite-label">通风监测预警</div>
-          <div class="ite-val">{{
-            paramDatas.tf == 0
-              ? '低风险'
-              : paramDatas.tf == 101
-                ? '低风险'
-                : paramDatas.tf == 102
-                  ? '一般风险'
-                  : paramDatas.tf == 103
-                    ? '较大风险'
-                    : paramDatas.tf == 104
-                      ? '重大风险'
-                      : paramDatas.tf == 201
-                        ? '报警'
-                        : paramDatas.tf
-          }}</div>
+          <div class="ite-val">{{ formatWarnLevel(paramDatas.tf) }}</div>
         </div>
         </div>
       </div>
       </div>
       <div class="gas-warn">
       <div class="gas-warn">
         <div class="gas-icon"></div>
         <div class="gas-icon"></div>
         <div class="item-content1">
         <div class="item-content1">
-          <div class="ite-content" @click="showModal('gas')">
+          <div class="ite-content is-clickable" @click="showModal('gas')">
             <div class="ite-label">瓦斯监测预警</div>
             <div class="ite-label">瓦斯监测预警</div>
-            <div class="ite-val">{{
-              paramDatas.ws == 0
-                ? '低风险'
-                : paramDatas.ws == 101
-                  ? '低风险'
-                  : paramDatas.ws == 102
-                    ? '一般风险'
-                    : paramDatas.ws == 103
-                      ? '较大风险'
-                      : paramDatas.ws == 104
-                        ? '重大风险'
-                        : paramDatas.ws == 201
-                          ? '报警'
-                          : paramDatas.ws
-            }}</div>
+            <div class="ite-val">{{ formatWarnLevel(paramDatas.ws) }}</div>
           </div>
           </div>
           <div class="ite-content">
           <div class="ite-content">
             <div class="ite-label">矿井瓦斯等级鉴定</div>
             <div class="ite-label">矿井瓦斯等级鉴定</div>
@@ -76,56 +36,28 @@
       </div>
       </div>
       <div class="device-warn">
       <div class="device-warn">
         <div class="device-icon"></div>
         <div class="device-icon"></div>
-        <div class="item-content" @click="showModal('sbyj')">
+        <div class="item-content is-clickable" @click="showModal('sbyj')">
           <div class="ite-label">设备监测预警</div>
           <div class="ite-label">设备监测预警</div>
-          <div class="ite-val">{{
-            paramDatas.sb == 0
-              ? '低风险'
-              : paramDatas.sb == 101
-                ? '低风险'
-                : paramDatas.sb == 102
-                  ? '一般风险'
-                  : paramDatas.sb == 103
-                    ? '较大风险'
-                    : paramDatas.sb == 104
-                      ? '重大风险'
-                      : paramDatas.sb == 201
-                        ? '报警'
-                        : paramDatas.sb
-          }}</div>
+          <div class="ite-val">{{ formatWarnLevel(paramDatas.sb) }}</div>
         </div>
         </div>
       </div>
       </div>
       <div class="fire-warn">
       <div class="fire-warn">
         <div class="fire-icon"></div>
         <div class="fire-icon"></div>
-        <div class="item-content" @click="showModal('fire')">
+        <div class="item-content is-clickable" @click="showModal('fire')">
           <div class="ite-label">火灾监测预警</div>
           <div class="ite-label">火灾监测预警</div>
-          <div class="ite-val">{{
-            paramDatas.fire == 0
-              ? '低风险'
-              : paramDatas.fire == 101
-                ? '低风险'
-                : paramDatas.fire == 102
-                  ? '一般风险'
-                  : paramDatas.fire == 103
-                    ? '较大风险'
-                    : paramDatas.fire == 104
-                      ? '重大风险'
-                      : paramDatas.fire == 201
-                        ? '报警'
-                        : paramDatas.fire
-          }}</div>
+          <div class="ite-val">{{ formatWarnLevel(paramDatas.fire) }}</div>
         </div>
         </div>
       </div>
       </div>
       <div class="db-warn">
       <div class="db-warn">
         <div class="db-icon"></div>
         <div class="db-icon"></div>
-        <div class="item-content" @click="showModal('Minepressure')">
+        <div class="item-content is-clickable" @click="showModal('Minepressure')">
           <div class="ite-label">顶板</div>
           <div class="ite-label">顶板</div>
           <div class="ite-val">低风险</div>
           <div class="ite-val">低风险</div>
         </div>
         </div>
       </div>
       </div>
       <div class="sz-warn">
       <div class="sz-warn">
         <div class="sz-icon"></div>
         <div class="sz-icon"></div>
-        <div class="item-content" @click="showModal('waterLevel')">
+        <div class="item-content is-clickable" @click="showModal('waterLevel')">
           <div class="ite-label">水灾</div>
           <div class="ite-label">水灾</div>
           <div class="ite-val">低风险</div>
           <div class="ite-val">低风险</div>
         </div>
         </div>
@@ -142,7 +74,8 @@
   import { useRouter } from 'vue-router';
   import { useRouter } from 'vue-router';
   import { useGlobSetting } from '/@/hooks/setting';
   import { useGlobSetting } from '/@/hooks/setting';
   import RiskWarnLevel from './dz-risk.vue';
   import RiskWarnLevel from './dz-risk.vue';
-  let props = defineProps({
+
+  const props = defineProps({
     paramData: {
     paramData: {
       type: Object,
       type: Object,
       default: () => {
       default: () => {
@@ -151,11 +84,24 @@
     },
     },
   });
   });
 
 
-  let router = useRouter();
-  let paramDatas = reactive<any>({});
-  const { sysOrgCode, sysDataType } = useGlobSetting();
+  const router = useRouter();
+  const paramDatas = reactive<any>({});
+  const { sysOrgCode } = useGlobSetting();
+
+  /** 风险等级码转展示文案,未知/空值显示 - */
+  function formatWarnLevel(level: unknown) {
+    if (level === undefined || level === null || level === '') return '-';
+    const map: Record<number | string, string> = {
+      0: '低风险',
+      101: '低风险',
+      102: '一般风险',
+      103: '较大风险',
+      104: '重大风险',
+      201: '报警',
+    };
+    return map[level as number | string] ?? '-';
+  }
 
 
-  //跳转详情
   function showModal(data) {
   function showModal(data) {
     switch (data) {
     switch (data) {
       case 'vent':
       case 'vent':
@@ -168,7 +114,6 @@
         router.push('/dust/warn/home');
         router.push('/dust/warn/home');
         break;
         break;
       case 'waterLevel':
       case 'waterLevel':
-        console.log('waterLevel');
         router.push('/warn/waterLevel/home');
         router.push('/warn/waterLevel/home');
         break;
         break;
       case 'gas':
       case 'gas':
@@ -185,8 +130,9 @@
 
 
   watch(
   watch(
     () => props.paramData,
     () => props.paramData,
-    (newV, oldV) => {
-      paramDatas = newV;
+    (newV) => {
+      if (!newV) return;
+      Object.assign(paramDatas, newV);
     },
     },
     { immediate: true, deep: true }
     { immediate: true, deep: true }
   );
   );
@@ -344,6 +290,15 @@
       background: var(--image-model_item_content) no-repeat;
       background: var(--image-model_item_content) no-repeat;
       background-size: 100% 100%;
       background-size: 100% 100%;
       color: #fff;
       color: #fff;
+      transition: filter 0.2s ease, opacity 0.2s ease;
+
+      &.is-clickable {
+        cursor: pointer;
+
+        &:hover {
+          filter: brightness(1.15);
+        }
+      }
     }
     }
 
 
     .item-content1 {
     .item-content1 {
@@ -360,8 +315,11 @@
     .ite-label {
     .ite-label {
       text-align: center;
       text-align: center;
       margin-bottom: 10px;
       margin-bottom: 10px;
-      padding-top: 4px;
+      padding: 4px 4px 0;
       font-size: 12px;
       font-size: 12px;
+      overflow: hidden;
+      white-space: nowrap;
+      text-overflow: ellipsis;
     }
     }
 
 
     .ite-val {
     .ite-val {
@@ -369,12 +327,26 @@
       text-align: center;
       text-align: center;
       font-family: 'douyuFont';
       font-family: 'douyuFont';
       font-size: 12px;
       font-size: 12px;
+      overflow: hidden;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      padding: 0 4px;
+      box-sizing: border-box;
     }
     }
 
 
     .ite-content {
     .ite-content {
       width: 50%;
       width: 50%;
       height: 100%;
       height: 100%;
       color: #fff;
       color: #fff;
+
+      &.is-clickable {
+        cursor: pointer;
+        transition: filter 0.2s ease, opacity 0.2s ease;
+
+        &:hover {
+          filter: brightness(1.15);
+        }
+      }
     }
     }
   }
   }
 </style>
 </style>

+ 10 - 22
src/views/vent/home/configurable/green/components/content-green.vue

@@ -64,7 +64,7 @@
         </template>
         </template>
         <!-- 表格部分,这部分通常是占一整个模块的 -->
         <!-- 表格部分,这部分通常是占一整个模块的 -->
         <template v-if="config.name === 'table'">
         <template v-if="config.name === 'table'">
-          <CustomTable class="content__module text-center overflow-auto" :type="config.type" :columns="config.columns" :data="config.data" />
+          <CustomTable class="content__module text-center" :type="config.type" :columns="config.columns" :data="config.data" />
         </template>
         </template>
         <template v-if="config.name === 'tabs'">
         <template v-if="config.name === 'tabs'">
           <CustomTabs class="content__module" :type="config.type" :tab-config="config.items" :overflow="config.overflow" />
           <CustomTabs class="content__module" :type="config.type" :tab-config="config.items" :overflow="config.overflow" />
@@ -133,7 +133,6 @@
   import MeasureDetail from '../../components/preset/MeasureDetail.vue';
   import MeasureDetail from '../../components/preset/MeasureDetail.vue';
   import CustomTabs from '../../components/preset/CustomTabs.vue';
   import CustomTabs from '../../components/preset/CustomTabs.vue';
   import DeviceAlarm from '../../components/preset/DeviceAlarm.vue';
   import DeviceAlarm from '../../components/preset/DeviceAlarm.vue';
-  import { config } from 'dotenv';
 
 
   const props = defineProps<{
   const props = defineProps<{
     data: any;
     data: any;
@@ -396,20 +395,16 @@
     height: 100%;
     height: 100%;
     color: #fff;
     color: #fff;
     overflow-y: auto;
     overflow-y: auto;
-  }
-
-  // .content__module:first-of-type {
-  //   margin-top: 0;
-  // }
-  // .content__module:last-of-type {
-  //   margin-bottom: 0;
-  // }
-  ::-webkit-scrollbar {
-    width: 5px !important;
-  }
+    overflow-x: hidden;
+    // 隐藏模块内滚动条(预警监测等),仍可滚动
+    scrollbar-width: none;
+    -ms-overflow-style: none;
 
 
-  ::-webkit-scrollbar-thumb {
-    width: 5px !important;
+    &::-webkit-scrollbar {
+      width: 0 !important;
+      height: 0 !important;
+      display: none;
+    }
   }
   }
 
 
   :deep(.zxm-select:not(.zxm-select-customize-input) .zxm-select-selector) {
   :deep(.zxm-select:not(.zxm-select-customize-input) .zxm-select-selector) {
@@ -436,11 +431,4 @@
     box-shadow: unset;
     box-shadow: unset;
   }
   }
 
 
-  ::-webkit-scrollbar {
-    width: 5px !important;
-  }
-
-  ::-webkit-scrollbar-thumb {
-    width: 5px !important;
-  }
 </style>
 </style>

+ 231 - 217
src/views/vent/home/configurable/green/components/dz-chart.vue

@@ -5,7 +5,7 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import { ref, reactive, watch, onMounted, nextTick } from 'vue';
+  import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue';
   import * as echarts from 'echarts';
   import * as echarts from 'echarts';
   import warnLevel1 from '@/assets/images/home-green/300.png';
   import warnLevel1 from '@/assets/images/home-green/300.png';
   import warnLevel2 from '@/assets/images/home-green/400.png';
   import warnLevel2 from '@/assets/images/home-green/400.png';
@@ -13,7 +13,7 @@
   import warnLevel4 from '@/assets/images/home-green/600.png';
   import warnLevel4 from '@/assets/images/home-green/600.png';
   import warnLevel5 from '@/assets/images/home-green/700.png';
   import warnLevel5 from '@/assets/images/home-green/700.png';
 
 
-  let props = defineProps({
+  const props = defineProps({
     echartOption: {
     echartOption: {
       type: Object,
       type: Object,
       default: () => {
       default: () => {
@@ -28,264 +28,277 @@
     },
     },
   });
   });
 
 
-  //获取dom元素节点
-  let chartRef = ref<any>();
-  // 类别
-  let category = ref<any[]>([]);
-  let echartY = ref<any[]>([]);
+  const chartRef = ref<HTMLElement | null>(null);
+  const category = ref<any[]>([]);
+  const echartY = ref<any[]>([]);
+  let chartInstance: echarts.ECharts | null = null;
+
+  function ensureChart() {
+    if (!chartRef.value) return null;
+    if (!chartInstance) {
+      chartInstance = echarts.init(chartRef.value);
+    }
+    return chartInstance;
+  }
+
   function getOption() {
   function getOption() {
-    let myChart = echarts.init(chartRef.value);
-    let option = {
-      xAxis: {
-        max: 10,
-        splitLine: {
-          show: false,
-        },
-        axisLine: {
-          show: false,
-        },
-        axisLabel: {
-          show: false,
-        },
-        axisTick: {
-          show: false,
-        },
-      },
-      grid: {
-        left: 10,
-        top: 10, // 设置条形图的边距
-        right: 10,
-        bottom: 0,
-      },
-      yAxis: [
-        {
-          type: 'category',
-          inverse: false,
-          data: category.value,
-          axisLine: {
+    nextTick(() => {
+      const myChart = ensureChart();
+      if (!myChart) return;
+      const option = {
+        xAxis: {
+          max: 10,
+          splitLine: {
             show: false,
             show: false,
           },
           },
-          axisTick: {
+          axisLine: {
             show: false,
             show: false,
           },
           },
           axisLabel: {
           axisLabel: {
             show: false,
             show: false,
           },
           },
+          axisTick: {
+            show: false,
+          },
         },
         },
-      ],
-      series: [
-        {
-          // 内
-          type: 'bar',
-          barWidth: 18,
-          silent: true,
-          itemStyle: {
-            normal: {
-              color: function (param) {
-                const colors = ['#ff0000', '#ff7700', '#d8b66f', '#dbbf2e', '#61b4c5'];
-                return colors[param.dataIndex % colors.length];
-              },
+        grid: {
+          left: 10,
+          top: 10,
+          right: 10,
+          bottom: 0,
+        },
+        yAxis: [
+          {
+            type: 'category',
+            inverse: false,
+            data: category.value,
+            axisLine: {
+              show: false,
+            },
+            axisTick: {
+              show: false,
+            },
+            axisLabel: {
+              show: false,
             },
             },
           },
           },
-          label: {
-            normal: {
-              formatter: (params) => {
-                let text;
-                if (params.dataIndex == 0) {
-                  text = `{warnLevel5|\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0${params.data.name}}`;
-                } else if (params.dataIndex == 1) {
-                  text = `{warnLevel4|\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0${params.data.name}}`;
-                } else if (params.dataIndex == 2) {
-                  text = `{warnLevel3|\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0${params.data.name}}`;
-                } else if (params.dataIndex == 3) {
-                  text = `{warnLevel2|\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0${params.data.name}}`;
-                } else if (params.dataIndex == 4) {
-                  text = `{warnLevel1|\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0${params.data.name}}`;
-                }
-                return text;
-              },
-              textStyle: {
-                color: '#fff',
-                fontSize: 12,
+        ],
+        series: [
+          {
+            type: 'bar',
+            barWidth: 18,
+            silent: true,
+            itemStyle: {
+              normal: {
+                color: function (param) {
+                  const colors = ['#ff0000', '#ff7700', '#d8b66f', '#dbbf2e', '#61b4c5'];
+                  return colors[param.dataIndex % colors.length];
+                },
               },
               },
-              position: [0, -22],
-              show: true,
-              rich: {
-                //富文本
-                warnLevel1: {
-                  backgroundColor: {
-                    image: warnLevel1,
-                  },
-                  height: 16,
-                  width: 16,
+            },
+            label: {
+              normal: {
+                formatter: (params) => {
+                  let text;
+                  if (params.dataIndex == 0) {
+                    text = `{warnLevel5|\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0${params.data.name}}`;
+                  } else if (params.dataIndex == 1) {
+                    text = `{warnLevel4|\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0${params.data.name}}`;
+                  } else if (params.dataIndex == 2) {
+                    text = `{warnLevel3|\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0${params.data.name}}`;
+                  } else if (params.dataIndex == 3) {
+                    text = `{warnLevel2|\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0${params.data.name}}`;
+                  } else if (params.dataIndex == 4) {
+                    text = `{warnLevel1|\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0${params.data.name}}`;
+                  }
+                  return text;
                 },
                 },
-                warnLevel2: {
-                  backgroundColor: {
-                    //这里可以添加你想自定义的图片
-                    image: warnLevel2,
-                  },
-                  height: 16,
-                  width: 16,
+                textStyle: {
+                  color: '#fff',
+                  fontSize: 12,
                 },
                 },
-                warnLevel3: {
-                  backgroundColor: {
-                    image: warnLevel3,
+                position: [0, -22],
+                show: true,
+                rich: {
+                  warnLevel1: {
+                    backgroundColor: {
+                      image: warnLevel1,
+                    },
+                    height: 16,
+                    width: 16,
                   },
                   },
-                  height: 16,
-                  width: 16,
-                },
-                warnLevel4: {
-                  backgroundColor: {
-                    //这里可以添加你想自定义的图片
-                    image: warnLevel4,
+                  warnLevel2: {
+                    backgroundColor: {
+                      image: warnLevel2,
+                    },
+                    height: 16,
+                    width: 16,
                   },
                   },
-                  height: 16,
-                  width: 16,
-                },
-                warnLevel5: {
-                  backgroundColor: {
-                    //这里可以添加你想自定义的图片
-                    image: warnLevel5,
+                  warnLevel3: {
+                    backgroundColor: {
+                      image: warnLevel3,
+                    },
+                    height: 16,
+                    width: 16,
+                  },
+                  warnLevel4: {
+                    backgroundColor: {
+                      image: warnLevel4,
+                    },
+                    height: 16,
+                    width: 16,
+                  },
+                  warnLevel5: {
+                    backgroundColor: {
+                      image: warnLevel5,
+                    },
+                    height: 16,
+                    width: 16,
                   },
                   },
-                  height: 16,
-                  width: 16,
                 },
                 },
               },
               },
             },
             },
+            data: category.value,
+            z: 1,
+            animationEasing: 'elasticOut',
           },
           },
-          data: category.value,
-          z: 1,
-          animationEasing: 'elasticOut',
-        },
-
-        {
-          // 分隔
-          type: 'pictorialBar',
-          itemStyle: {
-            normal: {
-              color: 'rgba(28, 48, 52)',
+          {
+            type: 'pictorialBar',
+            itemStyle: {
+              normal: {
+                color: 'rgba(28, 48, 52)',
+              },
             },
             },
+            symbolRepeat: 'fixed',
+            symbolMargin: 3,
+            symbol: 'rect',
+            symbolClip: true,
+            symbolSize: [10, 24],
+            symbolPosition: 'start',
+            symbolOffset: [1, -4],
+            symbolBoundingData: 10,
+            data: [10, 10, 10, 10, 10],
+            z: 2,
+            animationEasing: 'elasticOut',
           },
           },
-          symbolRepeat: 'fixed',
-          symbolMargin: 3,
-          symbol: 'rect',
-          symbolClip: true,
-          symbolSize: [10, 24],
-          symbolPosition: 'start',
-          symbolOffset: [1, -4],
-          symbolBoundingData: 10,
-          data: [10, 10, 10, 10, 10],
-          z: 2,
-          animationEasing: 'elasticOut',
-        },
-        {
-          // label
-          type: 'pictorialBar',
-          symbolBoundingData: 10,
-          itemStyle: {
-            normal: {
-              color: 'none',
+          {
+            type: 'pictorialBar',
+            symbolBoundingData: 10,
+            itemStyle: {
+              normal: {
+                color: 'none',
+              },
             },
             },
-          },
-          label: {
-            normal: {
-              formatter: (params) => {
-                let text;
-                if (params.dataIndex == 0) {
-                  text = '{a| ' + params.data + '个}';
-                } else if (params.dataIndex == 1) {
-                  text = '{b| ' + params.data + '个}';
-                } else if (params.dataIndex == 2) {
-                  text = '{c| ' + params.data + '个}';
-                } else if (params.dataIndex == 3) {
-                  text = '{d| ' + params.data + '个}';
-                } else if (params.dataIndex == 4) {
-                  text = '{e| ' + params.data + '个}';
-                } else {
-                  text = '{f| ' + params.data + '个}';
-                }
+            label: {
+              normal: {
+                formatter: (params) => {
+                  let text;
+                  if (params.dataIndex == 0) {
+                    text = '{a| ' + params.data + '个}';
+                  } else if (params.dataIndex == 1) {
+                    text = '{b| ' + params.data + '个}';
+                  } else if (params.dataIndex == 2) {
+                    text = '{c| ' + params.data + '个}';
+                  } else if (params.dataIndex == 3) {
+                    text = '{d| ' + params.data + '个}';
+                  } else if (params.dataIndex == 4) {
+                    text = '{e| ' + params.data + '个}';
+                  } else {
+                    text = '{f| ' + params.data + '个}';
+                  }
 
 
-                return text;
-              },
-              rich: {
-                a: {
-                  color: '#ff0000',
-                },
-                b: {
-                  color: '#ff7700',
-                },
-                c: {
-                  color: '#d8b66f',
-                },
-                d: {
-                  color: '#dbbf2e',
+                  return text;
                 },
                 },
-                e: {
-                  color: '#61b4c5',
-                },
-                f: {
-                  color: '#fff',
+                rich: {
+                  a: {
+                    color: '#ff0000',
+                  },
+                  b: {
+                    color: '#ff7700',
+                  },
+                  c: {
+                    color: '#d8b66f',
+                  },
+                  d: {
+                    color: '#dbbf2e',
+                  },
+                  e: {
+                    color: '#61b4c5',
+                  },
+                  f: {
+                    color: '#fff',
+                  },
                 },
                 },
+                position: [350, -12],
+                show: true,
               },
               },
-              position: [350, -12],
-              show: true,
             },
             },
+            data: echartY.value,
+            z: 0,
           },
           },
-          data: echartY.value,
-          z: 0,
-        },
-
-        {
-          name: '外框',
-          type: 'bar',
-          barGap: '-130%', // 设置外框粗细
-          data: [10, 10, 10, 10, 10],
-
-          barWidth: 29,
-          itemStyle: {
-            normal: {
-              barBorderRadius: [0, 0, 0, 0],
-              color: 'transparent', // 填充色
-              barBorderColor: '#3ecca7', // 边框色
-              barBorderWidth: 1, // 边框宽度
+          {
+            name: '外框',
+            type: 'bar',
+            barGap: '-130%',
+            data: [10, 10, 10, 10, 10],
+            barWidth: 29,
+            itemStyle: {
+              normal: {
+                barBorderRadius: [0, 0, 0, 0],
+                color: 'transparent',
+                barBorderColor: '#3ecca7',
+                barBorderWidth: 1,
+              },
             },
             },
+            z: 2,
           },
           },
-
-          z: 2,
-        },
-        {
-          type: 'scatter',
-          name: '条形',
-          symbol: 'roundRect',
-          symbolSize: [6, 14],
-          symbolOffset: [3, -3],
-          symbolKeepAspect: true,
-          itemStyle: {
-            normal: {
-              color: '#3ecca7',
+          {
+            type: 'scatter',
+            name: '条形',
+            symbol: 'roundRect',
+            symbolSize: [6, 14],
+            symbolOffset: [3, -3],
+            symbolKeepAspect: true,
+            itemStyle: {
+              normal: {
+                color: '#3ecca7',
+              },
             },
             },
+            data: [10, 10, 10, 10, 10],
           },
           },
-          data: [10, 10, 10, 10, 10],
-        },
-      ],
-    };
-    myChart.setOption(option);
-    window.onresize = function () {
-      myChart.resize();
-    };
+        ],
+      };
+      myChart.setOption(option as any, true);
+    });
+  }
+
+  function handleResize() {
+    chartInstance?.resize();
   }
   }
 
 
   watch(
   watch(
     () => props.echartData,
     () => props.echartData,
-    (newV, oldV) => {
-      if (newV.length) {
-        category.value = newV;
-        echartY.value = newV.map((el: any) => el.value);
+    (newV) => {
+      if (newV && newV.length) {
+        category.value = newV as any[];
+        echartY.value = (newV as any[]).map((el: any) => el.value);
         getOption();
         getOption();
       }
       }
     },
     },
     { immediate: true }
     { immediate: true }
   );
   );
+
+  onMounted(() => {
+    window.addEventListener('resize', handleResize);
+  });
+
+  onUnmounted(() => {
+    window.removeEventListener('resize', handleResize);
+    if (chartInstance) {
+      chartInstance.dispose();
+      chartInstance = null;
+    }
+  });
 </script>
 </script>
 
 
 <style lang="less" scoped>
 <style lang="less" scoped>
@@ -293,6 +306,7 @@
     position: relative;
     position: relative;
     padding-top: 10px;
     padding-top: 10px;
     height: 100%;
     height: 100%;
+    box-sizing: border-box;
 
 
     .chartRef {
     .chartRef {
       width: 100%;
       width: 100%;

+ 40 - 16
src/views/vent/home/configurable/green/components/dz-list.vue

@@ -80,39 +80,63 @@ watch(
 }
 }
 .icons-box {
 .icons-box {
   height: 100%;
   height: 100%;
-  overflow-y: hidden;
-
-  &:hover {
-    overflow-y: auto;
-    overflow-x: auto;
+  overflow-y: auto;
+  overflow-x: hidden;
+  box-sizing: border-box;
+  // 上下留白,避免左侧图标(top:-18px)被模块边框裁切
+  padding: 18px 0 12px;
+  // 隐藏滚动条,仍可滚动
+  scrollbar-width: none;
+  -ms-overflow-style: none;
+
+  &::-webkit-scrollbar {
+    width: 0;
+    height: 0;
+    display: none;
   }
   }
 
 
   .icon-item {
   .icon-item {
     position: relative;
     position: relative;
     width: 100%;
     width: 100%;
     height: 46px;
     height: 46px;
-    background: var(--image-model_icon-item) no-repeat;
-    background-size: 85% 100%;
+    background: var(--image-model_icon-item) no-repeat left center;
+    // 加长背景条,保证「总数/报警数/断开数」完整落在图内
+    background-size: 100% 100%;
     display: flex;
     display: flex;
     align-items: center;
     align-items: center;
     justify-content: center;
     justify-content: center;
-    margin: 5px auto;
+    margin: 8px auto;
+    box-sizing: border-box;
+    padding-left: 68px;
+    padding-right: 12px;
 
 
     &:nth-child(even) {
     &:nth-child(even) {
-      padding-right: 0px;
+      padding-right: 12px;
     }
     }
 
 
     .level-text {
     .level-text {
-      width: 76%;
+      width: 100%;
       display: flex;
       display: flex;
-      justify-content: space-around;
-      position: absolute;
-      top: 10px;
-      left: 97;
+      justify-content: space-between;
+      align-items: center;
+      position: relative;
+      top: 0;
+      left: 0;
       color: #ffffffe0;
       color: #ffffffe0;
-      font-size: 14px;
+      font-size: 13px;
       text-align: center;
       text-align: center;
-      letter-spacing: 1px;
+      letter-spacing: 0;
+      box-sizing: border-box;
+      padding: 0 8px 0 4px;
+
+      .all-count,
+      .warn-count,
+      .close-count {
+        flex: 1;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
 
 
       .num-count,
       .num-count,
       .num-count1 {
       .num-count1 {

+ 162 - 155
src/views/vent/home/configurable/green/components/dz-risk.vue

@@ -1,184 +1,191 @@
 <template>
 <template>
-    <div class="dz-risk">
-        <div class="risk-container">
-            <div class="risk-center"></div>
-            <div ref="riskPie" class="risk-pie"></div>
-        </div>
+  <div class="dz-risk">
+    <div class="risk-container">
+      <div class="risk-center"></div>
+      <div ref="riskPie" class="risk-pie"></div>
     </div>
     </div>
+  </div>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import { ref, nextTick, onMounted, watch } from 'vue'
-import * as echarts from 'echarts';
+  import { ref, nextTick, onMounted, onUnmounted, watch } from 'vue';
+  import * as echarts from 'echarts';
 
 
-let props = defineProps({
+  const props = defineProps({
     echartData: {
     echartData: {
-        type: Array,
-        default: () => {
-            return []
-        }
+      type: Array,
+      default: () => {
+        return [];
+      },
+    },
+  });
+
+  const riskPie = ref<HTMLElement | null>(null);
+  let chartInstance: echarts.ECharts | null = null;
+
+  const colorList = [
+    {
+      type: 'radial',
+      r: 1,
+      colorStops: [
+        { offset: 0, color: 'rgba(182, 242, 255,1)' },
+        { offset: 1, color: 'rgba(95, 228, 255,1)' },
+      ],
+    },
+    {
+      type: 'radial',
+      r: 1,
+      colorStops: [
+        { offset: 0, color: 'rgba(176, 255, 236,1)' },
+        { offset: 1, color: 'rgba(86, 255, 171,1)' },
+      ],
+    },
+    {
+      type: 'radial',
+      r: 1,
+      colorStops: [
+        { offset: 0, color: 'rgba(255, 253, 176,1)' },
+        { offset: 1, color: 'rgba(255, 255, 86,1)' },
+      ],
+    },
+    {
+      type: 'radial',
+      r: 1,
+      colorStops: [
+        { offset: 0, color: 'rgba(215, 179, 255,1)' },
+        { offset: 1, color: 'rgba(134, 88, 255,1)' },
+      ],
+    },
+  ];
+
+  function ensureChart() {
+    if (!riskPie.value) return null;
+    if (!chartInstance) {
+      chartInstance = echarts.init(riskPie.value);
     }
     }
-})
+    return chartInstance;
+  }
 
 
-let riskPie = ref(null)
-let echartDatas = ref<any[]>([])
-
-function getOption() {
+  function renderChart(data: any[]) {
     nextTick(() => {
     nextTick(() => {
-        const colorList = [
-            {
-                type: 'radial',
-                r: 1,
-                colorStops: [{
-                    offset: 0, color: 'rgba(182, 242, 255,1)' // 0% 处的颜色
-                }, {
-                    offset: 1, color: 'rgba(95, 228, 255,1)' // 100% 处的颜色
-                }],
-
-            },
-            {
-                type: 'radial',
-                r: 1,
-                colorStops: [{
-                    offset: 0, color: 'rgba(176, 255, 236,1)' // 0% 处的颜色
-                }, {
-                    offset: 1, color: 'rgba(86, 255, 171,1)' // 100% 处的颜色
-                }],
-
-            },
-            {
-                type: 'radial',
-                r: 1,
-                colorStops: [{
-                    offset: 0, color: 'rgba(255, 253, 176,1)' // 0% 处的颜色
-                }, {
-                    offset: 1, color: 'rgba(255, 255, 86,1)' // 100% 处的颜色
-                }],
-
+      const myChart = ensureChart();
+      if (!myChart) return;
+      const option = {
+        color: colorList,
+        series: [
+          {
+            type: 'pie',
+            center: ['50%', '50%'],
+            radius: ['30%', '50%'],
+            emphasis: {
+              label: {
+                show: true,
+              },
+              opacity: 1,
+              color: function (params) {
+                return colorList[params.dataIndex];
+              },
             },
             },
-            {
-                type: 'radial',
-                r: 1,
-                colorStops: [{
-                    offset: 0, color: 'rgba(215, 179, 255,1)' // 0% 处的颜色
-                }, {
-                    offset: 1, color: 'rgba(134, 88, 255,1)' // 100% 处的颜色
-                }],
-
-            },
-        ]
-        let myChart = echarts.init(riskPie.value);
-        let option = {
-            color: colorList,
-            // tooltip: {
-            //     trigger: 'item'
-            // },
-            series: [{
-                type: 'pie',
-                center: ['50%', '50%'],
-                radius: ['30%', '50%'],
-                emphasis: {
-                    label: {
-                        show: true
-                    },
-                    opacity:1,
-                    color: function (params) {
-                        return colorList[params.dataIndex]
-                    }
-                },
-                itemStyle: {
-                    normal: {
-                        color: function (params) {
-                            return colorList[params.dataIndex]
-                        }
-                    }
+            itemStyle: {
+              normal: {
+                color: function (params) {
+                  return colorList[params.dataIndex];
                 },
                 },
-                label: {
-                    show: true,
-                    position: 'outside',
-                    textStyle: {
-                        color: "#fff",
-                        fontSize: 10
-                    },
-                    formatter: '{b}:{c}',
-                    rich: {
-                        // hr: {
-                        //     backgroundColor: 't',
-                        //     borderRadius: 3,
-                        //     width: 3,
-                        //     height: 3,
-                        //     padding: [3, 3, 0, -12]
-                        // },
-                        a: {
-                            padding: [-10, 5, -15, 5]
-                        }
-                    }
+              },
+            },
+            label: {
+              show: true,
+              position: 'outside',
+              textStyle: {
+                color: '#fff',
+                fontSize: 10,
+              },
+              formatter: '{b}:{c}',
+              rich: {
+                a: {
+                  padding: [-10, 5, -15, 5],
                 },
                 },
-
-
-                labelLine: {
-                    normal: {
-                        length: 10,
-                        length2: 5,
-                        lineStyle: {
-                            width: 1
-                        }
-                    }
+              },
+            },
+            labelLine: {
+              normal: {
+                length: 10,
+                length2: 5,
+                lineStyle: {
+                  width: 1,
                 },
                 },
-                data: echartDatas.value,
-            }]
-        };
-        myChart.setOption(option);
-        window.onresize = function () {
-            myChart.resize();
-        };
+              },
+            },
+            data: data || [],
+          },
+        ],
+      };
+      myChart.setOption(option as any, true);
     });
     });
-}
-
-watch(() => props.echartData, (newV, oldV) => {
-    echartDatas.value = newV
-    getOption()
-}, { immediate: true })
-
+  }
+
+  function handleResize() {
+    chartInstance?.resize();
+  }
+
+  watch(
+    () => props.echartData,
+    (newV) => {
+      renderChart((newV as any[]) || []);
+    },
+    { immediate: true, deep: true }
+  );
+
+  onMounted(() => {
+    window.addEventListener('resize', handleResize);
+  });
+
+  onUnmounted(() => {
+    window.removeEventListener('resize', handleResize);
+    if (chartInstance) {
+      chartInstance.dispose();
+      chartInstance = null;
+    }
+  });
 </script>
 </script>
 
 
 <style lang="less" scoped>
 <style lang="less" scoped>
-@import '/@/design/theme.less';
+  @import '/@/design/theme.less';
 
 
-@{theme-deepblue} {
+  @{theme-deepblue} {
     .dz-risk {
     .dz-risk {
-        --image-model_risk-container: url('@/assets/images/themify/deepblue/home-container/configurable/1400.png');
-        --image-model_risk-center: url('@/assets/images/themify/deepblue/home-container/configurable/1500.png');
+      --image-model_risk-container: url('@/assets/images/themify/deepblue/home-container/configurable/1400.png');
+      --image-model_risk-center: url('@/assets/images/themify/deepblue/home-container/configurable/1500.png');
     }
     }
-}
+  }
 
 
-.dz-risk {
+  .dz-risk {
     --image-model_risk-container: url('@/assets/images/home-green/1400.png');
     --image-model_risk-container: url('@/assets/images/home-green/1400.png');
     --image-model_risk-center: url('@/assets/images/home-green/1500.png');
     --image-model_risk-center: url('@/assets/images/home-green/1500.png');
     height: 100%;
     height: 100%;
 
 
     .risk-container {
     .risk-container {
-        position: relative;
-        height: 100%;
+      position: relative;
+      height: 100%;
+      width: 100%;
+      background: var(--image-model_risk-container) no-repeat center;
+      background-size: 55% 72%;
+
+      .risk-center {
+        position: absolute;
+        left: 50%;
+        top: 50%;
+        transform: translate(-50%, -50%);
         width: 100%;
         width: 100%;
-        background: var(--image-model_risk-container) no-repeat center;
-        background-size: 55% 72%;
-
-        .risk-center {
-            position: absolute;
-            left: 50%;
-            top: 50%;
-            transform: translate(-50%, -50%);
-            width: 100%;
-            height: 40px;
-            background: var(--image-model_risk-center) no-repeat center;
-            background-size: auto 100%;
-        }
-
-        .risk-pie {
-            width: 100%;
-            height: 100%;
-        }
+        height: 40px;
+        background: var(--image-model_risk-center) no-repeat center;
+        background-size: auto 100%;
+      }
+
+      .risk-pie {
+        width: 100%;
+        height: 100%;
+      }
     }
     }
-}
-</style>
+  }
+</style>

+ 146 - 138
src/views/vent/home/configurable/green/components/green-nav.vue

@@ -4,14 +4,27 @@
     <div class="main-title">{{ mainTitle }}</div>
     <div class="main-title">{{ mainTitle }}</div>
     <!-- menu区域 -->
     <!-- menu区域 -->
     <div class="nav-menu">
     <div class="nav-menu">
-      <div :class="activeIndex == index ? 'nav-menu-active' : 'nav-menu-unactive'" v-for="(item, index) in menuList"
-        :key="index" @click="menuClick(index, item)">
+      <div
+        :class="currentIndex == index ? 'nav-menu-active' : 'nav-menu-unactive'"
+        v-for="(item, index) in menuList"
+        :key="index"
+        @click="menuClick(index, item)"
+      >
         <div>{{ item.name }}</div>
         <div>{{ item.name }}</div>
         <!-- menu-item -->
         <!-- menu-item -->
-        <div v-if="activeIndex == index && isShowMenuItem && item.MenuItemList.length != 0" class="nav-menu-item">
+        <div
+          v-if="currentIndex == index && isShowMenuItem && item.MenuItemList && item.MenuItemList.length != 0"
+          class="nav-menu-item"
+        >
           <div class="nav-menu-content">
           <div class="nav-menu-content">
-            <div :class="menuItemActive == ind ? 'menu-item-active' : 'menu-item-unactive'"
-              v-for="(ite, ind) in item.MenuItemList" :key="ind" @click.stop="menuItemClick(ind)">{{ ite.name }}</div>
+            <div
+              :class="menuItemActive == ind ? 'menu-item-active' : 'menu-item-unactive'"
+              v-for="(ite, ind) in item.MenuItemList"
+              :key="ind"
+              @click.stop="menuItemClick(ind)"
+            >
+              {{ ite.name }}
+            </div>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
@@ -19,159 +32,154 @@
   </div>
   </div>
 </template>
 </template>
 <script lang="ts" setup>
 <script lang="ts" setup>
-import { onMounted, onUnmounted, ref, watch, computed } from 'vue';
-import { menuList } from '../../configurable.data'
-import { useGlobSetting } from '/@/hooks/setting';
-import { useInitPage } from '../../hooks/useInit';
-import { useRouter } from 'vue-router';
-
-let props=defineProps({
-  activeIndex:{
-    type:Number,
-  }
-})
-
-const { title = '智能通风管控系统' } = useGlobSetting();
-const { mainTitle } = useInitPage(title);
-let isShowMenuItem = ref(false)//是否显示menuItem下拉选项菜单
-let menuItemActive = ref(0)//menuItem当前激活选项
-let router = useRouter()
-
-
-
-//menu选项切换
-function menuClick(index, item) {
-  isShowMenuItem.value = !isShowMenuItem.value
-  //menu切换,界面跳转
-  switch (item.name) {
-    case '灾害预警':
-      router.push('/configurable/green/home')
-      break;
-    case '通风预警':
-      router.push('/micro-vent-3dModal/configurable/green/ventS/home')
-      break;
-    case '火灾预警':
-      router.push('/micro-vent-3dModal/configurable/green/fireS/home')
-      break;
-    case '粉尘预警':
-      router.push('/micro-vent-3dModal/configurable/green/dustS/home')
-      break;
+  import { ref, computed } from 'vue';
+  import { menuList } from '../../configurable.data';
+  import { useGlobSetting } from '/@/hooks/setting';
+  import { useInitPage } from '../../hooks/useInit';
+  import { useRouter } from 'vue-router';
+
+  const props = defineProps({
+    activeIndex: {
+      type: Number,
+      default: 0,
+    },
+  });
+
+  const { title = '智能通风管控系统' } = useGlobSetting();
+  const { mainTitle } = useInitPage(title);
+  const isShowMenuItem = ref(false);
+  const menuItemActive = ref(0);
+  const router = useRouter();
+
+  const currentIndex = computed(() => (props.activeIndex !== undefined && props.activeIndex !== null ? props.activeIndex : 0));
+
+  function menuClick(index, item) {
+    const hasMenuItems = item.MenuItemList && item.MenuItemList.length > 0;
+    if (hasMenuItems && currentIndex.value === index) {
+      isShowMenuItem.value = !isShowMenuItem.value;
+    } else {
+      isShowMenuItem.value = hasMenuItems;
+    }
 
 
+    // 当前高亮菜单再次点击时不整页重跳
+    if (currentIndex.value === index) return;
+
+    switch (item.name) {
+      case '灾害预警':
+        router.push('/configurable/green/home');
+        break;
+      case '通风预警':
+        router.push('/micro-vent-3dModal/configurable/green/ventS/home');
+        break;
+      case '火灾预警':
+        router.push('/micro-vent-3dModal/configurable/green/fireS/home');
+        break;
+      case '粉尘预警':
+        router.push('/micro-vent-3dModal/configurable/green/dustS/home');
+        break;
+    }
   }
   }
-}
 
 
-//menuItem选项切换
-function menuItemClick(ind) {
-  menuItemActive.value = ind
-  isShowMenuItem.value = false
-}
+  function menuItemClick(ind) {
+    menuItemActive.value = ind;
+    isShowMenuItem.value = false;
+  }
 </script>
 </script>
 <style lang="less" scoped>
 <style lang="less" scoped>
-@import '/@/design/theme.less';
+  @import '/@/design/theme.less';
 
 
-@{theme-deepblue} {
-  .green-nav {
-    --image-model_green-nav: url('@/assets/images/themify/deepblue/home-container/configurable/green-nav-bg.png');
-    --image-model_nav-menu-active: url('@/assets/images/themify/deepblue/home-container/configurable/green-menu-bg.png');
-    --image-model_nav-menu-item: url('@/assets/images/themify/deepblue/home-container/configurable/green-menu-item.png');
-  }
-}
-
-.green-nav {
-  --image-model_green-nav: url('@/assets/images/home-green/green-nav-bg.png');
-  --image-model_nav-menu-active: url('@/assets/images/home-green/green-menu-bg.png');
-  --image-model_nav-menu-item: url('@/assets/images/home-green/green-menu-item.png');
-
-  position: relative;
-  position: relative;
-  position: relative;
-  width: 100%;
-  height: 100%;
-  background: var(--image-model_green-nav) no-repeat;
-  background-size: 100% 100%;
-
-  .main-title {
-    width: 518px;
-    height: 100%;
-    display: flex;
-    align-items: center;
-    padding-left: 70px;
-    font-family: 'douyuFont';
-    font-size: 20px;
-    letter-spacing: 2px;
-    color: #fff;
+  @{theme-deepblue} {
+    .green-nav {
+      --image-model_green-nav: url('@/assets/images/themify/deepblue/home-container/configurable/green-nav-bg.png');
+      --image-model_nav-menu-active: url('@/assets/images/themify/deepblue/home-container/configurable/green-menu-bg.png');
+      --image-model_nav-menu-item: url('@/assets/images/themify/deepblue/home-container/configurable/green-menu-item.png');
+    }
   }
   }
 
 
-  .nav-menu {
-    position: absolute;
-    top: 0;
-    left: 675px;
-    width: 880px;
+  .green-nav {
+    --image-model_green-nav: url('@/assets/images/home-green/green-nav-bg.png');
+    --image-model_nav-menu-active: url('@/assets/images/home-green/green-menu-bg.png');
+    --image-model_nav-menu-item: url('@/assets/images/home-green/green-menu-item.png');
+
+    position: relative;
+    width: 100%;
     height: 100%;
     height: 100%;
-    display: flex;
-    justify-content: flex-start;
-    color: #fff;
-
-    .nav-menu-active {
-      position: relative;
-      width: 120px;
-      height: 60px;
-      line-height: 60px;
-      text-align: center;
-      margin: 0px 20px;
-      font-size: 18px;
-      letter-spacing: 2px;
-      background: var(--image-model_nav-menu-active) no-repeat;
-      background-size: 100% 100%;
-    }
+    background: var(--image-model_green-nav) no-repeat;
+    background-size: 100% 100%;
 
 
-    .nav-menu-unactive {
-      position: relative;
-      width: 120px;
-      height: 60px;
-      line-height: 60px;
-      text-align: center;
-      margin: 0px 10px;
-      font-size: 16px;
+    .main-title {
+      width: 518px;
+      height: 100%;
+      display: flex;
+      align-items: center;
+      padding-left: 70px;
+      font-family: 'douyuFont';
+      font-size: 20px;
       letter-spacing: 2px;
       letter-spacing: 2px;
-      background-size: 100% 100%;
-      cursor: pointer;
+      color: #fff;
     }
     }
 
 
-    .nav-menu-item {
+    .nav-menu {
       position: absolute;
       position: absolute;
-      left: -34px;
-      top: 56px;
-      width: 186px;
-      height: 273px;
-      padding: 28px 12px 12px 12px;
-      background: var(--image-model_nav-menu-item) no-repeat;
+      top: 0;
+      left: 675px;
+      width: 880px;
+      height: 100%;
       display: flex;
       display: flex;
-      flex-direction: column;
-      align-items: center;
-      box-sizing: border-box;
-
-      .nav-menu-content {
-        width: 100%;
-        height: 100%;
-        overflow-y: auto;
+      justify-content: flex-start;
+      color: #fff;
+
+      .nav-menu-active,
+      .nav-menu-unactive {
+        position: relative;
+        width: 110px;
+        height: 60px;
+        line-height: 60px;
+        text-align: center;
+        margin: 0 8px;
+        font-size: 16px;
+        letter-spacing: 0;
+        background-size: 100% 100%;
+        cursor: pointer;
+      }
 
 
-        .menu-item-active {
-          width: 100%;
-          height: 36px;
-          line-height: 36px;
-          font-size: 14px;
-          background: linear-gradient(to right, transparent, rgba(47, 132, 111), transparent);
-        }
+      .nav-menu-active {
+        background: var(--image-model_nav-menu-active) no-repeat;
+        background-size: 100% 100%;
+      }
 
 
-        .menu-item-unactive {
+      .nav-menu-item {
+        position: absolute;
+        left: -34px;
+        top: 56px;
+        width: 186px;
+        height: 273px;
+        padding: 28px 12px 12px 12px;
+        background: var(--image-model_nav-menu-item) no-repeat;
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        box-sizing: border-box;
+
+        .nav-menu-content {
           width: 100%;
           width: 100%;
-          height: 40px;
-          line-height: 40px;
-          font-size: 14px;
+          height: 100%;
+          overflow-y: auto;
+
+          .menu-item-active,
+          .menu-item-unactive {
+            width: 100%;
+            height: 36px;
+            line-height: 36px;
+            font-size: 14px;
+            cursor: pointer;
+          }
+
+          .menu-item-active {
+            background: linear-gradient(to right, transparent, rgba(47, 132, 111), transparent);
+          }
         }
         }
       }
       }
     }
     }
   }
   }
-}
 </style>
 </style>

+ 5 - 3
src/views/vent/home/configurable/green/components/module-green.vue

@@ -74,9 +74,11 @@
   const { selectedDeviceID, selectedDevice, options, init } = useInitModule(props.deviceType, props.moduleData);
   const { selectedDeviceID, selectedDevice, options, init } = useInitModule(props.deviceType, props.moduleData);
 
 
   const style = computed(() => {
   const style = computed(() => {
-    const size = props.showStyle.size;
-    const position = props.showStyle.position;
-    return size + position;
+    const size = (props.showStyle.size || '').trim();
+    const position = (props.showStyle.position || '').trim();
+    const sizePart = size && !size.endsWith(';') ? `${size};` : size;
+    const positionPart = position && !position.endsWith(';') ? `${position};` : position;
+    return sizePart + positionPart;
   });
   });
 
 
   // 根据配置里的定位判断应该使用哪个module组件
   // 根据配置里的定位判断应该使用哪个module组件

+ 6 - 2
src/views/vent/home/configurable/green/components/moduleBottom-green.vue

@@ -2,7 +2,7 @@
   <div v-if="visible" class="module-content">
   <div v-if="visible" class="module-content">
     <div v-if="title" class="module-content__title__expand">
     <div v-if="title" class="module-content__title__expand">
       <span class="action-btn close-btn" @click="closeModel"></span>
       <span class="action-btn close-btn" @click="closeModel"></span>
-      <span @click="clickHandler">{{ title }}</span>
+      <span :class="{ 'is-clickable': clickable }" @click="clickHandler">{{ title }}</span>
     </div>
     </div>
     <div class="module-slot">
     <div class="module-slot">
       <slot></slot>
       <slot></slot>
@@ -10,7 +10,7 @@
   </div>
   </div>
 </template>
 </template>
 <script lang="ts" setup>
 <script lang="ts" setup>
-defineProps<{ title: string; visible: boolean }>();
+defineProps<{ title: string; visible: boolean; clickable?: boolean }>();
 const emit = defineEmits(['close', 'click']);
 const emit = defineEmits(['close', 'click']);
 
 
 function closeModel() {
 function closeModel() {
@@ -54,6 +54,10 @@ function clickHandler() {
   padding-left: 36px;
   padding-left: 36px;
   font-weight: bold;
   font-weight: bold;
   font-size: 16px;
   font-size: 16px;
+
+  .is-clickable {
+    cursor: pointer;
+  }
 }
 }
 
 
 // .module-content__title {
 // .module-content__title {

+ 5 - 3
src/views/vent/home/configurable/green/components/moduleDual-green.vue

@@ -102,9 +102,11 @@
   } = useInitModule(props.deviceTypeB, props.moduleDataB);
   } = useInitModule(props.deviceTypeB, props.moduleDataB);
 
 
   const style = computed(() => {
   const style = computed(() => {
-    const size = props.showStyle.size;
-    const position = props.showStyle.position;
-    return size + position;
+    const size = (props.showStyle.size || '').trim();
+    const position = (props.showStyle.position || '').trim();
+    const sizePart = size && !size.endsWith(';') ? `${size};` : size;
+    const positionPart = position && !position.endsWith(';') ? `${position};` : position;
+    return sizePart + positionPart;
   });
   });
 
 
   // 根据配置里的定位判断应该使用哪个module组件
   // 根据配置里的定位判断应该使用哪个module组件

+ 6 - 2
src/views/vent/home/configurable/green/components/moduleLeft-green.vue

@@ -2,7 +2,7 @@
   <div v-if="visible" class="module-content">
   <div v-if="visible" class="module-content">
     <div v-if="title" class="module-content__title__expand">
     <div v-if="title" class="module-content__title__expand">
       <span class="action-btn close-btn" @click="closeModel"></span>
       <span class="action-btn close-btn" @click="closeModel"></span>
-      <span @click="clickHandler">{{ title }}</span>
+      <span :class="{ 'is-clickable': clickable }" @click="clickHandler">{{ title }}</span>
     </div>
     </div>
     <div class="module-slot">
     <div class="module-slot">
       <slot></slot>
       <slot></slot>
@@ -10,7 +10,7 @@
   </div>
   </div>
 </template>
 </template>
 <script lang="ts" setup>
 <script lang="ts" setup>
-defineProps<{ title: string; visible: boolean }>();
+defineProps<{ title: string; visible: boolean; clickable?: boolean }>();
 const emit = defineEmits(['close', 'click']);
 const emit = defineEmits(['close', 'click']);
 
 
 function closeModel() {
 function closeModel() {
@@ -53,6 +53,10 @@ function clickHandler() {
   padding-left: 36px;
   padding-left: 36px;
   font-weight: bold;
   font-weight: bold;
   font-size: 16px;
   font-size: 16px;
+
+  .is-clickable {
+    cursor: pointer;
+  }
 }
 }
 
 
 // .module-content__title {
 // .module-content__title {

+ 113 - 133
src/views/vent/home/configurable/green/dust-green.vue

@@ -1,110 +1,142 @@
 <template>
 <template>
-  <div class="fire-green">
-    <div class="top-bg">
-      <greenNav :activeIndex="activeIndex" />
-    </div>
-    <div class="main-container">
-      <!-- 采用定位方式以避免出现各个模块隐藏时其他模块下移的问题 -->
-      <div class="left-t">
-        <div class="tcontent-area">
-          <div class="tcontent-l">
-            <div>监测</div>
-            <div>区域</div>
-          </div>
-          <div class="tcontent-c">
-            <div style="color: #3afde7; font-size: 24px; font-weight: bolder; letter-spacing: 10px">
-              {{ data.dustAllMineWarn }}
+  <div class="dust-green">
+    <!-- 有 deviceType query 时视为详情页,只展示中心模型 -->
+    <template v-if="!route.query.deviceType">
+      <div class="top-bg">
+        <greenNav :activeIndex="activeIndex" />
+      </div>
+      <div class="main-container">
+        <!-- 采用定位方式以避免出现各个模块隐藏时其他模块下移的问题 -->
+        <div class="left-t">
+          <div class="tcontent-area">
+            <div class="tcontent-l">
+              <div>监测</div>
+              <div>区域</div>
+            </div>
+            <div class="tcontent-c">
+              <div class="risk-value">
+                {{ data.dustAllMineWarn }}
+              </div>
+            </div>
+            <div class="tcontent-r">
+              <div>粉尘</div>
+              <div>风险</div>
             </div>
             </div>
-          </div>
-          <div class="tcontent-r">
-            <div>粉尘</div>
-            <div>风险</div>
           </div>
           </div>
         </div>
         </div>
-      </div>
 
 
-      <ModuleGreen
-        v-for="cfg in configs"
-        :key="cfg.deviceType"
-        :show-style="cfg.showStyle"
-        :module-data="cfg.moduleData"
-        :module-name="cfg.moduleName"
-        :device-type="cfg.deviceType"
-        :data="data"
-        :visible="true"
-      />
-    </div>
+        <ModuleGreen
+          v-for="(cfg, index) in configs"
+          :key="`${cfg.deviceType}_${cfg.moduleName}_${index}`"
+          :show-style="cfg.showStyle"
+          :module-data="cfg.moduleData"
+          :module-name="cfg.moduleName"
+          :device-type="cfg.deviceType"
+          :data="data"
+          :visible="true"
+        />
+      </div>
+    </template>
 
 
-    <div style="width: calc(100% - 928px); height: 544px; position: absolute; left: 464px; top: 96px">
+    <div class="center-model">
       <SubApp />
       <SubApp />
     </div>
     </div>
   </div>
   </div>
 </template>
 </template>
 <script lang="ts" setup>
 <script lang="ts" setup>
-  import { ref, computed, onMounted, onUnmounted } from 'vue';
+  import { ref, onMounted, onUnmounted, watch } from 'vue';
+  import { useRoute } from 'vue-router';
   import { useInitConfigs, useInitPage } from '../hooks/useInit';
   import { useInitConfigs, useInitPage } from '../hooks/useInit';
   import greenNav from './components/green-nav.vue';
   import greenNav from './components/green-nav.vue';
   import ModuleGreen from './components/module-green.vue';
   import ModuleGreen from './components/module-green.vue';
   import SubApp from '/@/components/vent/micro/createSubApp.vue';
   import SubApp from '/@/components/vent/micro/createSubApp.vue';
   import { getDisHome } from '../configurable.api';
   import { getDisHome } from '../configurable.api';
-  import { EyeFilled } from '@ant-design/icons-vue';
   import { testConfigBDDust } from '../configurable.data';
   import { testConfigBDDust } from '../configurable.data';
+  import { useGlobSetting } from '/@/hooks/setting';
 
 
-  let activeIndex = ref(3);
+  const { title = '智能通风管控系统' } = useGlobSetting();
+  const route = useRoute();
+  const activeIndex = ref(3);
   const { configs, devicesTypes, fetchConfigs } = useInitConfigs();
   const { configs, devicesTypes, fetchConfigs } = useInitConfigs();
-  const { mainTitle, data, updateData } = useInitPage('');
+  const { data, updateData } = useInitPage(title);
   let interval: number | undefined;
   let interval: number | undefined;
+  let unmounted = false;
+
+  function fetchListData() {
+    if (unmounted) return Promise.resolve();
+    const dataList = devicesTypes.value.concat('dustAllMineWarn').join(',');
+    if (!dataList) return Promise.resolve();
+    return getDisHome({ dataList }).then((res) => {
+      if (!unmounted) updateData(res);
+    });
+  }
 
 
-  onMounted(() => {
-    fetchConfigs('BD_dust').then(() => {
+  function stopInterval() {
+    if (interval !== undefined) {
+      clearInterval(interval);
+      interval = undefined;
+    }
+  }
+
+  function initInterval() {
+    stopInterval();
+    interval = window.setInterval(() => {
+      fetchListData();
+    }, 2000);
+  }
+
+  function refresh() {
+    return fetchConfigs('BD_dust').then(() => {
+      if (unmounted) return;
       configs.value = testConfigBDDust;
       configs.value = testConfigBDDust;
+      return fetchListData();
+    });
+  }
 
 
-      getDisHome({
-        dataList: devicesTypes.value.concat('dustAllMineWarn').join(','),
-      }).then(updateData);
+  function setupPage() {
+    if (route.query.deviceType) {
+      stopInterval();
+      return;
+    }
+    refresh().then(() => {
+      if (!unmounted && !route.query.deviceType) {
+        initInterval();
+      }
     });
     });
-    // [perf-base-v1] 定时器句柄赋值给 interval,修复卸载时清理无效的泄漏
-    interval = setInterval(() => {
-      getDisHome({
-        dataList: devicesTypes.value.concat('dustAllMineWarn').join(','),
-      }).then(updateData);
-    }, 2000);
+  }
+
+  watch(
+    () => route.query,
+    () => {
+      setupPage();
+    }
+  );
+
+  onMounted(() => {
+    setupPage();
   });
   });
 
 
   onUnmounted(() => {
   onUnmounted(() => {
-    clearInterval(interval);
+    unmounted = true;
+    stopInterval();
   });
   });
-
-  function redirectTo(url) {
-    window.open(url);
-  }
 </script>
 </script>
 <style lang="less" scoped>
 <style lang="less" scoped>
   @import '/@/design/theme.less';
   @import '/@/design/theme.less';
 
 
   @font-face {
   @font-face {
     font-family: 'douyuFont';
     font-family: 'douyuFont';
-    src: url('../../../../assets/font/douyuFont.otf');
+    src: url('/@/assets/font/douyuFont.otf');
   }
   }
 
 
   @{theme-deepblue} {
   @{theme-deepblue} {
-    .fire-green {
-      --image-bg: url('@/assets/images/themify/deepblue/home-container/configurable/firehome/bg.png');
-      --image-fire-title: url(/@/assets/images/themify/deepblue/home-container/configurable/firehome/fire-title.png);
+    .dust-green {
       --image-qkjaq: url('/@/assets/images/themify/deepblue/home-container/configurable/firehome/1-2.png');
       --image-qkjaq: url('/@/assets/images/themify/deepblue/home-container/configurable/firehome/1-2.png');
-      --image-common-border2: url('/@/assets/images/themify/deepblue/home-container/configurable/firehome/green-bd-bottom.png');
-      --image-znzjxt: url(/@/assets/images/themify/deepblue/home-container/configurable/firehome/1-7.png);
-      --image-znzdxt: url(/@/assets/images/themify/deepblue/home-container/configurable/firehome/1-8.png);
     }
     }
   }
   }
 
 
-  .fire-green {
-    --image-bg: url('@/assets/images/home-container/configurable/firehome/bg.png');
-    --image-fire-title: url(/@/assets/images/home-container/configurable/firehome/fire-title.png);
+  .dust-green {
     --image-qkjaq: url('/@/assets/images/home-container/configurable/firehome/1-2.png');
     --image-qkjaq: url('/@/assets/images/home-container/configurable/firehome/1-2.png');
-    --image-common-border2: url('@/assets/images/home-container/configurable/firehome/green-bd-bottom.png');
-    --image-znzjxt: url(/@/assets/images/home-container/configurable/firehome/1-7.png);
-    --image-znzdxt: url(/@/assets/images/home-container/configurable/firehome/1-8.png);
     position: relative;
     position: relative;
     width: 100%;
     width: 100%;
     height: 100%;
     height: 100%;
@@ -122,42 +154,25 @@
       top: 96px;
       top: 96px;
       width: calc(100% - 30px);
       width: calc(100% - 30px);
       height: calc(100% - 96px);
       height: calc(100% - 96px);
-      margin: 0px 15px;
+      margin: 0 15px;
       box-sizing: border-box;
       box-sizing: border-box;
     }
     }
 
 
-    .module-dropdown {
-      padding: 10px;
-      background-image: linear-gradient(to bottom, #036886, #072a40);
-      border-bottom: 2px solid #3df6ff;
-      color: @vent-font-color;
-      position: absolute;
-      top: 70px;
-      right: 460px;
-    }
-
-    .module-dropdown-original {
-      padding: 10px;
-      background-image: linear-gradient(to bottom, #036886, #072a40);
-      border-bottom: 2px solid #3df6ff;
-      color: @vent-font-color;
+    .center-model {
+      width: calc(100% - 928px);
+      height: 544px;
       position: absolute;
       position: absolute;
-      top: 70px;
-      right: 460px;
-    }
-
-    .module-trigger-button {
-      color: @vent-font-color;
-      background-image: linear-gradient(to bottom, #036886, #072a40);
-      border: none;
-      border-bottom: 2px solid #3df6ff;
+      left: 464px;
+      top: 96px;
+      z-index: 0;
+      pointer-events: auto;
     }
     }
   }
   }
 
 
   .left-t {
   .left-t {
     position: absolute;
     position: absolute;
     height: 90px;
     height: 90px;
-    top: 0px;
+    top: 0;
     left: 10px;
     left: 10px;
     width: 420px;
     width: 420px;
     background-image: var(--image-qkjaq);
     background-image: var(--image-qkjaq);
@@ -198,6 +213,13 @@
         align-items: center;
         align-items: center;
         justify-content: center;
         justify-content: center;
         height: 100%;
         height: 100%;
+
+        .risk-value {
+          color: #3afde7;
+          font-size: 24px;
+          font-weight: bolder;
+          letter-spacing: 4px;
+        }
       }
       }
 
 
       .tcontent-r {
       .tcontent-r {
@@ -215,48 +237,6 @@
     }
     }
   }
   }
 
 
-  .right-t {
-    position: absolute;
-    height: 90px;
-    right: 10px;
-    top: 0px;
-    width: 420px;
-    background-image: var(--image-common-border2);
-    background-repeat: no-repeat;
-    background-position: center;
-    background-size: 100% 100%;
-    display: flex;
-    align-items: center;
-    justify-content: space-around;
-    color: #fff;
-
-    .tcontent-l {
-      flex: 1;
-      height: 100%;
-      font-size: 14px;
-      font-weight: bold;
-      background-image: var(--image-znzjxt);
-      background-size: 75% 80%;
-      background-repeat: no-repeat;
-      background-position: center;
-      text-align: center;
-      padding-top: 70px;
-    }
-
-    .tcontent-r {
-      flex: 1;
-      height: 100%;
-      font-size: 14px;
-      font-weight: bold;
-      background-image: var(--image-znzdxt);
-      background-size: 75% 80%;
-      background-repeat: no-repeat;
-      background-position: center;
-      text-align: center;
-      padding-top: 70px;
-    }
-  }
-
   :deep(.loading-box) {
   :deep(.loading-box) {
     position: unset;
     position: unset;
   }
   }

+ 155 - 120
src/views/vent/home/configurable/green/fire-green.vue

@@ -1,67 +1,71 @@
 <template>
 <template>
   <div class="fire-green">
   <div class="fire-green">
-    <div class="top-bg">
-      <greenNav :activeIndex="activeIndex" />
-    </div>
-    <div class="main-container">
-      <!-- 采用定位方式以避免出现各个模块隐藏时其他模块下移的问题 -->
-      <div class="left-t">
-        <div class="tcontent-area">
-          <div class="tcontent-l">
-            <div>监测</div>
-            <div>区域</div>
-          </div>
-          <div class="tcontent-c">
-            <div style="margin-bottom: -4px; color: #3afde7; font-size: 24px; font-weight: bolder; letter-spacing: 10px">
-              {{ data.fireAllMineWarn }}
+    <!-- 有 deviceType query 时视为详情页,只展示中心模型 -->
+    <template v-if="!route.query.deviceType">
+      <div class="top-bg">
+        <greenNav :activeIndex="activeIndex" />
+      </div>
+      <div class="main-container">
+        <!-- 采用定位方式以避免出现各个模块隐藏时其他模块下移的问题 -->
+        <div class="left-t">
+          <div class="tcontent-area">
+            <div class="tcontent-l">
+              <div>监测</div>
+              <div>区域</div>
+            </div>
+            <div class="tcontent-c">
+              <div class="risk-value">
+                {{ data.fireAllMineWarn }}
+              </div>
+              <div class="risk-label">自燃倾向性等级 : 自燃</div>
+            </div>
+            <div class="tcontent-r">
+              <div>火灾</div>
+              <div>风险</div>
             </div>
             </div>
-            <div style="color: #fff; font-size: 12px">自燃倾向性等级 : 自燃</div>
-          </div>
-          <div class="tcontent-r">
-            <div>火灾</div>
-            <div>风险</div>
           </div>
           </div>
         </div>
         </div>
-      </div>
-      <div class="right-t">
-        <div class="tcontent-l" @click="redirectTo('/grout-home?mock-login=1')">
-          <div>智能注浆系统</div>
-        </div>
-        <div class="tcontent-r" @click="redirectTo('/nitrogen-home?mock-login=1')">
-          <div>智能注氮系统</div>
+        <div class="right-t">
+          <div class="tcontent-l" @click="redirectTo('/grout-home?mock-login=1')">
+            <div>智能注浆系统</div>
+          </div>
+          <div class="tcontent-r" @click="redirectTo('/nitrogen-home?mock-login=1')">
+            <div>智能注氮系统</div>
+          </div>
         </div>
         </div>
+        <ModuleGreen
+          v-for="(cfg, index) in cfgs"
+          :key="`${cfg.deviceType}_${cfg.moduleName}_${index}`"
+          :show-style="cfg.showStyle"
+          :module-data="cfg.moduleData"
+          :module-name="cfg.moduleName"
+          :device-type="cfg.deviceType"
+          :data="data"
+          :visible="true"
+        />
+        <ModuleDual
+          v-if="cfgA && cfgB"
+          :show-style="cfgA.showStyle"
+          :module-data-a="cfgA.moduleData"
+          :module-name-a="cfgA.moduleName"
+          :device-type-a="cfgA.deviceType"
+          :module-data-b="cfgB.moduleData"
+          :module-name-b="cfgB.moduleName"
+          :device-type-b="cfgB.deviceType"
+          :data="data"
+          :visible="true"
+        />
       </div>
       </div>
-      <ModuleGreen
-        v-for="cfg in cfgs"
-        :key="cfg.deviceType"
-        :show-style="cfg.showStyle"
-        :module-data="cfg.moduleData"
-        :module-name="cfg.moduleName"
-        :device-type="cfg.deviceType"
-        :data="data"
-        :visible="true"
-      />
-      <ModuleDual
-        v-if="cfgA && cfgB"
-        :show-style="cfgA.showStyle"
-        :module-data-a="cfgA.moduleData"
-        :module-name-a="cfgA.moduleName"
-        :device-type-a="cfgA.deviceType"
-        :module-data-b="cfgB.moduleData"
-        :module-name-b="cfgB.moduleName"
-        :device-type-b="cfgB.deviceType"
-        :data="data"
-        :visible="true"
-      />
-    </div>
+    </template>
 
 
-    <div style="width: 998px; height: 550px; position: absolute; left: calc(50% - 500px); top: 60px">
+    <div class="center-model">
       <SubApp />
       <SubApp />
     </div>
     </div>
   </div>
   </div>
 </template>
 </template>
 <script lang="ts" setup>
 <script lang="ts" setup>
-  import { ref, computed, onMounted, onUnmounted } from 'vue';
+  import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
+  import { useRoute } from 'vue-router';
   import { useInitConfigs, useInitPage } from '../hooks/useInit';
   import { useInitConfigs, useInitPage } from '../hooks/useInit';
   import greenNav from './components/green-nav.vue';
   import greenNav from './components/green-nav.vue';
   import ModuleGreen from './components/module-green.vue';
   import ModuleGreen from './components/module-green.vue';
@@ -69,45 +73,82 @@
   import SubApp from '/@/components/vent/micro/createSubApp.vue';
   import SubApp from '/@/components/vent/micro/createSubApp.vue';
   import { getDisHome } from '../configurable.api';
   import { getDisHome } from '../configurable.api';
   import { testConfigBDFire } from '../configurable.data';
   import { testConfigBDFire } from '../configurable.data';
-  // import { getToken } from '/@/utils/auth';
-  let activeIndex = ref(2);
+  import { useGlobSetting } from '/@/hooks/setting';
 
 
-  const cfgs = computed(() =>
-    configs.value.filter((_, index) => {
-      return index !== 4 && index !== 3;
-    })
-  );
-  const cfgA = computed<any>(() =>
-    configs.value.find((_, index) => {
-      return index === 3;
-    })
-  );
-  const cfgB = computed<any>(() =>
-    configs.value.find((_, index) => {
-      return index === 4;
-    })
-  );
+  const DUAL_MODULE_A = '工作面束管监测';
+  const DUAL_MODULE_B = '工作面光纤监测';
+
+  const { title = '智能通风管控系统' } = useGlobSetting();
+  const route = useRoute();
+  const activeIndex = ref(2);
   const { configs, devicesTypes, fetchConfigs } = useInitConfigs();
   const { configs, devicesTypes, fetchConfigs } = useInitConfigs();
-  const { mainTitle, data, updateData } = useInitPage('');
+  const { data, updateData } = useInitPage(title);
   let interval: number | undefined;
   let interval: number | undefined;
+  let unmounted = false;
 
 
-  onMounted(() => {
-    fetchConfigs('BD_fire').then(() => {
-      configs.value = testConfigBDFire;
-      getDisHome({
-        dataList: devicesTypes.value.concat('fireAllMineWarn').join(','),
-      }).then(updateData);
+  const cfgs = computed(() =>
+    configs.value.filter((cfg) => cfg.moduleName !== DUAL_MODULE_A && cfg.moduleName !== DUAL_MODULE_B)
+  );
+  const cfgA = computed<any>(() => configs.value.find((cfg) => cfg.moduleName === DUAL_MODULE_A));
+  const cfgB = computed<any>(() => configs.value.find((cfg) => cfg.moduleName === DUAL_MODULE_B));
+
+  function fetchListData() {
+    if (unmounted) return Promise.resolve();
+    const dataList = devicesTypes.value.concat('fireAllMineWarn').join(',');
+    if (!dataList) return Promise.resolve();
+    return getDisHome({ dataList }).then((res) => {
+      if (!unmounted) updateData(res);
     });
     });
-    // [perf-base-v1] 定时器句柄赋值给 interval,修复卸载时清理无效的泄漏
-    interval = setInterval(() => {
-      getDisHome({
-        dataList: devicesTypes.value.concat('fireAllMineWarn').join(','),
-      }).then(updateData);
+  }
+
+  function stopInterval() {
+    if (interval !== undefined) {
+      clearInterval(interval);
+      interval = undefined;
+    }
+  }
+
+  function initInterval() {
+    stopInterval();
+    interval = window.setInterval(() => {
+      fetchListData();
     }, 2000);
     }, 2000);
+  }
+
+  function refresh() {
+    return fetchConfigs('BD_fire').then(() => {
+      if (unmounted) return;
+      configs.value = testConfigBDFire;
+      return fetchListData();
+    });
+  }
+
+  function setupPage() {
+    if (route.query.deviceType) {
+      stopInterval();
+      return;
+    }
+    refresh().then(() => {
+      if (!unmounted && !route.query.deviceType) {
+        initInterval();
+      }
+    });
+  }
+
+  watch(
+    () => route.query,
+    () => {
+      setupPage();
+    }
+  );
+
+  onMounted(() => {
+    setupPage();
   });
   });
 
 
   onUnmounted(() => {
   onUnmounted(() => {
-    clearInterval(interval);
+    unmounted = true;
+    stopInterval();
   });
   });
 
 
   function redirectTo(url) {
   function redirectTo(url) {
@@ -119,7 +160,7 @@
 
 
   @font-face {
   @font-face {
     font-family: 'douyuFont';
     font-family: 'douyuFont';
-    src: url('../../../../assets/font/douyuFont.otf');
+    src: url('/@/assets/font/douyuFont.otf');
   }
   }
 
 
   @{theme-deepblue} {
   @{theme-deepblue} {
@@ -151,47 +192,31 @@
       position: absolute;
       position: absolute;
       z-index: 1;
       z-index: 1;
     }
     }
+
     .main-container {
     .main-container {
       position: absolute;
       position: absolute;
       top: 96px;
       top: 96px;
       width: calc(100% - 30px);
       width: calc(100% - 30px);
       height: calc(100% - 96px);
       height: calc(100% - 96px);
-      margin: 0px 15px;
+      margin: 0 15px;
       box-sizing: border-box;
       box-sizing: border-box;
     }
     }
 
 
-    .module-dropdown {
-      padding: 10px;
-      background-image: linear-gradient(to bottom, #036886, #072a40);
-      border-bottom: 2px solid #3df6ff;
-      color: @vent-font-color;
+    .center-model {
+      width: 998px;
+      height: 550px;
       position: absolute;
       position: absolute;
-      top: 70px;
-      right: 460px;
-    }
-
-    .module-dropdown-original {
-      padding: 10px;
-      background-image: linear-gradient(to bottom, #036886, #072a40);
-      border-bottom: 2px solid #3df6ff;
-      color: @vent-font-color;
-      position: absolute;
-      top: 70px;
-      right: 460px;
-    }
-
-    .module-trigger-button {
-      color: @vent-font-color;
-      background-image: linear-gradient(to bottom, #036886, #072a40);
-      border: none;
-      border-bottom: 2px solid #3df6ff;
+      left: calc(50% - 500px);
+      top: 60px;
+      z-index: 0;
+      pointer-events: auto;
     }
     }
   }
   }
 
 
   .left-t {
   .left-t {
     position: absolute;
     position: absolute;
     height: 90px;
     height: 90px;
-    top: 0px;
+    top: 0;
     left: 10px;
     left: 10px;
     width: 420px;
     width: 420px;
     background-image: var(--image-qkjaq);
     background-image: var(--image-qkjaq);
@@ -232,6 +257,19 @@
         align-items: center;
         align-items: center;
         justify-content: center;
         justify-content: center;
         height: 100%;
         height: 100%;
+
+        .risk-value {
+          margin-bottom: -4px;
+          color: #3afde7;
+          font-size: 24px;
+          font-weight: bolder;
+          letter-spacing: 4px;
+        }
+
+        .risk-label {
+          color: #fff;
+          font-size: 12px;
+        }
       }
       }
 
 
       .tcontent-r {
       .tcontent-r {
@@ -253,7 +291,7 @@
     position: absolute;
     position: absolute;
     height: 90px;
     height: 90px;
     right: 10px;
     right: 10px;
-    top: 0px;
+    top: 0;
     width: 420px;
     width: 420px;
     background-image: var(--image-common-border2);
     background-image: var(--image-common-border2);
     background-repeat: no-repeat;
     background-repeat: no-repeat;
@@ -263,30 +301,27 @@
     align-items: center;
     align-items: center;
     justify-content: space-around;
     justify-content: space-around;
     color: #fff;
     color: #fff;
-    .tcontent-l {
+
+    .tcontent-l,
+    .tcontent-r {
       flex: 1;
       flex: 1;
       height: 100%;
       height: 100%;
       font-size: 14px;
       font-size: 14px;
       font-weight: bold;
       font-weight: bold;
-      background-image: var(--image-znzjxt);
       background-size: 75% 80%;
       background-size: 75% 80%;
       background-repeat: no-repeat;
       background-repeat: no-repeat;
       background-position: center;
       background-position: center;
       text-align: center;
       text-align: center;
       padding-top: 70px;
       padding-top: 70px;
+      cursor: pointer;
+    }
+
+    .tcontent-l {
+      background-image: var(--image-znzjxt);
     }
     }
 
 
     .tcontent-r {
     .tcontent-r {
-      flex: 1;
-      height: 100%;
-      font-size: 14px;
-      font-weight: bold;
       background-image: var(--image-znzdxt);
       background-image: var(--image-znzdxt);
-      background-size: 75% 80%;
-      background-repeat: no-repeat;
-      background-position: center;
-      text-align: center;
-      padding-top: 70px;
     }
     }
   }
   }
 
 

+ 117 - 106
src/views/vent/home/configurable/green/fusion-warn-green.vue

@@ -6,9 +6,16 @@
         <greenNav :activeIndex="activeIndex" />
         <greenNav :activeIndex="activeIndex" />
       </div>
       </div>
       <div class="main-container">
       <div class="main-container">
-        <ModuleOriginal v-for="(cfg, index) in configs" :key="cfg.deviceType + index" :show-style="cfg.showStyle"
-          :module-data="cfg.moduleData" :module-name="cfg.moduleName" :device-type="cfg.deviceType" :data="data"
-          :visible="true" />
+        <ModuleOriginal
+          v-for="(cfg, index) in configs"
+          :key="cfg.deviceType + index"
+          :show-style="cfg.showStyle"
+          :module-data="cfg.moduleData"
+          :module-name="cfg.moduleName"
+          :device-type="cfg.deviceType"
+          :data="data"
+          :visible="true"
+        />
       </div>
       </div>
     </template>
     </template>
 
 
@@ -18,120 +25,124 @@
   </div>
   </div>
 </template>
 </template>
 <script lang="ts" setup>
 <script lang="ts" setup>
-import { ref, reactive, onMounted, onUnmounted } from 'vue';
-import greenNav from './components/green-nav.vue';
-import { useInitConfigs, useInitPage } from '../hooks/useInit';
-import ModuleOriginal from './components/ModuleOriginal-green.vue';
-import CenterAreaGreen from './components/center-area-green.vue';
-import { useRoute } from 'vue-router';
-import { useGlobSetting } from '/@/hooks/setting';
-import { testConfigFusionGreen } from '../configurable.data';
-import { getTotal, getDisasterProportion } from '../configurable.api';
-const { title = '智能通风管控系统' } = useGlobSetting();
-const { configs, fetchConfigs } = useInitConfigs();
-const { data, updateData } = useInitPage(title);
-const route = useRoute();
-const paramData = reactive<any>({})
-let activeIndex = ref(0)
-// https获取监测数据
-let timer: null | NodeJS.Timeout = null;
-function getMonitor(flag = false) {
-  timer = setTimeout(
-    async () => {
-      getTotal({}).then(updateData);
-      getCenterList()
-      if (timer) {
-        timer = null;
+  import { ref, reactive, onMounted, onUnmounted } from 'vue';
+  import greenNav from './components/green-nav.vue';
+  import { useInitConfigs, useInitPage } from '../hooks/useInit';
+  import ModuleOriginal from './components/ModuleOriginal-green.vue';
+  import CenterAreaGreen from './components/center-area-green.vue';
+  import { useRoute } from 'vue-router';
+  import { useGlobSetting } from '/@/hooks/setting';
+  import { testConfigFusionGreen } from '../configurable.data';
+  import { getTotal, getDisasterProportion } from '../configurable.api';
+  const { title = '智能通风管控系统' } = useGlobSetting();
+  const { configs, fetchConfigs } = useInitConfigs();
+  const { data, updateData } = useInitPage(title);
+  const route = useRoute();
+  const paramData = reactive<any>({});
+  let activeIndex = ref(0);
+  let timer: null | NodeJS.Timeout = null;
+  let unmounted = false;
+
+  function getMonitor(flag = false) {
+    if (unmounted) return;
+    if (timer) {
+      clearTimeout(timer);
+      timer = null;
+    }
+    timer = setTimeout(async () => {
+      if (unmounted) return;
+      try {
+        await getCenterList();
+      } finally {
+        if (!unmounted) {
+          getMonitor(false);
+        }
       }
       }
-      getMonitor();
-    },
-    flag ? 0 : 20000
-  );
-}
-async function getCenterList() {
-  let res = await getTotal({})
-  paramData.fire = res.info.sysInfo.fireS.maxLevel;
-  paramData.tf = res.info.sysInfo.ventS.maxLevel;
-  paramData.ws = res.info.sysInfo.gasS.maxLevel;
-  paramData.sb = res.info.deviceWarnInfo.maxLevel;
-  paramData.fc = res.info.sysInfo.dustS.maxLevel;
-  paramData.riskLevel = res.info.riskLevel;
-  let Levels = Object.assign({}, await getDisasterProportion({}));
-  paramData.levels =
-    Levels.level > 0 && Levels.level <= 1
-      ? 101
-      : Levels.level > 1 && Levels.level <= 2
-        ? 102
-        : Levels.level > 2 && Levels.level <= 3
-          ? 103
-          : Levels.level > 3 && Levels.level <= 4
-            ? 104
-            : 0;
-  paramData.riskData = [
-    { name: '通风', value: Levels.vent },
-    { name: '火灾', value: Levels.fire },
-    { name: '瓦斯', value: Levels.gas },
-    { name: '粉尘', value: Levels.dust },
-  ]
-}
-onMounted(() => {
-  fetchConfigs('fusion-warn-green').then(() => {
-    configs.value = testConfigFusionGreen;
-    // updateEnhancedConfigs(configs.value);
-    getMonitor(true);
+    }, flag ? 0 : 20000);
+  }
+
+  async function getCenterList() {
+    const res = await getTotal({});
+    updateData(res);
+    const info = res?.info || {};
+    const sysInfo = info.sysInfo || {};
+    paramData.fire = sysInfo.fireS?.maxLevel;
+    paramData.tf = sysInfo.ventS?.maxLevel;
+    paramData.ws = sysInfo.gasS?.maxLevel;
+    paramData.sb = info.deviceWarnInfo?.maxLevel;
+    paramData.fc = sysInfo.dustS?.maxLevel;
+    paramData.riskLevel = info.riskLevel;
+    const Levels = Object.assign({}, await getDisasterProportion({}));
+    const level = Number(Levels.level);
+    paramData.levels =
+      level > 0 && level <= 1 ? 101 : level > 1 && level <= 2 ? 102 : level > 2 && level <= 3 ? 103 : level > 3 && level <= 4 ? 104 : 0;
+    paramData.riskData = [
+      { name: '通风', value: Levels.vent },
+      { name: '火灾', value: Levels.fire },
+      { name: '瓦斯', value: Levels.gas },
+      { name: '粉尘', value: Levels.dust },
+    ];
+  }
+
+  onMounted(() => {
+    fetchConfigs('fusion-warn-green').then(() => {
+      configs.value = testConfigFusionGreen;
+      getMonitor(true);
+    });
   });
   });
-});
 
 
-onUnmounted(() => {
-  clearTimeout(timer);
-  timer = undefined;
-});
+  onUnmounted(() => {
+    unmounted = true;
+    if (timer) {
+      clearTimeout(timer);
+      timer = null;
+    }
+  });
 </script>
 </script>
 <style lang="less" scoped>
 <style lang="less" scoped>
-@import '/@/design/theme.less';
+  @import '/@/design/theme.less';
 
 
-@{theme-deepblue} {
-  .fusion-home {
-    --image-model_left-area: url('@/assets/images/themify/deepblue/home-container/configurable/green-bd-left.png');
-    --image-model_bottom-area: url('@/assets/images/themify/deepblue/home-container/configurable/green-bd-bottom.png');
+  @{theme-deepblue} {
+    .fusion-home {
+      --image-model_left-area: url('@/assets/images/themify/deepblue/home-container/configurable/green-bd-left.png');
+      --image-model_bottom-area: url('@/assets/images/themify/deepblue/home-container/configurable/green-bd-bottom.png');
+    }
   }
   }
-}
 
 
-.fusion-home {
-  --image-model_left-area: url('@/assets/images/home-green/green-bd-left.png');
-  --image-model_bottom-area: url('@/assets/images/home-green/green-bd-bottom.png');
-  width: 100%;
-  height: 100%;
-  position: relative;
-  background-color: #181b24;
-
-  .top-bg {
+  .fusion-home {
+    --image-model_left-area: url('@/assets/images/home-green/green-bd-left.png');
+    --image-model_bottom-area: url('@/assets/images/home-green/green-bd-bottom.png');
     width: 100%;
     width: 100%;
-    height: 96px;
-    // background: var(--image-modal-top) no-repeat center;
-    position: absolute;
-    z-index: 1;
-  }
+    height: 100%;
+    position: relative;
+    background-color: #181b24;
 
 
-  .main-container {
-    position: absolute;
-    top: 96px;
-    width: calc(100% - 30px);
-    height: calc(100% - 96px);
-    margin: 0px 15px;
-    box-sizing: border-box;
-  }
+    .top-bg {
+      width: 100%;
+      height: 96px;
+      position: absolute;
+      z-index: 1;
+    }
+
+    .main-container {
+      position: absolute;
+      top: 96px;
+      width: calc(100% - 30px);
+      height: calc(100% - 96px);
+      margin: 0px 15px;
+      box-sizing: border-box;
+    }
 
 
-  .center-area {
-    position: absolute;
-    left: 450px;
-    top: 96px;
-    width: calc(100% - 900px);
-    height: calc(100% - 200px);
+    .center-area {
+      position: absolute;
+      left: 450px;
+      top: 96px;
+      width: calc(100% - 900px);
+      height: calc(100% - 200px);
+    }
   }
   }
-}
 
 
-:deep(.loading-box) {
-  position: unset;
-}
+  :deep(.loading-box) {
+    position: unset;
+  }
 </style>
 </style>

+ 81 - 39
src/views/vent/home/configurable/green/vent-green.vue

@@ -23,78 +23,95 @@
       </div>
       </div>
     </template>
     </template>
 
 
-    <div style="width: calc(100% - 468px); height: 530px; position: absolute; left: 436px; top: 110px">
+    <div class="center-model">
       <SubApp />
       <SubApp />
     </div>
     </div>
   </div>
   </div>
 </template>
 </template>
 <script lang="ts" setup>
 <script lang="ts" setup>
-  import { onMounted, onUnmounted, ref, watch, computed } from 'vue';
+  import { onMounted, onUnmounted, ref, watch } from 'vue';
   import { useInitConfigs, useInitPage } from '../hooks/useInit';
   import { useInitConfigs, useInitPage } from '../hooks/useInit';
   import greenNav from './components/green-nav.vue';
   import greenNav from './components/green-nav.vue';
   import ModuleOriginal from './components/ModuleOriginal-green.vue';
   import ModuleOriginal from './components/ModuleOriginal-green.vue';
-  // import greenRightTag from './components/green-right-tag.vue';
+  import greenRightTag from '../components/green-right-tag.vue';
   import { list } from '../configurable.api';
   import { list } from '../configurable.api';
-  import { useRoute, useRouter } from 'vue-router';
+  import { useRoute } from 'vue-router';
   import { useGlobSetting } from '/@/hooks/setting';
   import { useGlobSetting } from '/@/hooks/setting';
   import { testConfigVent } from '../configurable.data';
   import { testConfigVent } from '../configurable.data';
   import SubApp from '/@/components/vent/micro/createSubApp.vue';
   import SubApp from '/@/components/vent/micro/createSubApp.vue';
 
 
   const { title = '智能通风管控系统' } = useGlobSetting();
   const { title = '智能通风管控系统' } = useGlobSetting();
-  //   const { configs, isOriginal, isCommon, fetchConfigs } = useInitConfigs();
-  const { isOriginal, fetchConfigs } = useInitConfigs();
-  const { mainTitle, data, updateData, updateEnhancedConfigs } = useInitPage(title);
+  const { fetchConfigs } = useInitConfigs();
+  const { data, updateData, updateEnhancedConfigs } = useInitPage(title);
   const route = useRoute();
   const route = useRoute();
-  let activeIndex = ref(1);
+  const activeIndex = ref(1);
   let interval: number | undefined;
   let interval: number | undefined;
-  let configs = ref<any[]>([]);
+  const configs = ref<any[]>([]);
+  let unmounted = false;
+
+  function getTypes() {
+    return configs.value
+      .filter((e) => e.deviceType)
+      .map((e) => e.deviceType)
+      .join(',');
+  }
+
+  function fetchListData() {
+    const types = getTypes();
+    if (!types || unmounted) return Promise.resolve();
+    return list({ types }).then(updateData);
+  }
+
+  function stopInterval() {
+    if (interval !== undefined) {
+      clearInterval(interval);
+      interval = undefined;
+    }
+  }
+
+  function initInterval() {
+    stopInterval();
+    interval = window.setInterval(() => {
+      fetchListData();
+    }, 60000);
+  }
+
   function refresh() {
   function refresh() {
-    fetchConfigs('vent').then(() => {
+    return fetchConfigs('vent').then(() => {
+      if (unmounted) return;
       configs.value = testConfigVent;
       configs.value = testConfigVent;
       updateEnhancedConfigs(configs.value);
       updateEnhancedConfigs(configs.value);
-      list({
-        types: configs.value
-          .filter((e) => e.deviceType)
-          .map((e) => e.deviceType)
-          .join(','),
-      }).then(updateData);
+      return fetchListData();
     });
     });
   }
   }
 
 
-  function initInterval() {
-    // [perf-base-v1] 重复初始化前先清理旧句柄,并将新句柄赋值给 interval,修复定时器泄漏
-    clearInterval(interval);
-    interval = setInterval(() => {
-      list({
-        types: configs.value
-          .filter((e) => e.deviceType)
-          .map((e) => e.deviceType)
-          .join(','),
-      }).then(updateData);
-    }, 60000);
+  function setupPage() {
+    if (route.query.deviceType) {
+      // 仅展示子应用
+      stopInterval();
+      return;
+    }
+    refresh().then(() => {
+      if (!unmounted && !route.query.deviceType) {
+        initInterval();
+      }
+    });
   }
   }
 
 
   watch(
   watch(
     () => route.query,
     () => route.query,
     () => {
     () => {
-      if (route.query.deviceType) {
-        // 仅需要展示子应用,模拟 unmounted
-        clearInterval(interval);
-      } else {
-        // 模拟 mounted
-        refresh();
-        initInterval();
-      }
+      setupPage();
     }
     }
   );
   );
 
 
   onMounted(() => {
   onMounted(() => {
-    refresh();
-    initInterval();
+    setupPage();
   });
   });
 
 
   onUnmounted(() => {
   onUnmounted(() => {
-    clearInterval(interval);
+    unmounted = true;
+    stopInterval();
   });
   });
 </script>
 </script>
 <style lang="less" scoped>
 <style lang="less" scoped>
@@ -130,9 +147,34 @@
       top: 96px;
       top: 96px;
       width: calc(100% - 30px);
       width: calc(100% - 30px);
       height: calc(100% - 96px);
       height: calc(100% - 96px);
-      margin: 0px 15px;
+      margin: 0 15px;
       box-sizing: border-box;
       box-sizing: border-box;
     }
     }
+
+    .right-area {
+      position: absolute;
+      // 靠右上留白,避开底部模块与中心模型
+      right: 6px;
+      top: 8px;
+      width: 96px;
+      height: calc(100% - 300px);
+      z-index: 2;
+      box-sizing: border-box;
+      padding: 2px 0;
+      overflow: hidden;
+      pointer-events: none;
+    }
+
+    .center-model {
+      // 右侧为风量标签预留空间,避免遮挡
+      width: calc(100% - 560px);
+      height: 530px;
+      position: absolute;
+      left: 436px;
+      top: 110px;
+      z-index: 0;
+      pointer-events: auto;
+    }
   }
   }
 
 
   :deep(.loading-box) {
   :deep(.loading-box) {

+ 6 - 5
src/views/vent/home/homeAI/index.vue

@@ -8,7 +8,9 @@
 <script setup lang="ts">
 <script setup lang="ts">
   import { onMounted, onUnmounted, ref } from 'vue';
   import { onMounted, onUnmounted, ref } from 'vue';
   import AiAssistantModal from '/@/views/ventAI/manageAssistent/components/AiAssistantModal.vue';
   import AiAssistantModal from '/@/views/ventAI/manageAssistent/components/AiAssistantModal.vue';
-  const visible = ref(false);
+  import { pageProvidesAssistant } from '/@/views/ventAI/manageAssistent/lib/assistantHost';
+  // 本页助手常驻展开:初始化已改为在挂载时立即执行一次,不再依赖 visible 的 false→true 跳变
+  const visible = ref(true);
 
 
   // AI 弹框渲染进 #adaptive-container,与页面同坐标系、同缩放:
   // AI 弹框渲染进 #adaptive-container,与页面同坐标系、同缩放:
   // 布局层的 BottomSider 与本页右上用户下拉(z-index 均高于弹框)才能正常盖在弹框之上
   // 布局层的 BottomSider 与本页右上用户下拉(z-index 均高于弹框)才能正常盖在弹框之上
@@ -17,12 +19,13 @@
   onMounted(() => {
   onMounted(() => {
     // 标记 AI 首页:隐藏布局层右侧操作栏(天气/语音/模式切换等),仅保留本页内嵌的右上用户下拉
     // 标记 AI 首页:隐藏布局层右侧操作栏(天气/语音/模式切换等),仅保留本页内嵌的右上用户下拉
     document.body.classList.add('ai-home-page');
     document.body.classList.add('ai-home-page');
-    // 初始 false 再置 true,触发 AiAssistantModal 的 visible watcher 完成会话列表等初始化加载
-    visible.value = true;
+    // 告诉布局层本页已自备助手界面:不再挂载悬浮球与助手弹框,避免同一助手出现两份实例
+    pageProvidesAssistant.value = true;
   });
   });
 
 
   onUnmounted(() => {
   onUnmounted(() => {
     document.body.classList.remove('ai-home-page');
     document.body.classList.remove('ai-home-page');
+    pageProvidesAssistant.value = false;
   });
   });
 </script>
 </script>
 
 
@@ -31,8 +34,6 @@
     width: 100%;
     width: 100%;
     height: 100%;
     height: 100%;
   }
   }
-
-
 </style>
 </style>
 
 
 <!-- <style lang="less">
 <!-- <style lang="less">

+ 3 - 0
src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHome.vue

@@ -242,6 +242,8 @@
   import { useCamera } from '/@/hooks/system/useCamera';
   import { useCamera } from '/@/hooks/system/useCamera';
 
 
   const globalConfig = inject('globalConfig');
   const globalConfig = inject('globalConfig');
+  /** 二维模型的动画入口(页面 provide;三维模式下 modelRef 为空,调用即 no-op) */
+  const animate2D = inject<((data?: any) => void) | null>('animateGasPump2D', null);
 
 
   const props = defineProps({
   const props = defineProps({
     deviceId: {
     deviceId: {
@@ -299,6 +301,7 @@
               const data = await getDataSource(props.deviceId);
               const data = await getDataSource(props.deviceId);
               selectData.value = data;
               selectData.value = data;
               playAnimate(data);
               playAnimate(data);
+              animate2D?.(data); // 二维模型:同一份监测数据
               // Object.assign(selectData, data);
               // Object.assign(selectData, data);
             }
             }
             if (timer) {
             if (timer) {

+ 3 - 0
src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHomeBD.vue

@@ -69,6 +69,8 @@
   const { configs: rawConfigs, fetchConfigs } = useInitConfigs();
   const { configs: rawConfigs, fetchConfigs } = useInitConfigs();
 
 
   const globalConfig = inject<any>('globalConfig');
   const globalConfig = inject<any>('globalConfig');
+  /** 二维模型的动画入口(页面 provide;三维模式下 modelRef 为空,调用即 no-op) */
+  const animate2D = inject<((data?: any) => void) | null>('animateGasPump2D', null);
 
 
   const props = defineProps<{ deviceId: string; deviceType: string }>();
   const props = defineProps<{ deviceId: string; deviceType: string }>();
   // const [register, { openModal }] = useModal();
   // const [register, { openModal }] = useModal();
@@ -93,6 +95,7 @@
             const data = await getDataSource();
             const data = await getDataSource();
             selectData.value = data;
             selectData.value = data;
             playAnimate(data);
             playAnimate(data);
+            animate2D?.(data); // 二维模型:同一份监测数据
             // Object.assign(selectData, data);
             // Object.assign(selectData, data);
             if (timer) {
             if (timer) {
               timer = null;
               timer = null;

+ 3 - 0
src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHomeBet.vue

@@ -180,6 +180,8 @@
   import { get } from 'lodash-es';
   import { get } from 'lodash-es';
 
 
   const globalConfig = inject('globalConfig');
   const globalConfig = inject('globalConfig');
+  /** 二维模型的动画入口(页面 provide;三维模式下 modelRef 为空,调用即 no-op) */
+  const animate2D = inject<((data?: any) => void) | null>('animateGasPump2D', null);
 
 
   const props = defineProps({
   const props = defineProps({
     deviceId: {
     deviceId: {
@@ -243,6 +245,7 @@
               const data = await getDataSource(props.deviceId);
               const data = await getDataSource(props.deviceId);
               selectData.value = data;
               selectData.value = data;
               playAnimate(data);
               playAnimate(data);
+              animate2D?.(data); // 二维模型:同一份监测数据
               // Object.assign(selectData, data);
               // Object.assign(selectData, data);
             }
             }
             if (timer) {
             if (timer) {

+ 3 - 0
src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHomeCC.vue

@@ -213,6 +213,8 @@
   import ListItem from '@/views/vent/gas/components/list/listItem.vue';
   import ListItem from '@/views/vent/gas/components/list/listItem.vue';
 
 
   const globalConfig = inject('globalConfig');
   const globalConfig = inject('globalConfig');
+  /** 二维模型的动画入口(页面 provide;三维模式下 modelRef 为空,调用即 no-op) */
+  const animate2D = inject<((data?: any) => void) | null>('animateGasPump2D', null);
 
 
   const props = defineProps({
   const props = defineProps({
     deviceId: {
     deviceId: {
@@ -254,6 +256,7 @@
               const data = await getDataSource(props.deviceId);
               const data = await getDataSource(props.deviceId);
               selectData.value = data;
               selectData.value = data;
               playAnimate(data);
               playAnimate(data);
+              animate2D?.(data); // 二维模型:同一份监测数据
             }
             }
             if (timer) {
             if (timer) {
               timer = null;
               timer = null;

+ 3 - 0
src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpHomeCopy.vue

@@ -73,6 +73,8 @@
   import { set } from 'lodash-es';
   import { set } from 'lodash-es';
 
 
   const globalConfig = inject<any>('globalConfig');
   const globalConfig = inject<any>('globalConfig');
+  /** 二维模型的动画入口(页面 provide;三维模式下 modelRef 为空,调用即 no-op) */
+  const animate2D = inject<((data?: any) => void) | null>('animateGasPump2D', null);
 
 
   const props = defineProps({
   const props = defineProps({
     deviceId: {
     deviceId: {
@@ -113,6 +115,7 @@
               const data = await getDataSource();
               const data = await getDataSource();
               selectData.value = data;
               selectData.value = data;
               playAnimate(data);
               playAnimate(data);
+              animate2D?.(data); // 二维模型:同一份监测数据
               // Object.assign(selectData, data);
               // Object.assign(selectData, data);
             }
             }
             if (timer) {
             if (timer) {

Plik diff jest za duży
+ 4334 - 0
src/views/vent/monitorManager/gasPumpMonitor/components/gasPumpSVG.vue


+ 1 - 1
src/views/vent/monitorManager/gasPumpMonitor/gasPump.data.bet.ts

@@ -126,7 +126,7 @@ export const leftMonitor = [
     children: [
     children: [
       {
       {
         title: '',
         title: '',
-        childTitle: ['1#抑爆器'],
+        childTitle: ['抑爆器'],
         key: ['ExplosionSS1'],
         key: ['ExplosionSS1'],
         list: [
         list: [
           {
           {

+ 69 - 0
src/views/vent/monitorManager/gasPumpMonitor/gasPump.data.ts

@@ -590,3 +590,72 @@ export interface devicePropertyType {
   children: deviceChildType[];
   children: deviceChildType[];
   list: propertyListType[];
   list: propertyListType[];
 }
 }
+
+/** 二维 SVG 模型组件缓存(用于切换模型时不闪烁) */
+const componentsCaches = new Map<string, any>();
+
+/**
+ * 获取瓦斯抽采泵模型组件
+ *
+ * @param is2DModel 是否为二维模型;三维模型由 index.vue 的 mountedThree 负责,此处仅返回二维组件
+ * @param type 站型(页面 dataSource 里的 deviceType,如 pump_over / pump_under / pump_22m2pq)
+ */
+export function getModelComponent(is2DModel: boolean = false, type: string = '') {
+  if (!is2DModel) return null;
+
+  const getComponent = (name) => {
+    if (!componentsCaches.has(name)) {
+      componentsCaches.set(name, import(`./components/${name}.vue`));
+    }
+    return componentsCaches.get(name);
+  };
+
+  // @ts-ignore
+  return defineAsyncComponent(() => {
+    switch (type) {
+      // case 'pump_over':
+      // case 'pump_under':
+      // 目前地上/井下共用同一套二维场景,将来要分开时按站型指到不同组件即可
+      default:
+        return getComponent('gasPumpSVG');
+    }
+  });
+}
+
+/**
+ * 判断某台泵是否运行(与三维 gasPump.threejs.under.ts 的 isPumpRunning 同一套口径)
+ *
+ * 已注入 runSignals 时(布尔台两站)按泵运行状态点位判定:Number(data[code]) === 1;
+ * 未注入时(寸草塔1/2、保德等未接入该机制的站型)回落到进气/出气阀门开度,
+ * 避免这些站的流动动画因缺少运行点位而全部不动。
+ *
+ * @param data 子组件轮询得到的监测数据(与传给三维 playAnimate 的同一份)
+ * @param pumpNo 泵号(1 / 2)
+ * @param runSignals 该站的"泵号 → 运行点位"表,null 表示未接入
+ * @param tags 回落口径用的标签表(getModelMonitorTags() 的返回值)
+ */
+export function isPumpRunning(
+  data: any,
+  pumpNo: number,
+  runSignals: Record<number, { code: string }> | null,
+  tags: { domId: string; code: string }[]
+): boolean {
+  if (!data) return false;
+
+  const code = runSignals?.[pumpNo]?.code;
+  if (code) {
+    const raw = data[code];
+    if (raw === undefined || raw === null || raw === '') return false;
+    const value = Number(raw);
+    return Number.isFinite(value) && value === 1;
+  }
+
+  // 回落到旧的阀门开度口径:该泵的任一进气/出气阀门开启即认为有流动
+  const valvePrefixes = pumpNo === 1 ? ['jqf1', 'cqf1'] : ['jqf2', 'cqf2'];
+  return valvePrefixes.some((domId) => {
+    const tag = tags?.find((e: any) => e.domId === domId);
+    if (!tag) return false;
+    const value = Number(data[tag.code]);
+    return Number.isFinite(value) && value > 0;
+  });
+}

+ 7 - 27
src/views/vent/monitorManager/gasPumpMonitor/gasPump.threejs.under.ts

@@ -1,6 +1,6 @@
 import * as THREE from 'three';
 import * as THREE from 'three';
 import { CSS3DSprite } from 'three/examples/jsm/renderers/CSS3DRenderer.js';
 import { CSS3DSprite } from 'three/examples/jsm/renderers/CSS3DRenderer.js';
-import { getModelMonitorTags } from './gasPump.data';
+import { getModelMonitorTags, isPumpRunning } from './gasPump.data';
 import ArrowFlow from '../../comment/threejs/ArrowFlow';
 import ArrowFlow from '../../comment/threejs/ArrowFlow';
 // import * as dat from 'dat.gui';
 // import * as dat from 'dat.gui';
 // const gui = new dat.GUI();
 // const gui = new dat.GUI();
@@ -94,13 +94,14 @@ class gasPumpUnder {
   /**
   /**
    * 【核心不变式】泵号 → 该泵所接的管道箭头。左右分配只在这里写一次。
    * 【核心不变式】泵号 → 该泵所接的管道箭头。左右分配只在这里写一次。
    *
    *
-   * 1# 瓦斯泵 = 场景左侧管(A 组,x≈-9.3,管号与泵号一致)
-   * 2# 瓦斯泵 = 场景右侧管(B+R 组,x≈-5.5)
+   * 1# 瓦斯泵 = 场景左侧(A 组 = 母管→左侧三通的管道段,L 组 = 三通→泵体的连接段)
+   * 2# 瓦斯泵 = 场景右侧(B 组 = 管道段,R 组 = 连接段)
    *
    *
+   * 四类管路各两段:进气(redArrow) / 出气(blueArrow) / 进水 / 排水(greenArrow)。
    * 现场若与上述左右相反,只需调换本表两行,几何与动画代码一律不动。
    * 现场若与上述左右相反,只需调换本表两行,几何与动画代码一律不动。
    */
    */
   private readonly pumpArrows = {
   private readonly pumpArrows = {
-    1: ['airInA', 'airOutA', 'waterInA', 'waterOutA'],
+    1: ['airInA', 'airInL', 'airOutA', 'airOutL', 'waterInA', 'waterInL', 'waterOutA', 'waterOutL'],
     2: ['airInB', 'airInR', 'airOutB', 'airOutR', 'waterInB', 'waterInR', 'waterOutB', 'waterOutR'],
     2: ['airInB', 'airInR', 'airOutB', 'airOutR', 'waterInB', 'waterInR', 'waterOutB', 'waterOutR'],
   } as const;
   } as const;
 
 
@@ -116,31 +117,10 @@ class gasPumpUnder {
   }
   }
 
 
   /**
   /**
-   * 判断某台泵是否运行。
-   *
-   * 已注入 runSignals 时(布尔台两站)按泵运行状态判定;
-   * 未注入时(寸草塔1/2、保德等未接入该机制的站型)保持历史上的阀门口径回落,
-   * 避免这些站的箭头因本次改动而全部不再流动。
+   * 判断某台泵是否运行:口径抽到 gasPump.data.ts 的 isPumpRunning,二维模型共用同一份判定。
    */
    */
   private isPumpRunning(data: any, pumpNo: number): boolean {
   private isPumpRunning(data: any, pumpNo: number): boolean {
-    if (!data) return false;
-
-    const code = this.runSignals?.[pumpNo]?.code;
-    if (code) {
-      const raw = data[code];
-      if (raw === undefined || raw === null || raw === '') return false;
-      const value = Number(raw);
-      return Number.isFinite(value) && value === 1;
-    }
-
-    // 回落到旧的阀门开度口径:该泵的任一进气/出气阀门开启即认为有流动
-    const valvePrefixes = pumpNo === 1 ? ['jqf1', 'cqf1'] : ['jqf2', 'cqf2'];
-    return valvePrefixes.some((domId) => {
-      const tag = this.modelMonitorTags.find((e) => e.domId === domId);
-      if (!tag) return false;
-      const value = Number(data[tag.code]);
-      return Number.isFinite(value) && value > 0;
-    });
+    return isPumpRunning(data, pumpNo, this.runSignals, this.modelMonitorTags);
   }
   }
 
 
   /** 根据各泵运行状态控制动画:泵运行时该泵所接的水、气四路管道全部流动 */
   /** 根据各泵运行状态控制动画:泵运行时该泵所接的水、气四路管道全部流动 */

+ 48 - 11
src/views/vent/monitorManager/gasPumpMonitor/index.vue

@@ -1,10 +1,18 @@
 <template>
 <template>
   <div class="bg" style="width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; overflow: hidden">
   <div class="bg" style="width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; overflow: hidden">
     <a-spin :spinning="loading" />
     <a-spin :spinning="loading" />
-    <div id="gasPump3D" v-show="activeKey == 'monitor'" style="width: 100%; height: 100%; position: absolute; overflow: hidden"> </div>
+    <!-- 二维 SVG 模型(瓦斯抽采泵站,按泵运行状态点亮管路) -->
+    <component v-if="globalConfig?.is2DModel" ref="modelRef" :is="modelComponent" :device-type="currentDeviceType" style="z-index: 2" />
+    <!-- 三维模型容器(由 index.vue 的 mountedThree 初始化) -->
+    <div v-else id="gasPump3D" v-show="activeKey == 'monitor'" style="width: 100%; height: 100%; position: absolute; overflow: hidden"> </div>
     <div
     <div
       id="gas3DCSS"
       id="gas3DCSS"
-      v-show="activeKey == 'monitor' && !loading && (currentDeviceType.startsWith('pump_under') || currentDeviceType == 'pump_n12m2pq')"
+      v-show="
+        !globalConfig?.is2DModel &&
+        activeKey == 'monitor' &&
+        !loading &&
+        (currentDeviceType.startsWith('pump_under') || currentDeviceType == 'pump_n12m2pq')
+      "
       style="width: 100%; height: 100%; top: 0; left: 0; position: absolute; overflow: hidden; pointer-events: none"
       style="width: 100%; height: 100%; top: 0; left: 0; position: absolute; overflow: hidden; pointer-events: none"
     >
     >
     </div>
     </div>
@@ -59,10 +67,10 @@
 
 
 <script setup lang="ts">
 <script setup lang="ts">
   import customHeader from '/@/components/vent/customHeader.vue';
   import customHeader from '/@/components/vent/customHeader.vue';
-  import { ref, onMounted, onUnmounted, reactive, toRaw } from 'vue';
+  import { ref, onMounted, onUnmounted, reactive, toRaw, inject, shallowRef, provide } from 'vue';
   import { list } from './gasPump.api';
   import { list } from './gasPump.api';
   import BottomMenu from '/@/views/vent/comment/components/bottomMenu.vue';
   import BottomMenu from '/@/views/vent/comment/components/bottomMenu.vue';
-  import { getComponent } from './gasPump.data';
+  import { getComponent, getModelComponent } from './gasPump.data';
   import gasPumpHistory from './components/gasPumpHistory.vue';
   import gasPumpHistory from './components/gasPumpHistory.vue';
   import gasPumpHandleHistoryVue from './components/gasPumpHandleHistory.vue';
   import gasPumpHandleHistoryVue from './components/gasPumpHandleHistory.vue';
   import gasPumpAlarmHistory from './components/gasPumpAlarmHistory.vue';
   import gasPumpAlarmHistory from './components/gasPumpAlarmHistory.vue';
@@ -75,6 +83,20 @@
   const activeKey = ref('monitor');
   const activeKey = ref('monitor');
   const loading = ref(false);
   const loading = ref(false);
 
 
+  /** 全局配置(is2DModel 决定二维 / 三维模型) */
+  const globalConfig: any = inject('globalConfig');
+  /** 二维模型容器(三维由 mountedThree 负责,此处仅二维有值) */
+  const modelRef = ref();
+  // 注意:站型 currentDeviceType 在下面才声明,这里先用默认场景,选中站型后由 getSelectRow 刷新
+  const modelComponent = shallowRef(getModelComponent(globalConfig?.is2DModel));
+
+  /**
+   * 二维模型的动画入口,供子组件(gasPumpHome*)在轮询里调用:
+   * 子组件那边就是 animate2D(data) —— 与三维 playAnimate(data) 用同一份监测数据。
+   * 三维模式下 modelRef 为空,这里自然 no-op。
+   */
+  provide('animateGasPump2D', (data: any) => modelRef.value?.animate?.(data));
+
   const historyTable = ref();
   const historyTable = ref();
   const alarmHistoryTable = ref();
   const alarmHistoryTable = ref();
   const handlerHistoryTable = ref();
   const handlerHistoryTable = ref();
@@ -98,7 +120,7 @@
   });
   });
 
 
   function changeActive(activeValue) {
   function changeActive(activeValue) {
-    if (activeKey.value == 'monitor') {
+    if (activeKey.value == 'monitor' && !globalConfig?.is2DModel) {
       if (currentDeviceType.value == 'pump_over') {
       if (currentDeviceType.value == 'pump_over') {
         setModelType('gasPump');
         setModelType('gasPump');
       } else if (currentDeviceType.value.startsWith('pump_under')) {
       } else if (currentDeviceType.value.startsWith('pump_under')) {
@@ -171,18 +193,31 @@
       optionValue.value = currentData['deviceID'];
       optionValue.value = currentData['deviceID'];
       currentDeviceType.value = currentData['deviceType'];
       currentDeviceType.value = currentData['deviceType'];
       Object.assign(selectData, currentData);
       Object.assign(selectData, currentData);
-      if (currentDeviceType.value == 'pump_over') {
-        setModelType('gasPump');
-      } else if (currentDeviceType.value.startsWith('pump_under')) {
-        setModelType('gasPumpUnder');
+      if (globalConfig?.is2DModel) {
+        // 二维:换站型时切到对应场景(当前地上/井下共用一套),并立刻刷新一次流动状态
+        // 换站型时切到对应场景(当前地上/井下共用一套),动画由子组件轮询驱动
+        modelComponent.value = getModelComponent(true, currentDeviceType.value);
       } else {
       } else {
-        setModelType('gasPumpUnder');
+        if (currentDeviceType.value == 'pump_over') {
+          setModelType('gasPump');
+        } else if (currentDeviceType.value.startsWith('pump_under')) {
+          setModelType('gasPumpUnder');
+        } else {
+          setModelType('gasPumpUnder');
+        }
       }
       }
     }
     }
   }
   }
 
 
   onMounted(async () => {
   onMounted(async () => {
     if (currentRoute.value && currentRoute.value['query'] && currentRoute.value['query']['id']) optionValue.value = currentRoute.value['query']['id'];
     if (currentRoute.value && currentRoute.value['query'] && currentRoute.value['query']['id']) optionValue.value = currentRoute.value['query']['id'];
+    // 二维模式:不初始化三维,只加载数据;动画由子组件(gasPumpHome*)轮询时调 animate2D(data) 驱动
+    if (globalConfig?.is2DModel) {
+      await getSysDataSource();
+      await getDeviceList();
+      getSelectRow(optionValue.value);
+      return;
+    }
     mountedThree().then(async () => {
     mountedThree().then(async () => {
       await getSysDataSource();
       await getSysDataSource();
       await getDeviceList();
       await getDeviceList();
@@ -191,7 +226,9 @@
   });
   });
 
 
   onUnmounted(() => {
   onUnmounted(() => {
-    destroy();
+    if (!globalConfig?.is2DModel) {
+      destroy();
+    }
   });
   });
 </script>
 </script>
 <style lang="less" scoped>
 <style lang="less" scoped>

+ 12 - 2
src/views/vent/monitorManager/groutMonitor/components/groutHome.vue

@@ -150,7 +150,7 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import { onBeforeMount, ref, onMounted, onUnmounted, shallowRef, ComponentOptions, reactive } from 'vue';
+  import { onBeforeMount, ref, onMounted, onUnmounted, shallowRef, ComponentOptions, reactive, inject } from 'vue';
   import { mountedThree, destroy, setModelType } from '../grout.threejs';
   import { mountedThree, destroy, setModelType } from '../grout.threejs';
   import { ScrollBoard as DvScrollBoard } from '@kjgl77/datav-vue3';
   import { ScrollBoard as DvScrollBoard } from '@kjgl77/datav-vue3';
   import ventBox1 from '/@/components/vent/ventBox1.vue';
   import ventBox1 from '/@/components/vent/ventBox1.vue';
@@ -171,6 +171,8 @@
     },
     },
   });
   });
   const left: string = '0px';
   const left: string = '0px';
+  /** 全局配置(is2DModel 为 true 时使用二维 SVG 模型,不初始化三维) */
+  const globalConfig: any = inject('globalConfig');
   const currentModal = shallowRef<Nullable<ComponentOptions>>(null); //模态框
   const currentModal = shallowRef<Nullable<ComponentOptions>>(null); //模态框
   const modalVisible = ref<Boolean>(false); // 模态框是否可见
   const modalVisible = ref<Boolean>(false); // 模态框是否可见
   const loading = ref(false);
   const loading = ref(false);
@@ -264,6 +266,12 @@
 
 
   onMounted(() => {
   onMounted(() => {
     loading.value = true;
     loading.value = true;
+    // 二维模式:不初始化三维(二维 SVG 由 index.vue 渲染,容器 #grout3D 不渲染)
+    if (globalConfig?.is2DModel) {
+      loading.value = false;
+      timer = null;
+      return;
+    }
     mountedThree().then(async () => {
     mountedThree().then(async () => {
       // await setModelType('groutBase');
       // await setModelType('groutBase');
       await setModelType('bertaiBase');
       await setModelType('bertaiBase');
@@ -273,7 +281,9 @@
     });
     });
   });
   });
   onUnmounted(() => {
   onUnmounted(() => {
-    destroy();
+    if (!globalConfig?.is2DModel) {
+      destroy();
+    }
     if (timer) {
     if (timer) {
       clearTimeout(timer);
       clearTimeout(timer);
       timer = undefined;
       timer = undefined;

+ 12 - 2
src/views/vent/monitorManager/groutMonitor/components/groutHomeHjt.vue

@@ -47,7 +47,7 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import { ref, onMounted, onUnmounted, watch, onBeforeUnmount } from 'vue';
+  import { ref, onMounted, onUnmounted, watch, onBeforeUnmount, inject } from 'vue';
   import { mountedThree, destroy, setModelType } from '../grout.threejs';
   import { mountedThree, destroy, setModelType } from '../grout.threejs';
   import { list } from '../grout.api';
   import { list } from '../grout.api';
   import { useCamera } from '/@/hooks/system/useCamera';
   import { useCamera } from '/@/hooks/system/useCamera';
@@ -63,6 +63,9 @@
     },
     },
   });
   });
 
 
+  /** 全局配置(is2DModel 为 true 时使用二维 SVG 模型,不初始化三维) */
+  const globalConfig: any = inject('globalConfig');
+
   const loading = ref(false);
   const loading = ref(false);
   const monitorData = ref({ InputFlux: undefined });
   const monitorData = ref({ InputFlux: undefined });
   const playerRef = ref();
   const playerRef = ref();
@@ -116,6 +119,11 @@
     timer = null;
     timer = null;
     getMonitor(true);
     getMonitor(true);
     loading.value = true;
     loading.value = true;
+    // 二维模式:不初始化三维(二维 SVG 由 index.vue 渲染,容器 #grout3D 不渲染)
+    if (globalConfig?.is2DModel) {
+      loading.value = false;
+      return;
+    }
     mountedThree().then(async () => {
     mountedThree().then(async () => {
       await setModelType('bertaiBase');
       await setModelType('bertaiBase');
       loading.value = false;
       loading.value = false;
@@ -125,7 +133,9 @@
     removeCamera(playerRef);
     removeCamera(playerRef);
   });
   });
   onUnmounted(() => {
   onUnmounted(() => {
-    destroy();
+    if (!globalConfig?.is2DModel) {
+      destroy();
+    }
     if (timer) {
     if (timer) {
       clearTimeout(timer);
       clearTimeout(timer);
       timer = undefined;
       timer = undefined;

+ 13 - 2
src/views/vent/monitorManager/groutMonitor/components/groutHomeJj.vue

@@ -36,7 +36,7 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import { watch, ref, onMounted, onUnmounted, reactive, onBeforeUnmount } from 'vue';
+  import { watch, ref, onMounted, onUnmounted, reactive, onBeforeUnmount, inject } from 'vue';
   import { mountedThree, destroy, setModelType } from '../grout.threejs';
   import { mountedThree, destroy, setModelType } from '../grout.threejs';
   import { deviceMonitorList } from '../grout.data';
   import { deviceMonitorList } from '../grout.data';
   import { list } from '../grout.api';
   import { list } from '../grout.api';
@@ -53,6 +53,9 @@
     },
     },
   });
   });
 
 
+  /** 全局配置(is2DModel 为 true 时使用二维 SVG 模型,不初始化三维) */
+  const globalConfig: any = inject('globalConfig');
+
   const loading = ref(false);
   const loading = ref(false);
   const monitorData = ref({});
   const monitorData = ref({});
   // 监测数据
   // 监测数据
@@ -109,6 +112,12 @@
 
 
   onMounted(() => {
   onMounted(() => {
     loading.value = true;
     loading.value = true;
+    // 二维模式:不初始化三维(二维 SVG 由 index.vue 渲染,容器 #grout3D 不渲染)
+    if (globalConfig?.is2DModel) {
+      loading.value = false;
+      timer = null;
+      return;
+    }
     mountedThree().then(async () => {
     mountedThree().then(async () => {
       // await setModelType('groutBase');
       // await setModelType('groutBase');
       await setModelType('bertaiBase');
       await setModelType('bertaiBase');
@@ -121,7 +130,9 @@
     removeCamera(playerRef);
     removeCamera(playerRef);
   });
   });
   onUnmounted(() => {
   onUnmounted(() => {
-    destroy();
+    if (!globalConfig?.is2DModel) {
+      destroy();
+    }
     if (timer) {
     if (timer) {
       clearTimeout(timer);
       clearTimeout(timer);
       timer = undefined;
       timer = undefined;

+ 13 - 2
src/views/vent/monitorManager/groutMonitor/components/groutHomehlg.vue

@@ -70,7 +70,7 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import { watch, ref, onMounted, onUnmounted, onBeforeUnmount } from 'vue';
+  import { watch, ref, onMounted, onUnmounted, onBeforeUnmount, inject } from 'vue';
   import ventBox1 from '/@/components/vent/ventBox1.vue';
   import ventBox1 from '/@/components/vent/ventBox1.vue';
   import { mountedThree, destroy, setModelType } from '../grout.threejs';
   import { mountedThree, destroy, setModelType } from '../grout.threejs';
   import { formatNum } from '/@/utils/ventutil';
   import { formatNum } from '/@/utils/ventutil';
@@ -91,6 +91,9 @@
 
 
   let monitorDataGroupNum = ref(1);
   let monitorDataGroupNum = ref(1);
 
 
+  /** 全局配置(is2DModel 为 true 时使用二维 SVG 模型,不初始化三维) */
+  const globalConfig: any = inject('globalConfig');
+
   const loading = ref(false);
   const loading = ref(false);
   const monitorData = ref({});
   const monitorData = ref({});
 
 
@@ -149,6 +152,12 @@
       if (props.deviceId) await getCamera(props.deviceId, playerRef, renderPlayer, 'pulping');
       if (props.deviceId) await getCamera(props.deviceId, playerRef, renderPlayer, 'pulping');
     });
     });
     loading.value = true;
     loading.value = true;
+    // 二维模式:不初始化三维(二维 SVG 由 index.vue 渲染,容器 #grout3D 不渲染)
+    if (globalConfig?.is2DModel) {
+      loading.value = false;
+      timer = null;
+      return;
+    }
     mountedThree().then(async () => {
     mountedThree().then(async () => {
       // await setModelType('groutBase');
       // await setModelType('groutBase');
       await setModelType('bertaiBase');
       await setModelType('bertaiBase');
@@ -162,7 +171,9 @@
   });
   });
 
 
   onUnmounted(() => {
   onUnmounted(() => {
-    destroy();
+    if (!globalConfig?.is2DModel) {
+      destroy();
+    }
     if (timer) {
     if (timer) {
       clearTimeout(timer);
       clearTimeout(timer);
       timer = undefined;
       timer = undefined;

+ 13 - 2
src/views/vent/monitorManager/groutMonitor/components/groutHomelt.vue

@@ -100,7 +100,7 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import { watch, ref, onMounted, onUnmounted, onBeforeUnmount } from 'vue';
+  import { watch, ref, onMounted, onUnmounted, onBeforeUnmount, inject } from 'vue';
   import ventBox1 from '/@/components/vent/ventBox1.vue';
   import ventBox1 from '/@/components/vent/ventBox1.vue';
   import { mountedThree, destroy, setModelType } from '../grout.threejs';
   import { mountedThree, destroy, setModelType } from '../grout.threejs';
   import { formatNum } from '/@/utils/ventutil';
   import { formatNum } from '/@/utils/ventutil';
@@ -121,6 +121,9 @@
 
 
   let monitorDataGroupNum = ref(1);
   let monitorDataGroupNum = ref(1);
 
 
+  /** 全局配置(is2DModel 为 true 时使用二维 SVG 模型,不初始化三维) */
+  const globalConfig: any = inject('globalConfig');
+
   const loading = ref(false);
   const loading = ref(false);
   const monitorData = ref({});
   const monitorData = ref({});
 
 
@@ -178,6 +181,12 @@
       if (props.deviceId) await getCamera(props.deviceId, playerRef, renderPlayer, 'pulping');
       if (props.deviceId) await getCamera(props.deviceId, playerRef, renderPlayer, 'pulping');
     });
     });
     loading.value = true;
     loading.value = true;
+    // 二维模式:不初始化三维(二维 SVG 由 index.vue 渲染,容器 #grout3D 不渲染)
+    if (globalConfig?.is2DModel) {
+      loading.value = false;
+      timer = null;
+      return;
+    }
     mountedThree().then(async () => {
     mountedThree().then(async () => {
       // await setModelType('groutBase');
       // await setModelType('groutBase');
       await setModelType('bertaiBase');
       await setModelType('bertaiBase');
@@ -189,7 +198,9 @@
     removeCamera(playerRef);
     removeCamera(playerRef);
   });
   });
   onUnmounted(() => {
   onUnmounted(() => {
-    destroy();
+    if (!globalConfig?.is2DModel) {
+      destroy();
+    }
     if (timer) {
     if (timer) {
       clearTimeout(timer);
       clearTimeout(timer);
       timer = undefined;
       timer = undefined;

Plik diff jest za duży
+ 1501 - 0
src/views/vent/monitorManager/groutMonitor/components/zhijiangSVG.vue


+ 29 - 0
src/views/vent/monitorManager/groutMonitor/grout.data.ts

@@ -764,3 +764,32 @@ export function getMonitorComponent() {
       return groutHome;
       return groutHome;
   }
   }
 }
 }
+
+/** 二维 SVG 模型组件缓存(用于切换模型时不闪烁) */
+const componentsCaches = new Map<string, any>();
+
+/**
+ * 获取制浆模型组件
+ *
+ * @param is2DModel 是否为二维模型;三维模型由 groutHome*.vue 的 mountedThree 负责,此处仅返回二维组件
+ * @param type 模型类型(与页面 strtype 对应,如 sys_pulping)
+ */
+export function getModelComponent(is2DModel: boolean = false, type: string = '') {
+  if (!is2DModel) return null;
+
+  const getComponent = (name) => {
+    if (!componentsCaches.has(name)) {
+      componentsCaches.set(name, import(`./components/${name}.vue`));
+    }
+    return componentsCaches.get(name);
+  };
+
+  // @ts-ignore
+  return defineAsyncComponent(() => {
+    switch (type) {
+      // case 'sys_pulping':
+      default:
+        return getComponent('zhijiangSVG');
+    }
+  });
+}

+ 11 - 3
src/views/vent/monitorManager/groutMonitor/index.vue

@@ -2,7 +2,10 @@
   <div class="bg"
   <div class="bg"
     style="width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; overflow: hidden">
     style="width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; overflow: hidden">
     <a-spin :spinning="loading" />
     <a-spin :spinning="loading" />
-    <div id="grout3D" v-show="!loading" style="width: 100%; height: 100%; position: absolute; overflow: hidden"> </div>
+    <!-- 二维 SVG 模型(制浆工艺流程图:管路流动箭头 + 脉冲元素) -->
+    <component v-if="globalConfig?.is2DModel" :is="modelComponent" style="z-index: 2" />
+    <!-- 三维模型容器(由 groutHome*.vue 的 mountedThree 初始化) -->
+    <div v-else id="grout3D" v-show="!loading" style="width: 100%; height: 100%; position: absolute; overflow: hidden"> </div>
     <!-- <div id="damper3DCSS" v-show="!loading" style="width: 100%; height: 100%; top:0; left: 0; position: absolute; overflow: hidden;">
     <!-- <div id="damper3DCSS" v-show="!loading" style="width: 100%; height: 100%; top:0; left: 0; position: absolute; overflow: hidden;">
       <div>
       <div>
         <div ref="elementContent" class="elementContent">
         <div ref="elementContent" class="elementContent">
@@ -35,15 +38,20 @@
 
 
 <script setup lang="ts">
 <script setup lang="ts">
 import customHeader from '/@/components/vent/customHeader.vue';
 import customHeader from '/@/components/vent/customHeader.vue';
-import { ref, onMounted, onUnmounted, reactive, toRaw } from 'vue';
+import { ref, onMounted, onUnmounted, reactive, toRaw, inject, shallowRef } from 'vue';
 import { systemList, getTableList } from './grout.api';
 import { systemList, getTableList } from './grout.api';
 import BottomMenu from '/@/views/vent/comment/components/bottomMenu.vue';
 import BottomMenu from '/@/views/vent/comment/components/bottomMenu.vue';
-import {getMonitorComponent} from './grout.data'
+import { getMonitorComponent, getModelComponent } from './grout.data'
 import groutHistory from './components/groutHistory.vue';
 import groutHistory from './components/groutHistory.vue';
 import groutHandleHistoryVue from './components/groutHandleHistory.vue';
 import groutHandleHistoryVue from './components/groutHandleHistory.vue';
 import groutAlarmHistory from './components/groutAlarmHistory.vue';
 import groutAlarmHistory from './components/groutAlarmHistory.vue';
 import { useRouter } from 'vue-router';
 import { useRouter } from 'vue-router';
 
 
+/** 全局配置(is2DModel 决定二维 / 三维模型) */
+const globalConfig: any = inject('globalConfig');
+/** 模型对应的组件,根据配置分为二维三维;三维由 groutHome*.vue 的 mountedThree 负责,此处仅二维有值 */
+const modelComponent = shallowRef(getModelComponent(globalConfig?.is2DModel, 'sys_pulping'));
+
 const groutHome = getMonitorComponent()
 const groutHome = getMonitorComponent()
 type DeviceType = { deviceType: string, deviceName: string, datalist: any[] };
 type DeviceType = { deviceType: string, deviceName: string, datalist: any[] };
 const activeKey = ref('monitor');
 const activeKey = ref('monitor');

+ 361 - 0
src/views/vent/testChart/FiberTempChart.vue

@@ -0,0 +1,361 @@
+<template>
+  <div class="fiber-temp-chart-wrap">
+    <div class="chart-header">
+      <div class="chart-title">{{ title }}</div>
+      <div class="chart-header-desc">
+        <div class="desc-item">
+          <span class="color-block normal"></span>
+          <span class="text">正常</span>
+        </div>
+        <div class="desc-item">
+          <span class="color-block alarm"></span>
+          <span class="text">报警</span>
+        </div>
+      </div>
+    </div>
+    <div class="chart-container" ref="chartDomRef"></div>
+  </div>
+</template>
+
+<script setup>
+import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
+import * as echarts from 'echarts';
+import firePng from '@/assets/images/fire.png';
+import warningSVG from '@/assets/images/warning.svg';
+
+const props = defineProps({
+  xAxisData: {
+    type: Array,
+    default: () => [],
+  },
+  seriesData: {
+    type: Array,
+    default: () => [],
+  },
+  maxValue: {
+    type: Number,
+    default: 60,
+  },
+  minValue: {
+    type: Number,
+    default: 20,
+  },
+  height: {
+    type: String,
+    default: '440px',
+  },
+  title: {
+    type: String,
+    default: '光纤测温',
+  },
+});
+
+const emit = defineEmits(['chart-click']);
+
+const chartDomRef = ref(null);
+let chartInstance = null;
+
+function disposeChart() {
+  if (chartInstance) {
+    chartInstance.dispose();
+    chartInstance = null;
+  }
+}
+
+function initChart() {
+  if (!chartDomRef.value) return;
+  disposeChart();
+  chartInstance = echarts.init(chartDomRef.value);
+  renderOption();
+  chartInstance.on('click', (params) => {
+    emit('chart-click', params);
+  });
+  window.addEventListener('resize', handleResize);
+}
+
+function handleResize() {
+  chartInstance?.resize();
+}
+
+function getMarkPointData() {
+  const markPoints = [];
+  const { seriesData, minValue, maxValue } = props;
+  seriesData.forEach((val, idx) => {
+    if (val < minValue || val >= maxValue) {
+      markPoints.push({
+        coord: [idx, val + 15],
+        symbol: `image://${val < minValue ? warningSVG : firePng}`,
+        symbolSize: [60, 60],
+        tooltip: {
+          formatter: `<div style="color:#ff4422;font-weight:bold">异常确认<br/>立即排查</div>`,
+        },
+      });
+    }
+  });
+  return markPoints;
+}
+
+// 获取报警区域的连续数据(包含边界点)
+function getAlarmContinuousData(data, minValue, maxValue) {
+  const result = [];
+  const n = data.length;
+
+  const isAlarm = (val) => val < minValue || val >= maxValue;
+
+  for (let i = 0; i < n; i++) {
+    const val = data[i];
+    if (isAlarm(val)) {
+      // 当前点是报警点,保留原值
+      result.push(val);
+    } else {
+      // 检查相邻点是否有报警点,如果有则保留当前值作为边界
+      const prevAlarm = i > 0 && isAlarm(data[i - 1]);
+      const nextAlarm = i < n - 1 && isAlarm(data[i + 1]);
+
+      if (prevAlarm || nextAlarm) {
+        // 使用实际边界值,与正常折线在切换点保持连续
+        result.push(val);
+      } else {
+        result.push(null);
+      }
+    }
+  }
+  return result;
+}
+
+function getAlarmPointData(data, minValue, maxValue) {
+  return data.map((val) => (val < minValue || val >= maxValue ? val : null));
+}
+
+function renderOption() {
+  if (!chartInstance) return;
+  const { xAxisData, seriesData, minValue, maxValue } = props;
+
+  // 报警填充数据:包含实际边界点,使报警区域连续
+  const alarmFillData = getAlarmContinuousData(seriesData, minValue, maxValue);
+
+  // 报警折线数据:只保留超过上限或低于下限的点
+  const alarmLineData = getAlarmPointData(seriesData, minValue, maxValue);
+
+  // 正常填充数据:仅保留上下限之间的值,报警值设为 null
+  const normalFillData = seriesData.map((val) => (val >= minValue && val < maxValue ? val : null));
+
+  // 正常折线数据:只保留上下限之间的点
+  const normalLineData = seriesData.map((val) => (val >= minValue && val < maxValue ? val : null));
+
+  const option = {
+    backgroundColor: 'transparent',
+    title: { show: false },
+    tooltip: {
+      trigger: 'axis',
+      backgroundColor: 'rgba(0,20,40,0.85)',
+      borderColor: '#2ef3ef',
+      textStyle: { color: '#fff' },
+      formatter: function (params) {
+        const dataIndex = params[0]?.dataIndex;
+        if (dataIndex !== undefined && seriesData[dataIndex] !== undefined) {
+          const val = seriesData[dataIndex];
+          const isAlarm = val < minValue || val >= maxValue;
+          return `<div style="font-weight:bold;${isAlarm ? 'color:#ff8800' : 'color:#35d9e5'}">
+            ${xAxisData[dataIndex] || ''}<br/>
+            温度:${val}℃${isAlarm ? ' 超限' : ''}
+          </div>`;
+        }
+        return '';
+      },
+    },
+    legend: { show: false },
+    grid: {
+      left: '4%',
+      right: '3%',
+      top: '18%',
+      bottom: '18%',
+    },
+    xAxis: {
+      type: 'category',
+      data: xAxisData,
+      axisLine: { lineStyle: { color: '#2ef3ef' } },
+      axisLabel: { color: '#ffffff', fontSize: 14 },
+      splitLine: { show: true, lineStyle: { color: 'rgba(46,243,239,0.15)' } },
+    },
+    yAxis: {
+      type: 'value',
+      name: '温度℃',
+      nameTextStyle: { color: '#fff' },
+      min: 0,
+      max: 130,
+      axisLine: { lineStyle: { color: '#2ef3ef' } },
+      axisLabel: { color: '#ffffff' },
+      splitLine: { lineStyle: { color: 'rgba(46,243,239,0.15)' } },
+    },
+    dataZoom: [
+      {
+        type: 'slider',
+        show: true,
+        height: 20,
+        bottom: 8,
+        backgroundColor: '#152c47',
+        fillerColor: '#2ef3ef66',
+        handleStyle: { color: '#2ef3ef' },
+        textStyle: { color: '#ffffff' },
+        start: 0,
+        end: 30,
+        zoomLock: false,
+      },
+      { type: 'inside' },
+    ],
+    series: [
+      // 正常填充(青色)
+      {
+        name: '正常填充',
+        type: 'line',
+        smooth: true,
+        data: normalFillData,
+        symbol: 'none',
+        connectNulls: false,
+        lineStyle: { width: 0 },
+        tooltip: { show: false },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: 'rgba(53,217,229,0.35)' },
+            { offset: 1, color: 'rgba(53,217,229,0.05)' },
+          ]),
+        },
+        z: 1,
+      },
+      // 报警填充(橙色)- 包含边界点,形成连续填充
+      {
+        name: '报警填充',
+        type: 'line',
+        smooth: true,
+        data: alarmFillData,
+        symbol: 'none',
+        connectNulls: false,
+        lineStyle: { width: 0 },
+        tooltip: { show: false },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: 'rgba(255,136,0,0.5)' },
+            { offset: 1, color: 'rgba(255,136,0,0.05)' },
+          ]),
+        },
+        z: 2,
+      },
+      // 正常折线(青色)
+      {
+        name: '正常测温',
+        type: 'line',
+        smooth: true,
+        data: normalLineData,
+        symbol: 'circle',
+        symbolSize: 8,
+        connectNulls: false,
+        lineStyle: { width: 3, color: '#35d9e5' },
+        itemStyle: { color: '#35d9e5' },
+        z: 3,
+      },
+      // 报警折线(橙色)- 包含边界点,形成连续折线
+      {
+        name: '报警测温',
+        type: 'line',
+        smooth: true,
+        data: alarmLineData,
+        symbol: 'circle',
+        symbolSize: 10,
+        connectNulls: false,
+        lineStyle: { width: 3, color: '#ff8800' },
+        itemStyle: {
+          color: function (params) {
+            const val = params.data;
+            // 边界点(等于阈值)不显示圆点
+            if (val === minValue || val === maxValue) return 'transparent';
+            return '#ff8800';
+          },
+        },
+        markPoint: {
+          data: getMarkPointData(),
+          offset: [0, -100],
+        },
+        markLine: {
+          silent: true,
+          lineStyle: { type: 'dashed', width: 2, color: '#ff2222' },
+          label: { show: true, position: 'end', fontSize: 12, color: '#ff2222' },
+          data: [
+            { yAxis: minValue, label: { formatter: '下限阈值' } },
+            { yAxis: maxValue, label: { formatter: '上限阈值' } },
+          ],
+        },
+        z: 4,
+      },
+    ],
+  };
+
+  chartInstance.setOption(option, true);
+}
+
+watch(
+  () => [props.xAxisData, props.seriesData, props.minValue, props.maxValue],
+  () => {
+    nextTick(() => renderOption());
+  },
+  { deep: true }
+);
+
+onMounted(() => {
+  nextTick(() => initChart());
+});
+
+onBeforeUnmount(() => {
+  disposeChart();
+  window.removeEventListener('resize', handleResize);
+});
+</script>
+
+<style scoped>
+.fiber-temp-chart-wrap {
+  width: 100%;
+  height: v-bind(height);
+  display: flex;
+  flex-direction: column;
+}
+.chart-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 10px 16px;
+}
+.chart-title {
+  color: #ffffff;
+  font-size: 24px;
+  font-weight: bold;
+}
+.chart-header-desc {
+  display: flex;
+  gap: 32px;
+  align-items: center;
+}
+.desc-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.color-block {
+  width: 32px;
+  height: 12px;
+  border-radius: 2px;
+}
+.color-block.normal {
+  background-color: #35d9e5;
+}
+.color-block.alarm {
+  background-color: #ff8800;
+}
+.text {
+  color: #ffffff;
+  font-size: 16px;
+}
+.chart-container {
+  flex: 1;
+  width: 100%;
+}
+</style>

+ 46 - 0
src/views/vent/testChart/index.vue

@@ -0,0 +1,46 @@
+<template>
+  <a-card style="background: #0b142b; border: 1px solid #2ef3ef">
+    <FiberTempChart
+      :x-axis-data="xAxisList"
+      :series-data="tempValueList"
+      :max-value="60"
+      :min-value="20"
+      title="光纤测温"
+      height="440px"
+      @chart-click="onChartClick"
+    />
+  </a-card>
+</template>
+<script setup>
+import FiberTempChart from './FiberTempChart.vue';
+const pointData = [
+  { name: '测点01', temp: 24.2 },
+  { name: '测点02', temp: 28.5 },
+  { name: '测点03', temp: 32.1 },
+  { name: '测点04', temp: 30.6 },
+  { name: '测点05', temp: 26.8 },
+  { name: '测点06', temp: 33.4 },
+  { name: '测点07', temp: 26.9 },
+  { name: '测点08', temp: 11.2 },
+  { name: '测点09', temp: 40.5 },
+  { name: '测点10', temp: 45.3 },
+  { name: '测点11', temp: 38.7 },
+  { name: '测点12', temp: 22.6 },
+  { name: '测点13', temp: 51.8 },
+  { name: '测点14', temp: 30.2 },
+  { name: '测点15', temp: 80 }, // 超上限
+  { name: '测点16', temp: 58.1 },
+  { name: '测点17', temp: 48.6 },
+  { name: '测点18', temp: 39.4 },
+  { name: '测点19', temp: 34.7 },
+  { name: '测点20', temp: 19.3 },
+];
+
+// 提取x轴、温度数组
+const xAxisList = pointData.map((item) => item.name);
+const tempValueList = pointData.map((item) => item.temp);
+
+function onChartClick(params) {
+  console.log('点击测点', params);
+}
+</script>

+ 16 - 73
src/views/ventAI/dataPicker/api.ts

@@ -1,8 +1,6 @@
+// dataPicker/api.ts:点选解读(巷道 / 设备)的 SSE 流式接口。
+import { streamPost } from '/@/views/ventAI/manageAssistent/api/streamPost';
 import type { SSERawEvent } from './types';
 import type { SSERawEvent } from './types';
-// 【改动 M1】取基座登录态的令牌(与对话链路 manageAssistent/api.ts 同源)
-import { getToken } from '/@/utils/auth';
-// 【改动 M2】复用对话框模块既有的结构化错误解析器,避免两套实现各自演化
-import { throwStreamHttpError } from '/@/views/ventAI/manageAssistent/api';
 
 
 enum Api {
 enum Api {
   clickTun = '/ventAI/api/interpret/click/tun',
   clickTun = '/ventAI/api/interpret/click/tun',
@@ -35,79 +33,24 @@ export async function fetchDeviceInterpretation(
   return ssePost(Api.clickDevice, params, onEvent);
   return ssePost(Api.clickDevice, params, onEvent);
 }
 }
 
 
-/** SSE 流式 POST 请求的通用实现 */
+/**
+ * SSE 流式 POST:请求体、令牌、错误解码、事件消费与中断处理统一交给 api/streamPost。
+ *
+ * [审计-第四轮 M1/M2] 重放自我方 fix/base-agent-token(M1/M2/M3)。本链路原先自造过 fetch + 错误处理,
+ * 收敛后语义由 streamPost 承接,在此留档,避免后续被当成「多余代码」误改:
+ * 1. 令牌是「可选」语义:getToken() 为空时仍照常发请求(TfAgents 侧有 click-anonymous 兜底)。
+ *    禁止加「未登录就拦截/跳登录」的判断(我方 D5 决策)——那会把未登录场景直接变成不可用。
+ *    补令牌的原因:原先是基座里唯一一条匿名调 TfAgents 的路径,消耗只能记到兜底账号,无法按登录人记账。
+ * 2. 非 2xx 复用 throwStreamHttpError 解析响应体 detail,抛出带 status/retryAfter/limitTokens/spentTokens
+ *    的 StreamError(文案由 useDataPicker 的 formatClickError() 渲染);原实现只抛「HTTP错误: 429」,
+ *    把后端给出的原因(额度耗尽等)整个丢掉。
+ * 3. 该接口只把 done / error 当终止事件(与统一对话不同,不含 interrupt)。
+ */
 async function ssePost(
 async function ssePost(
   url: string,
   url: string,
   body: Record<string, string>,
   body: Record<string, string>,
   onEvent: (event: SSERawEvent) => void
   onEvent: (event: SSERawEvent) => void
 ): Promise<{ thread_id: string; session_id: string }> {
 ): Promise<{ thread_id: string; session_id: string }> {
-  const response = await fetch(url, {
-    method: 'POST',
-    headers: {
-      'Content-Type': 'application/json',
-      // 【改动 M1 · 对应 TfAgents 决策 D5 / B1-20】
-      // 点选解读原先不带令牌,是基座里唯一一条「匿名」调 TfAgents 的路径,
-      // 后果:消耗只能记到兜底账号 click-anonymous,无法按登录人记账。
-      // 现在补上与对话链路同名的请求头,TfAgents 侧读的是小写 x-access-token(HTTP 头不区分大小写)。
-      // ⚠️ 令牌是「可选」语义:getToken() 为空时仍照常发请求(TfAgents 侧有 click-anonymous 兜底),
-      //    因此这里不能加「未登录就拦截/跳登录」的判断——那会把未登录场景直接变成不可用。
-      'X-Access-Token': getToken(),
-    },
-    body: JSON.stringify(body),
-  });
-
-  if (!response.ok) {
-    // 【改动 M2 · 对应 TfAgents 决策 D6 / B1-15 / B1-19 / B1-08】
-    // 原先:throw new Error(`HTTP错误: ${response.status}`) —— 用户只看到「HTTP错误: 429」。
-    // 现在:复用 manageAssistent/api.ts 的 throwStreamHttpError,它会解析响应体的
-    //       detail(字符串或 { code, message, retry_after, limit_tokens, spent_tokens }),
-    //       抛出携带 status/retryAfter/limitTokens/spentTokens 的 StreamError;
-    //       最终文案由 useDataPicker.ts 的 formatClickError() 统一渲染。
-    await throwStreamHttpError(response);
-  }
-
-  const reader = response.body!.getReader();
-  const decoder = new TextDecoder();
-  let buffer = '';
-  let threadId = '';
-  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 event: SSERawEvent = JSON.parse(dataStr);
-          onEvent(event);
-
-          if (event.type === 'done') {
-            threadId = event.thread_id || '';
-            sessionId = event.session_id || '';
-            isDone = true;
-            break;
-          }
-          if (event.type === 'error') {
-            isDone = true;
-            break;
-          }
-        } catch (e) {
-          console.warn('SSE数据解析失败:', dataStr);
-        }
-      }
-    }
-  }
-
+  const { sessionId, threadId } = await streamPost<SSERawEvent>(url, { body, label: '点选解读流式请求', terminateOn: ['done', 'error'] }, onEvent);
   return { thread_id: threadId, session_id: sessionId };
   return { thread_id: threadId, session_id: sessionId };
 }
 }

+ 9 - 3
src/views/ventAI/dataPicker/components/DataPickerModal.vue

@@ -166,10 +166,14 @@
       box-shadow: inset 0 0 12px #2cb6ff;
       box-shadow: inset 0 0 12px #2cb6ff;
       backdrop-filter: blur(30px);
       backdrop-filter: blur(30px);
     }
     }
+    // 弹框内容高时的滚动只能发生一次:这里只做 75vh 的高度上限,真正的滚动交给 .di-body。
+    // 两者都设 overflow-y: auto 会出现内外两条滚动条(外层还会把标题滚走)。
     .ant-modal-body {
     .ant-modal-body {
       padding: 0;
       padding: 0;
       max-height: 75vh;
       max-height: 75vh;
-      overflow-y: auto;
+      display: flex;
+      flex-direction: column;
+      overflow: hidden;
     }
     }
     .ant-spin-text {
     .ant-spin-text {
       color: #c8d6e5;
       color: #c8d6e5;
@@ -209,8 +213,9 @@
   }
   }
   .di-body {
   .di-body {
     padding: 18px 24px;
     padding: 18px 24px;
-    min-height: 120px;
-    max-height: 700px;
+    // 唯一滚动容器:随 .ant-modal-body 的 75vh 上限伸缩,min-height: 0 才能被压缩出滚动条
+    flex: 1 1 auto;
+    min-height: 0;
     overflow-y: auto;
     overflow-y: auto;
   }
   }
   .di-section {
   .di-section {
@@ -411,6 +416,7 @@
     gap: 8px;
     gap: 8px;
     padding: 8px 24px 16px;
     padding: 8px 24px 16px;
     border-top: 1px solid rgba(30, 144, 255, 0.1);
     border-top: 1px solid rgba(30, 144, 255, 0.1);
+    flex-shrink: 0;
     &__tip {
     &__tip {
       font-size: 12px;
       font-size: 12px;
       color: #8899aa;
       color: #8899aa;

+ 10 - 10
src/views/ventAI/dataPicker/useDataPicker.ts

@@ -1,12 +1,13 @@
 import { ref, h, createApp, Teleport, type Directive, type App } from 'vue';
 import { ref, h, createApp, Teleport, type Directive, type App } from 'vue';
 import Antd from 'ant-design-vue';
 import Antd from 'ant-design-vue';
 import { fetchTunnelInterpretation, fetchDeviceInterpretation } from './api';
 import { fetchTunnelInterpretation, fetchDeviceInterpretation } from './api';
+// [审计-第四轮 M2] 重放自我方 fix/base-agent-token(M1/M2/M3):formatClickError 需要识别 StreamError 的 status
+import { StreamError } from '/@/views/ventAI/manageAssistent/api/errors';
 import type { TodoItem, ToolCallRecord, SSERawEvent } from './types';
 import type { TodoItem, ToolCallRecord, SSERawEvent } from './types';
 import DataPickerModal from './components/DataPickerModal.vue';
 import DataPickerModal from './components/DataPickerModal.vue';
 import aiCloseIcon from '@/assets/images/ventAI/dataPicker/AI-close.svg';
 import aiCloseIcon from '@/assets/images/ventAI/dataPicker/AI-close.svg';
 import aiOpenIcon from '@/assets/images/ventAI/dataPicker/AI-open.svg';
 import aiOpenIcon from '@/assets/images/ventAI/dataPicker/AI-open.svg';
 import { getActions } from '/@/qiankun/state';
 import { getActions } from '/@/qiankun/state';
-import { StreamError } from '/@/views/ventAI/manageAssistent/api';
 
 
 // ─── 全局开关:控制悬停模式浮动按钮是否启用 ─────────────────────────────────
 // ─── 全局开关:控制悬停模式浮动按钮是否启用 ─────────────────────────────────
 const STORAGE_KEY = 'dataPickerHoverEnabled';
 const STORAGE_KEY = 'dataPickerHoverEnabled';
@@ -342,17 +343,15 @@ export function useDataPicker(
   /**
   /**
    * 点击解读失败时,把「结构化错误」转成用户能看懂的中文文案。
    * 点击解读失败时,把「结构化错误」转成用户能看懂的中文文案。
    *
    *
-   * 背景(对应改动清单 M2 / TfAgents 决策 D6、B1-15、B1-19):
-   * TfAgents 现在对失败请求返回结构化错误体,形如
+   * [审计-第四轮 M2] 重放自我方 fix/base-agent-token(M1/M2/M3):
+   * 背景:TfAgents 对失败请求返回结构化错误体,形如
    *   HTTP 429 → { "detail": { "code": "...", "message": "今日额度已用完…", "retry_after": 900 } }
    *   HTTP 429 → { "detail": { "code": "...", "message": "今日额度已用完…", "retry_after": 900 } }
    *   HTTP 413 → { "detail": { "code": "...", "message": "…" } }
    *   HTTP 413 → { "detail": { "code": "...", "message": "…" } }
    *   HTTP 404 → { "detail": { "code": "...", "message": "…" } }
    *   HTTP 404 → { "detail": { "code": "...", "message": "…" } }
-   * 而点选链路原先直接抛 `HTTP错误: 429`,用户看不懂。
-   *
-   * 原先的差别:本文件原来只有一行 `error.value = e?.message || '请求失败'`,
-   * 429/413/404 只能显示 `HTTP错误: xxx`。现在复用 manageAssistent/api.ts 既有的
-   * `throwStreamHttpError`(不新写一套解析器)+ 本地的兜底文案,得到可读提示。
-   *
+   * 原行为:只有一行 `error.value = e?.message || '请求失败'`,429/413/404 只能显示 `HTTP错误: xxx`。
+   * 现行为:复用 manageAssistent/api/errors.ts 的 throwStreamHttpError(不新写一套解析器)
+   *         + 本函数的兜底文案,得到可读提示。
+   * 为什么:429 带 retryAfter 换算分钟、413/404 服务端无文案时用本地兜底。
    * 保守策略:只对「确认是 StreamError」的情况改写文案;普通 Error 一律走 fallback,
    * 保守策略:只对「确认是 StreamError」的情况改写文案;普通 Error 一律走 fallback,
    * 保证行为不回归(例如网络中断、JSON 解析失败等仍显示原始 message)。
    * 保证行为不回归(例如网络中断、JSON 解析失败等仍显示原始 message)。
    */
    */
@@ -371,7 +370,7 @@ export function useDataPicker(
     if (e instanceof StreamError && e.status === 413 && (!e.message || e.message === 'HTTP错误: 413')) {
     if (e instanceof StreamError && e.status === 413 && (!e.message || e.message === 'HTTP错误: 413')) {
       return '附件体积超过上限,请减小文件后重试';
       return '附件体积超过上限,请减小文件后重试';
     }
     }
-    // 404:会话不存在或无权访问(TfAgents 的 B1-08 会话归属校验)
+    // 404:会话不存在或无权访问(TfAgents 的会话归属校验)
     if (e instanceof StreamError && e.status === 404 && (!e.message || e.message === 'HTTP错误: 404')) {
     if (e instanceof StreamError && e.status === 404 && (!e.message || e.message === 'HTTP错误: 404')) {
       return '会话不存在或无权访问';
       return '会话不存在或无权访问';
     }
     }
@@ -396,6 +395,7 @@ export function useDataPicker(
 
 
     request
     request
       .catch((e: any) => {
       .catch((e: any) => {
+        // [审计-第四轮 M2] 重放自我方 fix/base-agent-token(M1/M2/M3):结构化错误 → 可读文案
         error.value = formatClickError(e);
         error.value = formatClickError(e);
       })
       })
       .finally(() => {
       .finally(() => {

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

@@ -1,597 +0,0 @@
-import { defHttp } from '/@/utils/http/axios';
-import { getToken } from '/@/utils/auth';
-import type { SseEvent } from './components/chatModal/types';
-
-enum Api {
-  usageToday = '/ventAI/api/usage/today',
-  me = '/ventAI/api/me',
-  unified = '/ventAI/api/chat',
-  getHistoryList = '/ventAI/api/sessions',
-  getDetail = '/ventAI/api/chat/history/',
-  deleteSession = '/ventAI/api/sessions/',
-  deleteMessage = '/ventAI/api/messages/',
-  chatResume = '/ventAI/api/chat/resume',
-  getModelMsg = '/ventAI/api/model',
-  switchThinking = '/ventAI/api/model/thinking',
-  getSkills = '/ventAI/api/skills',
-  schedules = '/ventAI/api/schedules',
-  downloadReport = '/ventAI/api/download/report/',
-  saveReport = '/ventAI/api/report/save/',
-  editWord = '/ventAI/api/report/edit-word',
-}
-
-export interface UsageByModel {
-  model: string;
-  cache_hit_tokens: number;
-  cache_miss_tokens: number;
-  output_tokens: number;
-  tokens: number;
-  request_count: number;
-}
-
-export interface TodayUsage {
-  date: string;
-  limit_tokens: number;
-  spent_tokens: number;
-  remaining_tokens: number;
-  request_count: number;
-  by_model: UsageByModel[];
-}
-
-interface ApiErrorDetail {
-  code?: string;
-  message?: string;
-  retry_after?: number;
-  limit_tokens?: number;
-  spent_tokens?: number;
-}
-
-/**
- * 流式请求被服务端以普通 JSON 拒绝时抛出的错误(429 额度耗尽 / 500 计费异常等)
- */
-export class StreamError extends Error {
-  status: number;
-  code?: string;
-  retryAfter?: number;
-  limitTokens?: number;
-  spentTokens?: number;
-
-  constructor(status: number, message: string, detail?: ApiErrorDetail) {
-    super(message);
-    this.name = 'StreamError';
-    this.status = status;
-    this.code = detail?.code;
-    this.retryAfter = detail?.retry_after;
-    this.limitTokens = detail?.limit_tokens;
-    this.spentTokens = detail?.spent_tokens;
-  }
-}
-
-// 【改动 M2】由「模块内私有 const」改为「导出」:点选链路(dataPicker/api.ts)要复用同一套
-// 结构化错误解析,避免在点选侧再复制一份 detail 解析逻辑(两份实现会各自漂移)。
-// 函数体本身零改动。
-export const throwStreamHttpError = async (response: Response): Promise<never> => {
-  let detail: ApiErrorDetail | undefined;
-  let message = `HTTP错误: ${response.status}`;
-  try {
-    const body = await response.json();
-    if (body?.detail) {
-      if (typeof body.detail === 'string') {
-        message = body.detail;
-      } else {
-        detail = body.detail as ApiErrorDetail;
-        message = detail.message || message;
-      }
-    }
-  } catch {
-    // 响应体不是 JSON 时保留默认错误信息
-  }
-  throw new StreamError(response.status, message, detail);
-};
-
-/**
- * 查询今日用量与额度
- */
-export const getTodayUsage = () => defHttp.get({ url: Api.usageToday }, { isTransformResponse: false });
-
-/**
- * 【改动 M3 · 对应 TfAgents 决策 D1 / D9 与任务 B2-01】
- * 当前登录用户的权限信息。TfAgents 把「技能上传/启停、子智能体增删改、思考级别切换」等
- * 全局变更端点收归管理员后,基座据此隐藏入口(用户已定案「方案 B:由后端返回 is_admin,前端隐藏」)。
- *
- * 后端契约(TfAgents 侧):
- *   GET /api/me(需认证,带 X-Access-Token)
- *   → 200 { "username": "...", "is_admin": true|false, "admin_configured": true|false }
- *   → 401 未登录 / 令牌无效
- * 说明:admin_configured 表示服务端管理员白名单是否已配置(false 时可提示「管理员未配置」);
- *       接口不返回白名单内容。
- *
- * 两点实现说明:
- * 1) 这里不手写请求头:走 defHttp 时,axios 请求拦截器(src/utils/http/axios/index.ts:209-216)
- *    会自动把令牌塞进 Authorization 与 X-Access-Token,与其它接口保持一致。
- * 2) errorMessageMode: 'none' 是刻意加的:该接口在 TfAgents 尚未上线时返回 404,
- *    若用默认的错误提示模式,每次打开 AI 弹框都会弹一个红色报错(误判为故障)。
- *    这里静默失败,由调用方(AiAssistantModal.fetchMe)按「非管理员」保守降级。
- */
-export interface MeInfo {
-  username: string;
-  is_admin: boolean;
-  admin_configured: boolean;
-}
-
-export const getMe = () => defHttp.get<MeInfo>({ url: Api.me }, { isTransformResponse: false, errorMessageMode: 'none' });
-
-/**
- * 历史会话接口
- */
-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 });
-
-/**
- * 删除单条消息(按消息主键 id)
- * 删除后服务端会清空该会话的内存线程,下轮对话从数据库重建最近 40 条历史
- * @param message_id - 消息主键 id(来自历史详情 messages[].id)
- */
-export const deleteMessage = (message_id: number) => defHttp.delete({ url: Api.deleteMessage + message_id }, { isTransformResponse: false });
-
-/**
- * 置顶会话
- * @param session_id - 会话ID
- */
-export const pinSession = (session_id: string) => defHttp.put({ url: `/ventAI/api/sessions/${session_id}/pin` }, { isTransformResponse: false });
-
-/**
- * 编辑会话名称
- * @param session_id - 会话ID
- * @param title - 新标题
- */
-export const editSessionTitle = (session_id: string, title: string) =>
-  defHttp.put({ url: `/ventAI/api/sessions/${session_id}/title`, data: { title } }, { isTransformResponse: false });
-
-/**
- * 获取对话权限模式
- * @param session_id - 会话ID
- */
-export const getSessionMode = (session_id: string) => defHttp.get({ url: `/ventAI/api/sessions/${session_id}/mode` }, { isTransformResponse: false });
-
-/**
- * 设置对话权限模式
- * @param session_id - 会话ID
- */
-export const putSessionMode = (session_id: string, mode: string) =>
-  defHttp.put({ url: `/ventAI/api/sessions/${session_id}/mode`, data: { mode } }, { isTransformResponse: false });
-/**
- * 上下文用量查询
- * @param session_id - 会话ID
- */
-export const getContext = (session_id: string) => defHttp.get({ url: `/ventAI/api/sessions/${session_id}/context` }, { isTransformResponse: false });
-
-/**
- * 查询模型信息
- */
-export const getModelMsg = () => defHttp.get({ url: Api.getModelMsg }, { isTransformResponse: false });
-
-/**
- * 切换模型思考深度
- * @param level - 思考级别 off/high/highest
- */
-export const switchThinking = (level: string) => defHttp.post({ url: Api.switchThinking, data: { level } }, { isTransformResponse: false });
-
-/**
- * 查询模型技能
- */
-export const getSkills = () => defHttp.get({ url: Api.getSkills }, { isTransformResponse: false });
-
-/**
- * 查询模型技能详情
- */
-export const getSkillDetails = (name: string) => defHttp.get({ url: Api.getSkills + '/' + name }, { isTransformResponse: false });
-
-/**
- * 启用/禁用模型技能
- */
-export const toggleSkill = (name: string) => defHttp.post({ url: Api.getSkills + '/' + name + '/toggle' }, { isTransformResponse: false });
-
-/**
- * 上传新技能
- * @param file - zip附件
- */
-export const uploadSkill = (file: File) => {
-  const formData = new FormData();
-  formData.append('file', file);
-  return defHttp.post(
-    { url: Api.getSkills + '/upload', data: formData, headers: { 'Content-Type': 'multipart/form-data' } },
-    { 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; mode?: string; signal?: AbortSignal },
-  onChunk: (data: SseEvent) => 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);
-    }
-    if (params.mode) {
-      formData.append('mode', params.mode);
-    }
-
-    const response = await fetch(Api.unified, {
-      method: 'POST',
-      body: formData,
-      headers: { 'X-Access-Token': getToken() },
-      signal: params.signal,
-    });
-
-    if (!response.ok) {
-      await throwStreamHttpError(response);
-    }
-
-    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) as SseEvent;
-
-            onChunk(data);
-
-            if (data.type === 'done' || data.type === 'error' || data.type === 'interrupt') {
-              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) {
-    if ((error as Error)?.name !== 'AbortError') {
-      console.error('统一对话流式请求失败:', error);
-    }
-    throw error;
-  }
-};
-
-/**
- * PDF审查接口(SSE流式响应)
- * @param params.file - 配风计划PDF文件,必填
- * @param params.session_id - 会话唯一标识ID,可选
- * @param params.message - 附加消息/审查指令,可选
- * @param onChunk - 流式数据回调函数
- * @returns Promise<{ session_id: string }>
- */
-export const reviewPdfStream = async (
-  params: { file: File; session_id?: string; message?: string; mode?: string; signal?: AbortSignal },
-  onChunk: (data: SseEvent) => void
-): Promise<{ session_id: string }> => {
-  try {
-    const formData = new FormData();
-    formData.append('file', params.file);
-    if (params.session_id) {
-      formData.append('session_id', params.session_id);
-    }
-    if (params.message) {
-      formData.append('message', params.message);
-    }
-    if (params.mode) {
-      formData.append('mode', params.mode);
-    }
-
-    const response = await fetch(Api.unified, {
-      method: 'POST',
-      body: formData,
-      headers: { 'X-Access-Token': getToken() },
-      signal: params.signal,
-    });
-
-    if (!response.ok) {
-      await throwStreamHttpError(response);
-    }
-
-    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) as SseEvent;
-
-            onChunk(data);
-
-            if (data.type === 'done' || data.type === 'error' || data.type === 'interrupt') {
-              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) {
-    if ((error as Error)?.name !== 'AbortError') {
-      console.error('PDF审查流式请求失败:', error);
-    }
-    throw error;
-  }
-};
-
-/**
- * 恢复会话接口(SSE流式响应)
- * @param params.session_id - 会话ID,必填
- * @param params.thread_id - LangGraph线程ID,可选
- * @param params.status - ask_user响应状态:"answered"(默认,消费answers)/ "cancelled"(取消)/ "error"(出错)
- * @param params.answers - ask_user答案列表,与中断事件ask_user.questions一一对应(status=answered时必填)
- * @param onChunk - 流式数据回调函数
- * @returns Promise<{ session_id: string }>
- */
-export const chatResumeStream = async (
-  params: { session_id: string; thread_id?: string; status?: string; answers?: string[]; signal?: AbortSignal },
-  onChunk: (data: SseEvent) => void
-): Promise<{ session_id: string }> => {
-  try {
-    const body: Record<string, any> = {
-      session_id: params.session_id,
-      status: params.status || 'answered',
-    };
-    if (params.thread_id) {
-      body.thread_id = params.thread_id;
-    }
-    if (params.answers) {
-      body.answers = params.answers;
-    }
-
-    const response = await fetch(Api.chatResume, {
-      method: 'POST',
-      body: JSON.stringify(body),
-      headers: {
-        'Content-Type': 'application/json',
-        'X-Access-Token': getToken(),
-      },
-      signal: params.signal,
-    });
-
-    if (!response.ok) {
-      await throwStreamHttpError(response);
-    }
-
-    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) as SseEvent;
-
-            onChunk(data);
-
-            if (data.type === 'done' || data.type === 'error' || data.type === 'interrupt') {
-              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) {
-    if ((error as Error)?.name !== 'AbortError') {
-      console.error('恢复会话流式请求失败:', error);
-    }
-    throw error;
-  }
-};
-
-/**
- * 定时任务接口
- */
-export const createSchedule = (data: {
-  name: string;
-  schedule?: { freq: string; hour?: number; minute?: number; day_of_week?: number; day_of_month?: number };
-  cron_expr?: string;
-  prompt: string;
-  notify_webhook?: string;
-}) => defHttp.post({ url: Api.schedules, data }, { isTransformResponse: false });
-
-export const getScheduleList = (include_disabled = true) =>
-  defHttp.get({ url: Api.schedules, params: { include_disabled } }, { joinParamsToUrl: true, isTransformResponse: false });
-
-export const getScheduleDetail = (task_id: number) => defHttp.get({ url: `${Api.schedules}/${task_id}` }, { isTransformResponse: false });
-
-export const updateSchedule = (
-  task_id: number,
-  data: { name?: string; schedule?: Record<string, any>; cron_expr?: string; prompt?: string; notify_webhook?: string }
-) => defHttp.put({ url: `${Api.schedules}/${task_id}`, data }, { isTransformResponse: false });
-
-export const pauseSchedule = (task_id: number) => defHttp.post({ url: `${Api.schedules}/${task_id}/pause` }, { isTransformResponse: false });
-
-export const resumeSchedule = (task_id: number) => defHttp.post({ url: `${Api.schedules}/${task_id}/resume` }, { isTransformResponse: false });
-
-export const deleteSchedule = (id: number) => defHttp.delete({ url: `${Api.schedules}/${id}` }, { isTransformResponse: false });
-
-export const getScheduleRuns = (task_id: number, limit = 50) =>
-  defHttp.get({ url: `${Api.schedules}/${task_id}/runs`, params: { limit } }, { joinParamsToUrl: true, isTransformResponse: false });
-
-export const runScheduleNow = (task_id: number) => defHttp.post({ url: `${Api.schedules}/${task_id}/run` }, { isTransformResponse: false });
-
-/**
- * 获取子智能体列表
- */
-export const getSubAgents = () => defHttp.get({ url: '/ventAI/api/subagents' }, { isTransformResponse: false });
-
-/**
- * 获取工具注册表与模型列表(表单用)
- */
-export const getSubAgentTools = () => defHttp.get({ url: '/ventAI/api/subagents/tools' }, { isTransformResponse: false });
-
-/**
- * 创建子智能体
- */
-export const createSubAgent = (data: {
-  name: string;
-  description: string;
-  system_prompt: string;
-  tools?: string[];
-  model?: string;
-  enabled?: boolean;
-}) => defHttp.post({ url: '/ventAI/api/subagents', data }, { isTransformResponse: false });
-
-/**
- * 获取子智能体详情
- */
-export const getSubAgentDetail = (name: string) => defHttp.get({ url: `/ventAI/api/subagents/${name}` }, { isTransformResponse: false });
-
-/**
- * 更新子智能体
- */
-export const updateSubAgent = (
-  name: string,
-  data: { description?: string; system_prompt?: string; tools?: string[]; model?: string; enabled?: boolean }
-) => defHttp.put({ url: `/ventAI/api/subagents/${name}`, data }, { isTransformResponse: false });
-
-/**
- * 删除子智能体
- */
-export const deleteSubAgent = (name: string) => defHttp.delete({ url: `/ventAI/api/subagents/${name}` }, { isTransformResponse: false });
-
-/**
- * 启用/禁用子智能体
- */
-export const toggleSubAgent = (name: string) => defHttp.post({ url: `/ventAI/api/subagents/${name}/toggle` }, { isTransformResponse: false });
-
-/**
- * 报告下载
- * 从 URL 或文件名中提取纯文件名,构建 /api/download/report/{filename} 地址
- */
-export const buildReportDownloadUrl = (urlOrFilename: string) => {
-  const filename = urlOrFilename.split('/').pop() || urlOrFilename;
-  return `${Api.downloadReport}${encodeURIComponent(filename)}`;
-};
-
-/**
- * 覆盖保存报告(文本)
- * @param filename - 文件名,如 report_20260810_150000.md
- * @param content - 文本内容
- */
-export const saveReportText = (filename: string, content: string) =>
-  defHttp.put({ url: `${Api.saveReport}${encodeURIComponent(filename)}`, data: { content } }, { isTransformResponse: false });
-
-/**
- * 覆盖保存报告(二进制,如 xlsx)
- * @param filename - 文件名
- * @param data - ArrayBuffer / Blob / Uint8Array
- */
-export const saveReportBinary = (filename: string, data: ArrayBuffer | Blob | Uint8Array) =>
-  defHttp.put(
-    {
-      url: `${Api.saveReport}${encodeURIComponent(filename)}`,
-      data,
-      headers: { 'Content-Type': 'application/octet-stream' },
-    },
-    { isTransformResponse: false }
-  );
-
-/**
- * 对话式修改 Word 报告
- * @param filename - docx 文件名
- * @param instruction - 修改要求(自然语言)
- */
-export const editWordReport = (filename: string, instruction: string) =>
-  defHttp.post({ url: Api.editWord, data: { filename, instruction } }, { isTransformResponse: false });

+ 91 - 0
src/views/ventAI/manageAssistent/api/cad.ts

@@ -0,0 +1,91 @@
+// api/cad.ts:DWG 图纸预览所需的「服务端转 SVG」接口。
+//
+// DWG 是专有二进制格式,前端没法本地解析(对比 .dxf 走 lib/dxf.ts 本地渲染),只能把原始文件传给后端转成
+// SVG 再展示。后端给了两个接口,差别只在返回什么:
+//   dwgFileToSVG     —— 只返回生成的 svg 路径,前端自己去静态目录取;
+//   dwgFileToSVGInfo —— 直接返回 svg 正文,不依赖静态目录能否被前端访问。
+import { defHttp } from '/@/utils/http/axios/index';
+import { useGlobSetting } from '/@/hooks/setting';
+
+// 接口地址按后端给的文档写(/Vmodel/ventanalyModel/...);实测「加 /ventanaly-model 服务前缀」和「不加」
+// 两种写法都能打到同一个 handler(见 views/vent/monitorManager/mainFanMonitor/main.api.ts 也是不加前缀的写法)。
+// 请求最终由 uploadFile 拼上网关前缀 /modelreq 发出去,dev 下由 Vite proxy 转发。
+enum Api {
+  dwgToSvg = '/Vmodel/ventanalyModel/dwgFileToSVG',
+  dwgToSvgInfo = '/Vmodel/ventanalyModel/dwgFileToSVGInfo',
+}
+
+/** 转换业务目录:后端据此存放上传的 dwg 与生成的 svg(对应返回里的 temp/dwg、/temp/svg/*.svg) */
+const DWG_BIZ = 'temp/dwg';
+
+export interface DwgToSvgResult {
+  /** 生成的 svg 相对路径,如 /temp/svg/test300K.svg */
+  svgFile: string;
+  /** 上传的 dwg 相对路径,如 temp/dwg/test300K.dwg */
+  dwgFile: string;
+}
+
+export interface DwgToSvgInfoResult extends DwgToSvgResult {
+  /** svg 正文(完整 xml 字符串) */
+  svgInfo: string;
+}
+
+/**
+ * 上传 dwg,返回转好的 svg 路径。
+ * @param file - dwg 文件(文件名会带到后端,用于命名生成物,务必传带名字的 File 而不是裸 Blob)
+ */
+export const dwgToSvg = (file: File): Promise<DwgToSvgResult> => postDwg<DwgToSvgResult>(Api.dwgToSvg, file);
+
+/**
+ * 上传 dwg,返回 svg 正文。
+ * @param file - dwg 文件
+ */
+export const dwgToSvgInfo = (file: File): Promise<DwgToSvgInfoResult> => postDwg<DwgToSvgInfoResult>(Api.dwgToSvgInfo, file);
+
+function postDwg<T>(url: string, file: File): Promise<T> {
+  // 走项目自带的上传方法,**不要**写成 defHttp.post({ data: formData }):
+  // beforeRequestHook 是用 `Object.keys(config.data).length > 0` 判断「有没有 body」的,而 FormData 的
+  // 自有可枚举属性为空,于是它把 FormData 整个换成 params(默认 {})——实测发出去的 body 就是字符串 "{}",
+  // 服务端一个 part 都收不到("Current request is not a multipart request" /
+  // "Required request part 'file' is not present"),只看到 getFile("file") 为 null 后抛出的 NPE。
+  // uploadFile 直接走 axios 实例、不过这个 hook,file 与附加字段(biz)都能正常送达。
+  return defHttp
+    .uploadFile<T>(
+      // uploadFile 内部会把 baseURL 设成网关地址(/modelreq),所以这里传服务前缀之后的完整路径
+      { url, timeout: 120000 }, // 转换是 CPU 密集操作,比普通接口慢得多,单独放宽超时(全局默认 10s)
+      { file, name: 'file', filename: file.name, data: { biz: DWG_BIZ } },
+      { isReturnResponse: true }
+    )
+    .then((res) => unwrapEnvelope<T>(res));
+}
+
+/**
+ * 兜住「业务失败但 HTTP 200」的信封。
+ *
+ * 后端业务失败时返回的是 `{success:false, code:500, result:null}`:此时响应拦截器不会走 reject
+ * (它是按 `data.result || data.success` 进分支的,两者都为假),而是把整个信封当普通数据返回。
+ * 不在这里判一次,调用方就只能看到「返回结果里没有 svgFile」,把「转换失败」误报成「缺字段」。
+ * 成功时拦截器已经把 result 拆出来了(普通对象,没有 success 字段),这里原样放行。
+ */
+function unwrapEnvelope<T>(res: unknown): T {
+  if (!res || typeof res !== 'object' || !('success' in res)) return res as T;
+  const envelope = res as { success?: boolean; code?: number; message?: string; result?: T };
+  if (envelope.success === false || (typeof envelope.code === 'number' && envelope.code !== 200)) {
+    throw new Error(envelope.message || `DWG 转换接口返回 code=${envelope.code}`);
+  }
+  return envelope.result as T;
+}
+
+/**
+ * 把接口返回的 svg 相对路径拼成浏览器可直接访问的地址:`<网关>/sys/common/static/<路径>`。
+ *
+ * 后端返回的路径带前导斜杠(示例 `/temp/svg/test300K.svg`),直接拼接会拼出 `static//temp/...`,
+ * 部分网关会把它当成非法路径,这里统一去掉再拼。
+ * 静态目录不校验 token,所以可以直接交给 <img> 加载(图片标签没法带 X-Access-Token 头)。
+ */
+export const buildStaticFileUrl = (path: string): string => {
+  const base = useGlobSetting().domainUrl || '';
+  return `${base}/sys/common/static/${String(path || '')
+    .trim()
+    .replace(/^\/+/, '')}`;
+};

+ 115 - 0
src/views/ventAI/manageAssistent/api/chat.ts

@@ -0,0 +1,115 @@
+// api/chat.ts:对话相关的三个 SSE 流式接口(统一对话 / PDF 审查 / 审批恢复)。
+//
+// 三个入口的差别只有「请求体怎么构造」和「日志前缀」,其余(带令牌、非 2xx 解码、消费事件流、
+// 中断处理、返回值归一)完全一致,统一走 streamPost,避免三份各自演化。
+import { streamPost } from './streamPost';
+import type { SseEvent } from '/@/views/ventAI/manageAssistent/types/index';
+
+enum Api {
+  unified = '/ventAI/api/chat',
+  resume = '/ventAI/api/chat/resume',
+}
+
+/** 一次流式对话的结束信息 */
+export interface ChatStreamResult {
+  /** 终止事件携带的会话 id,未携带时为空串 */
+  session_id: string;
+  /** 解析失败被丢弃的 data 行数;大于 0 表示正文可能不完整 */
+  parse_errors: number;
+}
+
+/**
+ * 统一对话接口(SSE流式响应)
+ * @param params.message - 用户输入的文本,必填
+ * @param params.session_id - 会话唯一标识ID,不传参时服务端自动生成全新会话ID
+ * @param params.file - 上传的PDF文件,可选
+ * @param params.mode - 对话权限模式,可选
+ * @param onChunk - 流式数据回调函数
+ * @returns 会话 id 与丢弃事件计数(parse_errors 大于 0 表示正文可能不完整)
+ */
+export const unifiedStream = async (
+  params: { message: string; session_id?: string; file?: File; mode?: string; signal?: AbortSignal },
+  onChunk: (data: SseEvent) => void
+): Promise<ChatStreamResult> => {
+  const formData = new FormData();
+  // message 一律提交(允许空串):纯附件发送时 message 为空是既有契约,
+  // 不能因为「空值就跳过 append」把请求体变成另一种形状
+  formData.append('message', params.message);
+  if (params.session_id) {
+    formData.append('session_id', params.session_id);
+  }
+  if (params.file) {
+    formData.append('file', params.file);
+  }
+  if (params.mode) {
+    formData.append('mode', params.mode);
+  }
+
+  const { sessionId, parseErrors } = await streamPost<SseEvent>(
+    Api.unified,
+    { body: formData, label: '统一对话流式请求', signal: params.signal },
+    onChunk
+  );
+  return { session_id: sessionId, parse_errors: parseErrors };
+};
+
+/**
+ * PDF审查接口(SSE流式响应)
+ * @param params.file - 配风计划PDF文件,必填
+ * @param params.session_id - 会话唯一标识ID,可选
+ * @param params.message - 附加消息/审查指令,可选
+ * @param params.mode - 对话权限模式,可选
+ * @param onChunk - 流式数据回调函数
+ * @returns 会话 id 与丢弃事件计数(parse_errors 大于 0 表示正文可能不完整)
+ */
+export const reviewPdfStream = async (
+  params: { file: File; session_id?: string; message?: string; mode?: string; signal?: AbortSignal },
+  onChunk: (data: SseEvent) => void
+): Promise<ChatStreamResult> => {
+  const formData = new FormData();
+  formData.append('file', params.file);
+  if (params.session_id) {
+    formData.append('session_id', params.session_id);
+  }
+  if (params.message) {
+    formData.append('message', params.message);
+  }
+  if (params.mode) {
+    formData.append('mode', params.mode);
+  }
+
+  const { sessionId, parseErrors } = await streamPost<SseEvent>(
+    Api.unified,
+    { body: formData, label: 'PDF审查流式请求', signal: params.signal },
+    onChunk
+  );
+  return { session_id: sessionId, parse_errors: parseErrors };
+};
+
+/**
+ * 恢复会话接口(SSE流式响应)
+ * @param params.session_id - 会话ID,必填
+ * @param params.thread_id - LangGraph线程ID,可选
+ * @param params.status - ask_user响应状态:"answered"(默认,消费answers)/ "cancelled"(取消)/ "error"(出错)
+ * @param params.answers - ask_user答案列表,与中断事件ask_user.questions一一对应(status=answered时必填)
+ * @param onChunk - 流式数据回调函数
+ * @returns 会话 id 与丢弃事件计数(parse_errors 大于 0 表示正文可能不完整)
+ */
+export const chatResumeStream = async (
+  params: { session_id: string; thread_id?: string; status?: string; answers?: string[]; signal?: AbortSignal },
+  onChunk: (data: SseEvent) => void
+): Promise<ChatStreamResult> => {
+  const body: Record<string, unknown> = {
+    session_id: params.session_id,
+    status: params.status || 'answered',
+  };
+  if (params.thread_id) {
+    body.thread_id = params.thread_id;
+  }
+  if (params.answers) {
+    body.answers = params.answers;
+  }
+
+  const { sessionId, parseErrors } = await streamPost<SseEvent>(Api.resume, { body, label: '恢复会话流式请求', signal: params.signal }, onChunk);
+  return { session_id: sessionId, parse_errors: parseErrors };
+};

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

@@ -0,0 +1,72 @@
+// api/errors.ts:后端错误体的统一解码。
+// 流式请求(fetch)与非流式请求(defHttp)共用同一套 detail 规则,避免两处各自解析漂移。
+
+/** 后端错误体 { detail: ... } 里 detail 为对象时的形状 */
+export interface ApiErrorDetail {
+  code?: string;
+  message?: string;
+  retry_after?: number;
+  limit_tokens?: number;
+  spent_tokens?: number;
+}
+
+/**
+ * 流式请求被服务端以普通 JSON 拒绝时抛出的错误(429 额度耗尽 / 500 计费异常等)
+ */
+export class StreamError extends Error {
+  status: number;
+  code?: string;
+  retryAfter?: number;
+  limitTokens?: number;
+  spentTokens?: number;
+
+  constructor(status: number, message: string, detail?: ApiErrorDetail) {
+    super(message);
+    this.name = 'StreamError';
+    this.status = status;
+    this.code = detail?.code;
+    this.retryAfter = detail?.retry_after;
+    this.limitTokens = detail?.limit_tokens;
+    this.spentTokens = detail?.spent_tokens;
+  }
+}
+
+/** 流式响应非 2xx:解析后端 detail 后抛出 StreamError */
+export const throwStreamHttpError = async (response: Response): Promise<never> => {
+  let detail: ApiErrorDetail | undefined;
+  let message = `HTTP错误: ${response.status}`;
+  try {
+    const body = await response.json();
+    if (body?.detail) {
+      if (typeof body.detail === 'string') {
+        message = body.detail;
+      } else {
+        detail = body.detail as ApiErrorDetail;
+        message = detail.message || message;
+      }
+    }
+  } catch {
+    // 响应体不是 JSON 时保留默认错误信息
+  }
+  throw new StreamError(response.status, message, detail);
+};
+
+/**
+ * 取出后端返回的错误说明,供非流式接口的调用方展示。
+ *
+ * 后端(FastAPI)的错误体形状是 { detail: string | { code, message, ... } },
+ * 因此读 err.response.data.message 永远取不到值,界面只会显示 axios 的
+ * "Request failed with status code 403",把后端真正有用的说明(例如"管理员未配置")整个丢掉。
+ * 这里复用与 throwStreamHttpError 相同的 detail 解码规则。
+ */
+export const extractApiErrorMessage = (err: unknown, fallback: string): string => {
+  const data = (err as { response?: { data?: { detail?: string | ApiErrorDetail } } })?.response?.data;
+  const detail = data?.detail;
+  if (typeof detail === 'string' && detail) return detail;
+  if (detail && typeof detail === 'object' && typeof detail.message === 'string' && detail.message) {
+    return detail.message;
+  }
+  // 非 detail 形状时退回 Error.message(网络错误、超时等仍能给出原因),最后用调用方兜底文案
+  const msg = (err as { message?: string })?.message;
+  return msg || fallback;
+};

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

@@ -0,0 +1,15 @@
+// api barrel:对外保持单一入口,调用方按 `.../api` 引入即可,不必关心内部按域拆分。
+// 各域相互独立:新增接口请放进对应域文件,不要再堆成一个大文件。
+export * from './errors';
+export * from './sse';
+export * from './streamPost';
+export * from './chat';
+export * from './session';
+export * from './user';
+export * from './usage';
+export * from './model';
+export * from './skill';
+export * from './schedule';
+export * from './subagent';
+export * from './report';
+export * from './cad';

+ 26 - 0
src/views/ventAI/manageAssistent/api/model.ts

@@ -0,0 +1,26 @@
+// api/model.ts:模型信息与思考级别。
+import { defHttp } from '/@/utils/http/axios/index';
+
+enum Api {
+  model = '/ventAI/api/model',
+  thinking = '/ventAI/api/model/thinking',
+}
+
+/** 模型信息(GET /model 实测形状):当前模型、思考级别取值与可选级别 */
+export interface ModelInfoResponse {
+  current: string;
+  thinking_level: string;
+  thinking_levels: string[];
+}
+
+/**
+ * 查询模型信息
+ */
+export const getModelMsg = (): Promise<ModelInfoResponse> => defHttp.get<ModelInfoResponse>({ url: Api.model }, { isTransformResponse: false });
+
+/**
+ * 切换模型思考深度
+ * @param level - 思考级别 off/high/highest
+ */
+export const switchThinking = (level: string): Promise<unknown> =>
+  defHttp.post<unknown>({ url: Api.thinking, data: { level } }, { isTransformResponse: false });

+ 60 - 0
src/views/ventAI/manageAssistent/api/report.ts

@@ -0,0 +1,60 @@
+// api/report.ts:报告文件(下载地址构造 / 覆盖保存 / 对话式改 Word)。
+import { defHttp } from '/@/utils/http/axios/index';
+
+enum Api {
+  downloadReport = '/ventAI/api/download/report/',
+  saveReport = '/ventAI/api/report/save/',
+  editWord = '/ventAI/api/report/edit-word',
+}
+
+/**
+ * 把后端返回的报告地址归一成走 /ventAI 代理的下载地址。
+ *
+ * 后端实际返回过两种形式:`http://<host>/api/download/report/<file>`(绝对地址)与
+ * `/api/download/report/<file>`(相对路径)。后者缺 /ventAI 前缀,直接交给浏览器会打到前端自己的源上
+ * (dev 是 Vite,没有 /api 路由)从而 404。这里统一重建成 /ventAI/api/download/report/<file>,
+ * 与其余接口一致走代理,dev 与生产都不依赖后端返回的 Host。
+ *
+ * 注意地址里的文件名是已百分号编码的,必须先解码再编码,否则会双重编码导致后端找不到文件。
+ */
+export const buildReportDownloadUrl = (urlOrFilename: string) => {
+  const raw = urlOrFilename.split('/').pop() || urlOrFilename;
+  let filename = raw;
+  try {
+    filename = decodeURIComponent(raw);
+  } catch {
+    // 含非法百分号序列时按原样处理
+  }
+  return `${Api.downloadReport}${encodeURIComponent(filename)}`;
+};
+
+/**
+ * 覆盖保存报告(文本)
+ * @param filename - 文件名,如 report_20260810_150000.md
+ * @param content - 文本内容
+ */
+export const saveReportText = (filename: string, content: string): Promise<unknown> =>
+  defHttp.put<unknown>({ url: `${Api.saveReport}${encodeURIComponent(filename)}`, data: { content } }, { isTransformResponse: false });
+
+/**
+ * 覆盖保存报告(二进制,如 xlsx)
+ * @param filename - 文件名
+ * @param data - ArrayBuffer / Blob / Uint8Array
+ */
+export const saveReportBinary = (filename: string, data: ArrayBuffer | Blob | Uint8Array): Promise<unknown> =>
+  defHttp.put<unknown>(
+    {
+      url: `${Api.saveReport}${encodeURIComponent(filename)}`,
+      data,
+      headers: { 'Content-Type': 'application/octet-stream' },
+    },
+    { isTransformResponse: false }
+  );
+
+/**
+ * 对话式修改 Word 报告
+ * @param filename - docx 文件名
+ * @param instruction - 修改要求(自然语言)
+ */
+export const editWordReport = (filename: string, instruction: string): Promise<unknown> =>
+  defHttp.post<unknown>({ url: Api.editWord, data: { filename, instruction } }, { isTransformResponse: false });

+ 84 - 0
src/views/ventAI/manageAssistent/api/schedule.ts

@@ -0,0 +1,84 @@
+// api/schedule.ts:定时任务(增删改查 / 启停 / 立即执行 / 执行记录)。
+//
+// 响应形状均已对真实响应核实(2026-10):
+//   GET /schedules            → { tasks, total }
+//   GET /schedules/{id}       → { task }
+//   GET /schedules/{id}/runs  → { runs, task_id, total },runs[] 元素为
+//                               { id, task_id, status, started_at, finished_at, duration_ms, input, output, error }
+// 命令类接口(增改删 / 启停 / 立即执行)的响应体没有调用方读取,声明为 unknown。
+import { defHttp } from '/@/utils/http/axios/index';
+import type { ScheduleTask } from '/@/views/ventAI/manageAssistent/types/index';
+
+enum Api {
+  schedules = '/ventAI/api/schedules',
+}
+
+export interface ScheduleListResponse {
+  tasks: ScheduleTask[];
+  total: number;
+}
+
+/**
+ * 一次执行记录(GET /schedules/{id}/runs 响应的 runs[] 元素)。
+ * 仍在执行中的记录只会填到 started_at,后续字段按可选处理(面板渲染也一律先判空)。
+ */
+export interface ScheduleRun {
+  id: number;
+  task_id: number;
+  /** running / success / failed / skipped(见 SchedulePanel 的 runStatusLabel) */
+  status: string;
+  started_at: string;
+  finished_at?: string;
+  duration_ms?: number;
+  input?: string;
+  output?: string;
+  error?: string;
+}
+
+export interface ScheduleRunsResponse {
+  runs: ScheduleRun[];
+  task_id: number;
+  total: number;
+}
+
+/** 单条定时任务详情(GET /schedules/{id}):外层是 { task } 而不是裸对象,已对真实响应核实 */
+export interface ScheduleDetailResponse {
+  task: ScheduleTask;
+}
+
+/**
+ * 定时任务接口
+ */
+export const createSchedule = (data: {
+  name: string;
+  schedule?: { freq: string; hour?: number; minute?: number; day_of_week?: number; day_of_month?: number };
+  cron_expr?: string;
+  prompt: string;
+  notify_webhook?: string;
+}): Promise<unknown> => defHttp.post<unknown>({ url: Api.schedules, data }, { isTransformResponse: false });
+
+export const getScheduleList = (include_disabled = true): Promise<ScheduleListResponse> =>
+  defHttp.get<ScheduleListResponse>({ url: Api.schedules, params: { include_disabled } }, { joinParamsToUrl: true, isTransformResponse: false });
+
+export const getScheduleDetail = (task_id: number): Promise<ScheduleDetailResponse> =>
+  defHttp.get<ScheduleDetailResponse>({ url: `${Api.schedules}/${task_id}` }, { isTransformResponse: false });
+
+export const updateSchedule = (
+  task_id: number,
+  data: { name?: string; schedule?: Record<string, unknown>; cron_expr?: string; prompt?: string; notify_webhook?: string }
+): Promise<unknown> => defHttp.put<unknown>({ url: `${Api.schedules}/${task_id}`, data }, { isTransformResponse: false });
+
+export const pauseSchedule = (task_id: number): Promise<unknown> =>
+  defHttp.post<unknown>({ url: `${Api.schedules}/${task_id}/pause` }, { isTransformResponse: false });
+
+export const resumeSchedule = (task_id: number): Promise<unknown> =>
+  defHttp.post<unknown>({ url: `${Api.schedules}/${task_id}/resume` }, { isTransformResponse: false });
+
+export const deleteSchedule = (id: number): Promise<unknown> =>
+  defHttp.delete<unknown>({ url: `${Api.schedules}/${id}` }, { isTransformResponse: false });
+
+export const getScheduleRuns = (task_id: number, limit = 50): Promise<ScheduleRunsResponse> =>
+  defHttp.get<ScheduleRunsResponse>({ url: `${Api.schedules}/${task_id}/runs`, params: { limit } }, { joinParamsToUrl: true, isTransformResponse: false });
+
+export const runScheduleNow = (task_id: number): Promise<unknown> =>
+  defHttp.post<unknown>({ url: `${Api.schedules}/${task_id}/run` }, { isTransformResponse: false });

+ 144 - 0
src/views/ventAI/manageAssistent/api/session.ts

@@ -0,0 +1,144 @@
+// api/session.ts:会话(历史列表 / 详情 / 标题 / 置顶 / 权限模式 / 上下文用量)与消息删除。
+//
+// 响应形状均已对真实响应采样核实(2026-10);命令类接口(删除/置顶/改标题/改模式)的响应体没有
+// 调用方读取,故如实声明为 unknown,不用 any 也不假造一个没人验证过的外壳。
+import { defHttp } from '/@/utils/http/axios/index';
+import type { CitationItem, CitationsData } from '/@/views/ventAI/manageAssistent/types/index';
+
+// 统一用枚举寻址,避免同一域里模板串与枚举两套写法混用
+enum Api {
+  sessions = '/ventAI/api/sessions',
+  chatHistory = '/ventAI/api/chat/history/',
+  messages = '/ventAI/api/messages/',
+}
+
+/** 会话列表项(GET /sessions 响应的 sessions[] 元素) */
+export interface SessionListItem {
+  session_id: string;
+  title: string;
+  user_name: string;
+  sort_order: number;
+  created_at: string;
+  updated_at: string;
+  message_count: number;
+}
+
+export interface SessionListResponse {
+  sessions: SessionListItem[];
+  total: number;
+}
+
+/**
+ * 历史消息(GET /chat/history/{id} 响应的 messages[] 元素)。
+ *
+ * role 为 'user' / 'assistant'。字段形状已对真实响应采样核实,另外保留两个「旧格式兼容」字段:
+ * type 与 tool_calls,当前后端不再返回,但历史数据里存在,归一化逻辑(transformHistoryToMessages)
+ * 仍然要吃它们,故如实声明为可选,而不是让消费端各自 as any。
+ */
+export interface HistoryMessage {
+  id: number;
+  session_id: string;
+  role: string;
+  content: string;
+  created_at: string;
+  /** 旧格式标记:'user_message' / 'model_thinking' */
+  type?: string;
+  /** 历史接口返回扁平数组,SSE 事件返回 { items, model_only },两种都要容 */
+  citations: CitationItem[] | CitationsData | null;
+  report_url: string | null;
+  report_filename: string | null;
+  /** 后端会返回但前端未消费,如实声明为 unknown 而不是 any */
+  tool_calls: unknown;
+}
+
+export interface SessionHistoryResponse {
+  session_id: string;
+  total: number;
+  messages: HistoryMessage[];
+}
+
+/** 会话权限模式(GET /sessions/{id}/mode) */
+export interface SessionModeResponse {
+  session_id: string;
+  mode: string;
+}
+
+/** 上下文容量(GET /sessions/{id}/context) */
+export interface SessionContextResponse {
+  session_id: string;
+  current_usage: number;
+  total_limit: number;
+  usage_pct: number;
+  updated_at: string;
+  breakdown: {
+    messages: number;
+    mcp: number;
+    skills: number;
+    system_prompt: number;
+    other: number;
+  };
+}
+
+/**
+ * 历史会话接口
+ */
+export const getHistoryList = (params: Record<string, unknown>): Promise<SessionListResponse> =>
+  defHttp.get<SessionListResponse>({ url: Api.sessions, params }, { joinParamsToUrl: true, isTransformResponse: false });
+
+/**
+ * 获取历史会话详情
+ * @param session_id - 会话ID
+ */
+export const getDetail = (session_id: string): Promise<SessionHistoryResponse> =>
+  defHttp.get<SessionHistoryResponse>({ url: Api.chatHistory + session_id }, { isTransformResponse: false });
+
+/**
+ * 删除会话
+ * @param session_id - 会话ID
+ */
+export const deleteSession = (session_id: string): Promise<unknown> =>
+  defHttp.delete<unknown>({ url: `${Api.sessions}/${session_id}` }, { isTransformResponse: false });
+
+/**
+ * 删除单条消息(按消息主键 id)
+ * 删除后服务端会清空该会话的内存线程,下轮对话从数据库重建最近 40 条历史
+ * @param message_id - 消息主键 id(来自历史详情 messages[].id)
+ */
+export const deleteMessage = (message_id: number): Promise<unknown> =>
+  defHttp.delete<unknown>({ url: Api.messages + message_id }, { isTransformResponse: false });
+
+/**
+ * 置顶会话
+ * @param session_id - 会话ID
+ */
+export const pinSession = (session_id: string): Promise<unknown> =>
+  defHttp.put<unknown>({ url: `${Api.sessions}/${session_id}/pin` }, { isTransformResponse: false });
+
+/**
+ * 编辑会话名称
+ * @param session_id - 会话ID
+ * @param title - 新标题
+ */
+export const editSessionTitle = (session_id: string, title: string): Promise<unknown> =>
+  defHttp.put<unknown>({ url: `${Api.sessions}/${session_id}/title`, data: { title } }, { isTransformResponse: false });
+
+/**
+ * 获取对话权限模式
+ * @param session_id - 会话ID
+ */
+export const getSessionMode = (session_id: string): Promise<SessionModeResponse> =>
+  defHttp.get<SessionModeResponse>({ url: `${Api.sessions}/${session_id}/mode` }, { isTransformResponse: false });
+
+/**
+ * 设置对话权限模式
+ * @param session_id - 会话ID
+ */
+export const putSessionMode = (session_id: string, mode: string): Promise<unknown> =>
+  defHttp.put<unknown>({ url: `${Api.sessions}/${session_id}/mode`, data: { mode } }, { isTransformResponse: false });
+
+/**
+ * 上下文用量查询
+ * @param session_id - 会话ID
+ */
+export const getContext = (session_id: string): Promise<SessionContextResponse> =>
+  defHttp.get<SessionContextResponse>({ url: `${Api.sessions}/${session_id}/context` }, { isTransformResponse: false });

+ 49 - 0
src/views/ventAI/manageAssistent/api/skill.ts

@@ -0,0 +1,49 @@
+// api/skill.ts:模型技能(列表 / 详情 / 启停 / 上传)。
+//
+// 列表项形状与 types/domain.ts 的 Skill 一致,直接复用该类型,不再各写一份。
+import { defHttp } from '/@/utils/http/axios/index';
+import type { Skill } from '/@/views/ventAI/manageAssistent/types/index';
+
+enum Api {
+  skills = '/ventAI/api/skills',
+}
+
+export interface SkillListResponse {
+  skills: Skill[];
+}
+
+/** 技能详情(GET /skills/{name}):SKILL.md 正文 */
+export interface SkillDetailResponse {
+  name: string;
+  content: string;
+}
+
+/**
+ * 查询模型技能
+ */
+export const getSkills = (): Promise<SkillListResponse> => defHttp.get<SkillListResponse>({ url: Api.skills }, { isTransformResponse: false });
+
+/**
+ * 查询模型技能详情
+ */
+export const getSkillDetails = (name: string): Promise<SkillDetailResponse> =>
+  defHttp.get<SkillDetailResponse>({ url: `${Api.skills}/${name}` }, { isTransformResponse: false });
+
+/**
+ * 启用/禁用模型技能
+ */
+export const toggleSkill = (name: string): Promise<unknown> =>
+  defHttp.post<unknown>({ url: `${Api.skills}/${name}/toggle` }, { isTransformResponse: false });
+
+/**
+ * 上传新技能
+ * @param file - zip附件
+ */
+export const uploadSkill = (file: File): Promise<unknown> => {
+  const formData = new FormData();
+  formData.append('file', file);
+  return defHttp.post<unknown>(
+    { url: `${Api.skills}/upload`, data: formData, headers: { 'Content-Type': 'multipart/form-data' } },
+    { isTransformResponse: false }
+  );
+};

+ 161 - 0
src/views/ventAI/manageAssistent/api/sse.ts

@@ -0,0 +1,161 @@
+/**
+ * SSE 流消费的统一实现。
+ *
+ */
+
+/** 本模块需要读取的最小事件形状;各业务自己的事件类型(SseEvent / SSERawEvent)都满足它 */
+export interface SseEventLike {
+  type?: string;
+  session_id?: string;
+  thread_id?: string;
+}
+
+/** 消费完一条 SSE 流的结果 */
+export interface SseConsumeResult {
+  /** 终止事件携带的会话 id,未携带时为空串 */
+  sessionId: string;
+  /** 终止事件携带的线程 id,未携带时为空串 */
+  threadId: string;
+  /** 解析失败被丢弃的 data 行数,0 表示整条流都解析正常 */
+  parseErrors: number;
+}
+
+/** 一次解析尝试的结果 */
+type FlushOutcome = 'terminated' | 'dispatched' | 'incomplete' | 'invalid' | 'empty';
+
+/** 默认的终止事件类型:收到其中之一即停止读取并释放连接 */
+const DEFAULT_TERMINATE_ON = ['done', 'error', 'interrupt'];
+
+/**
+ * 消费一个 SSE 响应体,逐事件回调,直到收到终止事件或流结束。
+ * @param response - fetch 返回的响应(须带 body)
+ * @param onEvent - 每个事件的回调
+ * @param options.terminateOn - 终止事件类型,默认 done / error / interrupt
+ */
+export async function consumeSse<E extends SseEventLike>(
+  response: Response,
+  onEvent: (event: E) => void,
+  options: { terminateOn?: readonly string[] } = {}
+): Promise<SseConsumeResult> {
+  const terminateOn = options.terminateOn ?? DEFAULT_TERMINATE_ON;
+
+  const body = response.body;
+  if (!body) {
+    throw new Error('SSE 响应缺少可读流');
+  }
+
+  const reader = body.getReader();
+  const decoder = new TextDecoder();
+
+  const result: SseConsumeResult = { sessionId: '', threadId: '', parseErrors: 0 };
+  let buffer = '';
+  /** 当前事件已收集、尚未解析的 data 行 */
+  let pending: string[] = [];
+
+  /** 丢弃坏数据时记账:解析失败不再无声跳过 */
+  const dropBadData = (raw: string) => {
+    result.parseErrors += 1;
+    console.warn('SSE数据解析失败:', raw);
+  };
+
+  /**
+   * 尝试把当前累积的 data 行解析成一个事件并派发。
+   * 解析失败时保留 pending 不清理——是"多行事件的前半段"还是"坏数据"由调用方判断。
+   */
+  const flushPending = (): FlushOutcome => {
+    if (pending.length === 0) return 'empty';
+
+    const raw = pending.join('\n');
+    // 规范规定 data 为空的事件不派发;服务端也有用 `data:` 空行做心跳的写法,故不算解析错误
+    if (raw.trim() === '') {
+      pending = [];
+      return 'empty';
+    }
+
+    let event: E;
+    try {
+      event = JSON.parse(raw) as E;
+    } catch {
+      return 'incomplete';
+    }
+
+    // 非对象负载(`data: null` / `data: 123`)没有事件语义,交给回调只会让它崩在取字段上。
+    // 按坏数据丢弃并继续读流——与重构前"回调抛错被 try 吞掉后继续"的最终表现一致。
+    if (typeof event !== 'object' || !event) {
+      dropBadData(raw);
+      pending = [];
+      return 'invalid';
+    }
+
+    pending = [];
+    onEvent(event);
+
+    if (event.type && terminateOn.includes(event.type)) {
+      result.sessionId = event.session_id || '';
+      result.threadId = event.thread_id || '';
+      return 'terminated';
+    }
+    return 'dispatched';
+  };
+
+  /** 处理一行;返回 true 表示已收到终止事件 */
+  const handleLine = (rawLine: string): boolean => {
+    // 兼容 CRLF / CR 行尾
+    const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
+
+    // 空行是规范定义的事件边界:到这里还解析不出来,才能判定为坏数据并丢弃
+    if (line === '') {
+      const outcome = flushPending();
+      if (outcome === 'incomplete') {
+        dropBadData(pending.join('\n'));
+        pending = [];
+      }
+      return outcome === 'terminated';
+    }
+
+    const colon = line.indexOf(':');
+    // 没有冒号的整行都是字段名、冒号在行首则是注释行(心跳),两者都不消费;
+    // 字段名与值之间允许 0 个空格,所以这里不能用 startsWith('data: ') 判断
+    if (colon <= 0 || line.slice(0, colon) !== 'data') return false;
+
+    const value = line.slice(colon + 1);
+    pending.push(value.startsWith(' ') ? value.slice(1) : value);
+
+    // 每收到一行就尝试解析累积的 data 行:
+    // - 能解析 → 立即派发(兼容事件之间不发送空行的服务端,与原实现行为一致)
+    // - 非对象负载 → 已记坏数据并清理干净,结束
+    // - 解析不出来且已积累多行 → 首行是坏数据,丢掉它再试,避免一行坏 JSON 连坐后续事件
+    // - 只剩一行 → 保留,它可能是多行事件的前半段
+    for (;;) {
+      const outcome = flushPending();
+      if (outcome === 'terminated') return true;
+      if (outcome === 'dispatched' || outcome === 'invalid') return false;
+      if (pending.length <= 1) return false;
+
+      dropBadData(pending.shift() as string);
+    }
+  };
+
+  try {
+    for (;;) {
+      const { done, value } = await reader.read();
+      if (done) break;
+
+      buffer += decoder.decode(value, { stream: true });
+      const lines = buffer.split('\n');
+      buffer = lines.pop() || '';
+
+      for (const line of lines) {
+        if (handleLine(line)) return result;
+      }
+    }
+
+    return result;
+  } finally {
+    try {
+      await reader.cancel();
+    } catch {
+      // 流已自然结束,或已被调用方的 AbortController 中断,取消失败无需处理
+    }
+  }
+}

+ 70 - 0
src/views/ventAI/manageAssistent/api/streamPost.ts

@@ -0,0 +1,70 @@
+// api/streamPost.ts:SSE 流式请求的发起与收尾。
+//
+// 与 sse.ts 的分工:sse.ts 只负责「怎么把字节流解析成事件」,本文件负责「怎么发起这次请求」——
+// 带令牌、非 2xx 解码成 StreamError、消费事件流、把用户主动中断当成正常结果、把结果归一成
+// { sessionId, threadId, parseErrors }。对话链路与点选解读链路共用这一份,避免各自演化。
+import { getToken } from '/@/utils/auth/index';
+import { consumeSse } from './sse';
+import type { SseConsumeResult, SseEventLike } from './sse';
+import { throwStreamHttpError } from './errors';
+
+export interface StreamPostOptions {
+  /**
+   * 请求体。FormData 走 multipart(不手写 Content-Type:boundary 必须由浏览器生成),
+   * 其余对象按 JSON 提交。
+   */
+  body: FormData | Record<string, unknown>;
+  /** 出错日志前缀,如「统一对话流式请求」 */
+  label: string;
+  signal?: AbortSignal;
+  /** 终止事件类型,默认 done / error / interrupt(见 sse.ts) */
+  terminateOn?: readonly string[];
+}
+
+/**
+ * 发起一次 SSE 流式 POST 并消费到终止事件。
+ * 用户主动中断(AbortError)不打日志:那是正常操作,不是异常。
+ *
+ * @param url - 接口地址(相对路径,由 dev 代理或同源网关转发)
+ * @param options - 请求体 / 日志前缀 / 中断信号 / 终止事件类型
+ * @param onChunk - 每个事件回调
+ */
+export const streamPost = async <E extends SseEventLike>(
+  url: string,
+  options: StreamPostOptions,
+  onChunk: (data: E) => void
+): Promise<SseConsumeResult> => {
+  let payload: BodyInit;
+  let contentType: Record<string, string> = {};
+  if (options.body instanceof FormData) {
+    payload = options.body;
+  } else {
+    payload = JSON.stringify(options.body);
+    contentType = { 'Content-Type': 'application/json' };
+  }
+
+  try {
+    const response = await fetch(url, {
+      method: 'POST',
+      body: payload,
+      headers: {
+        // 令牌是「可选」语义:getToken() 为空时仍照常发请求(后端有兜底账号)。
+        // 不要改成「未登录就拦截」——那会把未登录场景直接变成不可用。
+        'X-Access-Token': getToken(),
+        ...contentType,
+      },
+      signal: options.signal,
+    });
+
+    if (!response.ok) {
+      await throwStreamHttpError(response);
+    }
+
+    return await consumeSse<E>(response, onChunk, { terminateOn: options.terminateOn });
+  } catch (error) {
+    if ((error as Error)?.name !== 'AbortError') {
+      console.error(`${options.label}失败:`, error);
+    }
+    throw error;
+  }
+};

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

@@ -0,0 +1,72 @@
+// api/subagent.ts:子智能体(列表 / 工具注册表 / 增删改查 / 启停)。
+import { defHttp } from '/@/utils/http/axios/index';
+import type { SubAgent, SubAgentToolGroup } from '/@/views/ventAI/manageAssistent/types/index';
+
+enum Api {
+  subagents = '/ventAI/api/subagents',
+}
+
+/** 子智能体列表响应(GET /subagents):tool_infos 项形状统一为 { name, display_name, exists } */
+export interface SubAgentListResponse {
+  subagents: SubAgent[];
+}
+
+/** 工具注册表响应(GET /subagents/tools):响应中不存在 models 字段 */
+export interface SubAgentToolsResponse {
+  groups: SubAgentToolGroup[];
+}
+
+/**
+ * 获取子智能体列表。
+ * 响应外壳在此拆掉,调用方直接拿到数组,不再各自猜 subagents / data / 裸数组。
+ */
+export const getSubAgents = async (): Promise<SubAgent[]> => {
+  const res = await defHttp.get<SubAgentListResponse>({ url: Api.subagents }, { isTransformResponse: false });
+  return res?.subagents ?? [];
+};
+
+/**
+ * 获取子智能体表单可选用的工具注册表。
+ */
+export const getSubAgentTools = async (): Promise<SubAgentToolGroup[]> => {
+  const res = await defHttp.get<SubAgentToolsResponse>({ url: `${Api.subagents}/tools` }, { isTransformResponse: false });
+  return res?.groups ?? [];
+};
+
+/**
+ * 创建子智能体
+ */
+export const createSubAgent = (data: {
+  name: string;
+  description: string;
+  system_prompt: string;
+  tools?: string[];
+  model?: string;
+  enabled?: boolean;
+}): Promise<unknown> => defHttp.post<unknown>({ url: Api.subagents, data }, { isTransformResponse: false });
+
+/**
+ * 获取子智能体详情(当前无调用方,保留以镜像后端接口)
+ */
+export const getSubAgentDetail = (name: string): Promise<SubAgent> =>
+  defHttp.get<SubAgent>({ url: `${Api.subagents}/${name}` }, { isTransformResponse: false });
+
+/**
+ * 更新子智能体
+ */
+export const updateSubAgent = (
+  name: string,
+  data: { description?: string; system_prompt?: string; tools?: string[]; model?: string; enabled?: boolean }
+): Promise<unknown> => defHttp.put<unknown>({ url: `${Api.subagents}/${name}`, data }, { isTransformResponse: false });
+
+/**
+ * 删除子智能体
+ */
+export const deleteSubAgent = (name: string): Promise<unknown> =>
+  defHttp.delete<unknown>({ url: `${Api.subagents}/${name}` }, { isTransformResponse: false });
+
+/**
+ * 启用/禁用子智能体
+ */
+export const toggleSubAgent = (name: string): Promise<unknown> =>
+  defHttp.post<unknown>({ url: `${Api.subagents}/${name}/toggle` }, { isTransformResponse: false });

+ 29 - 0
src/views/ventAI/manageAssistent/api/usage.ts

@@ -0,0 +1,29 @@
+// api/usage.ts:今日用量与额度。
+import { defHttp } from '/@/utils/http/axios/index';
+
+enum Api {
+  usageToday = '/ventAI/api/usage/today',
+}
+
+export interface UsageByModel {
+  model: string;
+  cache_hit_tokens: number;
+  cache_miss_tokens: number;
+  output_tokens: number;
+  tokens: number;
+  request_count: number;
+}
+
+export interface TodayUsage {
+  date: string;
+  limit_tokens: number;
+  spent_tokens: number;
+  remaining_tokens: number;
+  request_count: number;
+  by_model: UsageByModel[];
+}
+
+/**
+ * 查询今日用量与额度
+ */
+export const getTodayUsage = (): Promise<TodayUsage> => defHttp.get<TodayUsage>({ url: Api.usageToday }, { isTransformResponse: false });

+ 28 - 0
src/views/ventAI/manageAssistent/api/user.ts

@@ -0,0 +1,28 @@
+// api/user.ts:当前登录用户的身份与权限。
+import { defHttp } from '/@/utils/http/axios/index';
+
+enum Api {
+  // 实测核对(2026-09-30):TfAgents 部署上该接口挂在 /api/model/me(dev 代理会剥掉 /ventAI 前缀),
+  // 审计 M3 重放时写的 /api/me 在部署上不存在(404)。以部署的 OpenAPI 为准。
+  me = '/ventAI/api/model/me',
+}
+
+export interface MeInfo {
+  username: string;
+  is_admin: boolean;
+  admin_configured: boolean;
+}
+
+/**
+ * 查询当前登录用户权限(TfAgents GET /api/model/me)。
+ * 令牌由 axios 请求拦截器统一带上,这里不手写请求头。
+ * errorMessageMode: 'none':接口不可用(404/网络失败)时用默认模式会让每次打开 AI 弹框都弹一个红错;
+ * 这里静默失败,由调用方按「非管理员」保守降级(且失败不缓存,每次打开重新拉取,管理员名单可能变更)。
+ *
+ * 字段语义(以后端实际返回为准,2026-09-30 实测):
+ *   { "username": "admin", "is_admin": false, "admin_configured": false }
+ *   - is_admin 由 TfAgents 侧管理员白名单判定;
+ *   - admin_configured=false 表示服务端白名单未配置,此时任何账号的 is_admin 都是 false
+ *     (入口会保持隐藏;前端是否据此提示「管理员未配置」属产品决策,当前不提示)。
+ */
+export const getMe = () => defHttp.get<MeInfo>({ url: Api.me }, { isTransformResponse: false, errorMessageMode: 'none' });

Plik diff jest za duży
+ 287 - 542
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue


+ 394 - 0
src/views/ventAI/manageAssistent/components/chat/AskUserCard.vue

@@ -0,0 +1,394 @@
+<template>
+  <!-- ask_user 分步向导:按题逐条问答,可返回上一步;required !== false 的题必须作答才能继续 -->
+  <div class="approval-card">
+    <div class="approval-icon"></div>
+    <div class="approval-content">
+      <div class="approval-title">{{ data.message }}</div>
+
+      <!-- 计划内容 -->
+      <div v-if="data.plan" class="approval-plan">
+        <div class="approval-plan-label">执行计划</div>
+        <div class="approval-plan-content">{{ data.plan }}</div>
+      </div>
+
+      <!-- 步骤指示器 -->
+      <div v-if="questions.length > 1" class="ask-user-stepper">
+        <div
+          v-for="(_, sIdx) in questions"
+          :key="sIdx"
+          :class="['ask-user-step-dot', { active: sIdx === step, completed: sIdx < step || !!(answers[sIdx] && answers[sIdx].trim()) }]"
+          @click="step = sIdx"
+        ></div>
+        <span class="ask-user-step-text">{{ step + 1 }} / {{ questions.length }}</span>
+      </div>
+
+      <!-- 当前问题 -->
+      <div v-if="current" class="ask-user-question">
+        <div class="ask-user-question-text">
+          {{ current.question }}
+          <span v-if="current.required !== false" class="ask-user-required">*</span>
+        </div>
+
+        <!-- multiple_choice 类型 -->
+        <div v-if="current.type === 'multiple_choice'" class="ask-user-choices">
+          <div
+            v-for="(choice, cIdx) in current.choices"
+            :key="cIdx"
+            :class="['ask-user-choice', { selected: answers[step] === getChoiceValue(choice) && !otherSelected[step] }]"
+            @click="selectChoice(step, choice, questions.length)"
+          >
+            <span>{{ getChoiceValue(choice) }}</span>
+          </div>
+          <!-- Other 选项 -->
+          <div :class="['ask-user-choice', { selected: !!otherSelected[step] }]" @click="selectOther(step)">
+            <span>其他</span>
+          </div>
+          <input
+            v-if="otherSelected[step]"
+            class="ask-user-other-input"
+            placeholder="请输入..."
+            :value="answers[step]"
+            @input="answers[step] = ($event.target as HTMLInputElement).value"
+          />
+        </div>
+
+        <!-- text 类型 -->
+        <div v-else-if="current.type === 'text'" class="ask-user-text">
+          <textarea
+            class="ask-user-textarea"
+            placeholder="请输入..."
+            :value="answers[step]"
+            @input="answers[step] = ($event.target as HTMLTextAreaElement).value"
+          ></textarea>
+        </div>
+      </div>
+
+      <!-- 操作按钮 -->
+      <div class="approval-actions">
+        <button v-if="step > 0" class="approval-btn nav-btn" @click="step--">上一步</button>
+        <button
+          v-if="step < questions.length - 1"
+          class="approval-btn approve-btn"
+          :disabled="!isAnswered(step, current)"
+          @click="goNext(questions.length)"
+        >
+          下一步
+        </button>
+        <button v-if="step === questions.length - 1" class="approval-btn approve-btn" :disabled="!isAnswered(step, current)" @click="submit"
+          >提交</button
+        >
+        <button class="approval-btn reject-btn" @click="emit('cancel')">取消</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, ref, watch } from 'vue';
+  import type { AskUserQuestion, PendingApprovalData } from '/@/views/ventAI/manageAssistent/types/index';
+
+  /**
+   * ask_user 审批卡(分步向导)。
+   *
+   * 向导状态(答案 / 是否选了“其他” / 当前步骤)由卡片自己持有:它是纯视图状态,
+   * 只属于这一条消息。父组件仍暴露按消息下标初始化答案的入口(SSE 收到 interrupt 时会调用),
+   * 通过组件实例路由到对应卡片,保持原有对外契约。
+   */
+  const props = defineProps<{
+    data: PendingApprovalData;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'submit', answers: string[]): void;
+    (e: 'cancel'): void;
+  }>();
+
+  const answers = ref<string[]>([]);
+  const otherSelected = ref<boolean[]>([]);
+  const step = ref(0);
+
+  const questions = computed<AskUserQuestion[]>(() => props.data.ask_user?.questions ?? []);
+  const current = computed<AskUserQuestion | undefined>(() => questions.value[step.value]);
+
+  const init = (qs: AskUserQuestion[]) => {
+    answers.value = qs.map(() => '');
+    otherSelected.value = qs.map(() => false);
+    step.value = 0;
+  };
+
+  // 每次 interrupt 都会替换 pendingApprovalData 对象,按引用变化重建答案(同一条消息可能被多次中断)
+  watch(
+    () => props.data,
+    (d) => {
+      const qs = d?.ask_user?.questions;
+      if (qs && qs.length) init(qs);
+    },
+    { immediate: true }
+  );
+
+  // choice 可能是字符串或 { value: "..." } 对象,统一提取字符串
+  const getChoiceValue = (choice: any): string => {
+    if (typeof choice === 'string') return choice;
+    if (choice && typeof choice === 'object' && 'value' in choice) return choice.value;
+    return String(choice);
+  };
+
+  const isAnswered = (qIndex: number, question?: AskUserQuestion): boolean => {
+    if (!question) return false;
+    const value = answers.value[qIndex];
+    if (question.required === false && !value) return true;
+    return !!(value && value.trim() !== '');
+  };
+
+  const selectChoice = (qIndex: number, choice: any, total: number) => {
+    answers.value[qIndex] = getChoiceValue(choice);
+    otherSelected.value[qIndex] = false;
+    // 单选自动跳到下一题(非最后一题时)
+    if (qIndex < total - 1) step.value = qIndex + 1;
+  };
+
+  const selectOther = (qIndex: number) => {
+    otherSelected.value[qIndex] = true;
+    answers.value[qIndex] = '';
+  };
+
+  const goNext = (total: number) => {
+    if (step.value < total - 1) step.value++;
+  };
+
+  const submit = () => {
+    // 防御性兜底:答案数组与题数不一致(未初始化)时按题数重建
+    if (answers.value.length !== questions.value.length) init(questions.value);
+    emit('submit', [...answers.value]);
+  };
+
+  defineExpose({ init });
+</script>
+
+<style scoped lang="less">
+  .approval-card {
+    display: flex;
+    align-items: flex-start;
+    gap: 12px;
+    padding: 14px 16px;
+    margin-top: 12px;
+    background: #002f4a;
+    border: 1px solid #005482;
+    border-radius: 8px;
+
+    .approval-icon {
+      width: 36px;
+      height: 36px;
+      background-image: var(--img-chat-session-approve-icon);
+      background-repeat: no-repeat;
+      background-size: 100% 100%;
+      flex-shrink: 0;
+    }
+
+    .approval-content {
+      flex: 1;
+
+      .approval-title {
+        font-size: 14px;
+        font-weight: 600;
+        color: #e0e6ed;
+        margin-bottom: 6px;
+      }
+
+      .approval-plan {
+        margin-bottom: 14px;
+        padding: 10px 12px;
+        background: rgba(10, 132, 255, 0.08);
+        border: 1px solid rgba(10, 132, 255, 0.2);
+        border-radius: 6px;
+
+        .approval-plan-label {
+          font-size: 12px;
+          color: #0a84ff;
+          font-weight: 600;
+          margin-bottom: 6px;
+        }
+
+        .approval-plan-content {
+          font-size: 13px;
+          color: #c9d1d9;
+          line-height: 1.6;
+          white-space: pre-wrap;
+        }
+      }
+
+      .ask-user-question {
+        margin-bottom: 14px;
+
+        .ask-user-question-text {
+          font-size: 13px;
+          color: #c9d1d9;
+          margin-bottom: 8px;
+          line-height: 1.5;
+
+          .ask-user-required {
+            color: #f85149;
+            margin-left: 2px;
+          }
+        }
+
+        .ask-user-choices {
+          display: flex;
+          flex-direction: column;
+          gap: 6px;
+
+          .ask-user-choice {
+            display: flex;
+            align-items: center;
+            padding: 8px 12px;
+            background: rgba(255, 255, 255, 0.04);
+            border: 1px solid rgba(63, 80, 106, 0.4);
+            border-radius: 6px;
+            cursor: pointer;
+            font-size: 13px;
+            color: #c9d1d9;
+            transition: all 0.2s;
+
+            &:hover {
+              background: rgba(10, 132, 255, 0.1);
+              border-color: rgba(10, 132, 255, 0.4);
+            }
+
+            &.selected {
+              background: rgba(10, 132, 255, 0.15);
+              border-color: #0a84ff;
+              color: #fff;
+            }
+          }
+
+          .ask-user-other-input {
+            margin-top: 4px;
+            padding: 8px 12px;
+            font-size: 13px;
+            color: #e0e6ed;
+            background: rgba(255, 255, 255, 0.06);
+            border: 1px solid rgba(63, 80, 106, 0.5);
+            border-radius: 6px;
+            outline: none;
+            width: 100%;
+
+            &:focus {
+              border-color: #0a84ff;
+              background: rgba(10, 132, 255, 0.08);
+            }
+
+            &::placeholder {
+              color: #6e7681;
+            }
+          }
+        }
+
+        .ask-user-text {
+          .ask-user-textarea {
+            width: 100%;
+            min-height: 60px;
+            padding: 8px 12px;
+            font-size: 13px;
+            color: #e0e6ed;
+            background: rgba(255, 255, 255, 0.06);
+            border: 1px solid rgba(63, 80, 106, 0.5);
+            border-radius: 6px;
+            outline: none;
+            resize: vertical;
+            font-family: inherit;
+
+            &:focus {
+              border-color: #0a84ff;
+              background: rgba(10, 132, 255, 0.08);
+            }
+
+            &::placeholder {
+              color: #6e7681;
+            }
+          }
+        }
+      }
+
+      .ask-user-stepper {
+        display: flex;
+        align-items: center;
+        gap: 6px;
+        margin-bottom: 14px;
+
+        .ask-user-step-dot {
+          width: 8px;
+          height: 8px;
+          border-radius: 50%;
+          background: rgba(63, 80, 106, 0.5);
+          cursor: pointer;
+          transition: all 0.2s;
+
+          &.active {
+            background: #0a84ff;
+            width: 20px;
+            border-radius: 4px;
+          }
+
+          &.completed {
+            background: rgba(10, 132, 255, 0.5);
+          }
+        }
+
+        .ask-user-step-text {
+          margin-left: 6px;
+          font-size: 12px;
+          color: #6e7681;
+        }
+      }
+
+      .approval-actions {
+        display: flex;
+        gap: 10px;
+
+        .approval-btn {
+          padding: 6px 20px;
+          border-radius: 6px;
+          font-size: 13px;
+          font-weight: 500;
+          cursor: pointer;
+          border: none;
+          transition: all 0.2s;
+
+          &.approve-btn {
+            background: #0a84ff;
+            color: #fff;
+
+            &:hover:not(:disabled) {
+              background: #3a9bfd;
+            }
+
+            &:disabled {
+              opacity: 0.4;
+              cursor: not-allowed;
+            }
+          }
+
+          &.nav-btn {
+            background: rgba(255, 255, 255, 0.08);
+            color: #c9d1d9;
+            border: 1px solid rgba(63, 80, 106, 0.5);
+
+            &:hover {
+              background: rgba(255, 255, 255, 0.15);
+            }
+          }
+
+          &.reject-btn {
+            background: rgba(255, 255, 255, 0.08);
+            color: #e0e6ed;
+            border: 1px solid rgba(63, 80, 106, 0.5);
+
+            &:hover {
+              background: rgba(255, 255, 255, 0.15);
+            }
+          }
+        }
+      }
+    }
+  }
+</style>

+ 5 - 2
src/views/ventAI/manageAssistent/components/chatModal/ChatHeader.vue → src/views/ventAI/manageAssistent/components/chat/ChatHeader.vue

@@ -42,6 +42,7 @@
       @file-preview="emit('file-preview', $event)"
       @file-preview="emit('file-preview', $event)"
       @agent-detail-click="emit('agent-detail-click', $event)"
       @agent-detail-click="emit('agent-detail-click', $event)"
       @locate-message="emit('locate-message', $event)"
       @locate-message="emit('locate-message', $event)"
+      @open-web="emit('open-web', $event)"
     />
     />
 
 
     <!-- 会话导出 -->
     <!-- 会话导出 -->
@@ -71,8 +72,8 @@
 <script setup lang="ts">
 <script setup lang="ts">
   import { ref, nextTick } from 'vue';
   import { ref, nextTick } from 'vue';
   import { onClickOutside } from '@vueuse/core';
   import { onClickOutside } from '@vueuse/core';
-  import ResourceCapsule from './ResourceCapsule.vue';
-  import type { AttachedFile, CapsuleFileItem, CapsuleLinkItem, CapsuleAgentItem, Task } from './types';
+  import ResourceCapsule from '/@/views/ventAI/manageAssistent/components/resource/ResourceCapsule.vue';
+  import type { AttachedFile, CapsuleFileItem, CapsuleLinkItem, CapsuleAgentItem, Task } from '/@/views/ventAI/manageAssistent/types/index';
 
 
   interface Props {
   interface Props {
     currentTask: Task | undefined;
     currentTask: Task | undefined;
@@ -102,6 +103,8 @@
     'agent-detail-click': [data: { messageIndex: number; agent: string; agentId?: string }];
     'agent-detail-click': [data: { messageIndex: number; agent: string; agentId?: string }];
     // 胶囊面板中点击"定位到消息",交给父组件滚动消息列表
     // 胶囊面板中点击"定位到消息",交给父组件滚动消息列表
     'locate-message': [messageIndex: number];
     'locate-message': [messageIndex: number];
+    // 胶囊面板中点击站内链接,交给父组件在右侧面板用 iframe 打开
+    'open-web': [payload: { url: string; title?: string }];
   }>();
   }>();
 
 
   // 导出气泡状态
   // 导出气泡状态

+ 685 - 0
src/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue

@@ -0,0 +1,685 @@
+<template>
+  <div class="chat-input-area">
+    <div v-if="pendingFile" class="pending-file-display">
+      <div class="pending-file-card" @click="emit('pending-file-preview', pendingFile)">
+        <div class="file-icon-pending" :style="{ backgroundImage: getFileIconVar(pendingFile.name) }"></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.stop="emit('remove-pending-file')">
+          <CloseOutlined />
+        </div>
+      </div>
+    </div>
+
+    <!-- 已选会话引用标签 -->
+    <div v-if="selectedSessions.length > 0" class="session-chips">
+      <div v-for="session in selectedSessions" :key="session.id" class="session-chip">
+        <div class="session-chip-icon"></div>
+        <span class="session-chip-name">{{ session.name }}</span>
+        <CloseOutlined class="session-chip-remove" @click.stop="removeSession(session.id)" />
+      </div>
+    </div>
+
+    <!-- 已选技能引用标签 -->
+    <div v-if="selectedSkill" class="skill-chips">
+      <div class="skill-chip">
+        <div class="skill-chip-icon"></div>
+        <span class="skill-chip-name">{{ selectedSkill.display_name }}</span>
+        <CloseOutlined class="skill-chip-remove" @click.stop="removeSkill()" />
+      </div>
+    </div>
+
+    <a-textarea
+      ref="textareaRef"
+      :value="modelValue"
+      @update:value="handleTextareaInput"
+      :rows="3"
+      class="chat-input"
+      placeholder="请输入你的问题"
+      :auto-size="{ minRows: 3, maxRows: 6 }"
+      @keydown="handleInputKeydown"
+      @keydown.enter="handleEnterKey"
+      @keydown.backspace="handleBackspace"
+      @compositionstart="handleCompositionStart"
+      @compositionend="handleCompositionEnd"
+      @blur="handleCompositionEnd"
+      @paste="handlePaste"
+    />
+
+    <div class="input-actions">
+      <div class="action-left">
+        <div class="btn-upload-wrapper">
+          <a-tooltip title="添加上下文">
+            <div class="btn-upload" @click="togglePopup">
+              <div class="plus-icon"></div>
+            </div>
+          </a-tooltip>
+          <div v-if="showPopup" ref="popupRef" class="upload-popup" @click.stop>
+            <div class="popup-item" @click="handleAddAttachment">
+              <div class="popup-item-icon attach-icon"></div>
+              <span class="popup-item-text">添加附件</span>
+            </div>
+            <div class="popup-item" @click="handleInsertSession">
+              <div class="popup-item-icon session-icon"></div>
+              <span class="popup-item-text">插入 # 会话</span>
+            </div>
+            <div class="popup-item" @click="handleInsertSkill">
+              <div class="popup-item-icon skill-icon"></div>
+              <span class="popup-item-text">插入 $ 技能</span>
+            </div>
+          </div>
+        </div>
+        <div class="action-divider"></div>
+        <ModePopup :initial-mode="initialEditMode" @change="emit('edit-mode-change', $event)" />
+        <input ref="fileInputRef" type="file" style="display: none" @change="emit('file-change', $event)" />
+      </div>
+      <div class="action-right">
+        <ContextRing :used="contextUsed" :max="contextMax" :percent="contextPercent" :breakdown="contextBreakdown" />
+        <div class="action-divider"></div>
+        <UsageRing :limit="usageLimit" :spent="usageSpent" :remaining="usageRemaining" :by-model="usageByModel" />
+        <!-- 思考级别对非管理员改为「可见但只读」:控件本身还承担「当前是什么级别」的信息,
+             藏掉就看不到了;因此保留显示、由组件内部拦住展开与切换(不会触发切换接口)。
+             控件常驻后它前面的 .action-divider 也常驻,不会再出现「隐藏控件残留一根竖线」的问题。
+             思考级别的"读取"(getModelMsg)不受影响,对所有人依旧可用。 -->
+        <div class="action-divider"></div>
+        <ThinkLevelPopup :initial-level="initialThinkLevel" :disabled="!isAdmin" @change="emit('think-level-change', $event)" />
+        <div class="action-divider"></div>
+        <div class="btn-send" :class="{ disabled: !streaming && (!modelValue.trim() || loading) }" @click="handleSendClick">
+          <div class="send-icon" :class="{ 'stop-icon': streaming }"></div>
+        </div>
+      </div>
+
+      <SessionPopup
+        v-if="showSessionPopup"
+        :list="filteredSessionList"
+        :active-index="activeSessionIndex"
+        :selected-ids="selectedSessions.map((s) => s.id)"
+        :current-task-id="currentTaskId || ''"
+        @select="handleSelectSession"
+        @close="closeSessionPopup"
+      />
+      <SkillPopup
+        v-if="showSkillPopup"
+        :list="filteredSkillList"
+        :active-index="activeSkillIndex"
+        :selected-name="selectedSkill ? selectedSkill.name : ''"
+        :load-failed="skillListLoadFailed"
+        @select="handleSelectSkill"
+        @close="closeSkillPopup"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, nextTick, ref, watch } from 'vue';
+  import { onClickOutside } from '@vueuse/core';
+  import { CloseOutlined } from '@ant-design/icons-vue';
+  import { formatFileSize, getClipboardFile, getFileIconVar, getUserInputText } from '/@/views/ventAI/manageAssistent/lib/index';
+  import ContextRing from '/@/views/ventAI/manageAssistent/components/chat/Input/ContextRing.vue';
+  import UsageRing from '/@/views/ventAI/manageAssistent/components/chat/Input/UsageRing.vue';
+  import ModePopup from '/@/views/ventAI/manageAssistent/components/chat/Input/ModePopup.vue';
+  import ThinkLevelPopup from '/@/views/ventAI/manageAssistent/components/chat/Input/ThinkLevelPopup.vue';
+  import SessionPopup from '/@/views/ventAI/manageAssistent/components/chat/Input/SessionPopup.vue';
+  import SkillPopup from '/@/views/ventAI/manageAssistent/components/chat/Input/SkillPopup.vue';
+  import { useMentionPopups } from '/@/views/ventAI/manageAssistent/hooks/useMentionPopups';
+  import { useInputHistory } from '/@/views/ventAI/manageAssistent/hooks/useInputHistory';
+  import type { UsageByModel } from '/@/views/ventAI/manageAssistent/api';
+  import type { AttachedFile, Task } from '/@/views/ventAI/manageAssistent/types/index';
+
+  const props = withDefaults(
+    defineProps<{
+      modelValue: string;
+      pendingFile: AttachedFile | null;
+      loading: boolean;
+      streaming?: boolean;
+      contextUsed?: number;
+      contextMax?: number;
+      contextPercent?: number;
+      contextBreakdown?: { messages: number; mcp: number; skills: number; system_prompt: number; other: number };
+      usageLimit?: number;
+      usageSpent?: number;
+      usageRemaining?: number;
+      usageByModel?: UsageByModel[];
+      initialThinkLevel?: string;
+      // [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3):是否管理员(AiAssistantModal 透传)。
+      // 可选、默认 false:值缺失 = 按非管理员处理,隐藏「思考级别」切换入口。
+      isAdmin?: boolean;
+      initialEditMode?: string;
+      taskList?: Task[];
+      currentTaskId?: string;
+    }>(),
+    {
+      contextUsed: 0,
+      contextMax: 1000000,
+      contextPercent: 0,
+      streaming: false,
+      contextBreakdown: () => ({ messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 }),
+      usageLimit: 1000,
+      usageSpent: 0,
+      usageRemaining: 0,
+      usageByModel: () => [],
+      initialThinkLevel: 'off',
+      // [审计-第四轮 M3] 保守默认:未传 isAdmin 时按非管理员处理(隐藏思考级别切换)
+      isAdmin: false,
+      initialEditMode: 'full',
+      taskList: () => [],
+      currentTaskId: '',
+    }
+  );
+
+  const emit = defineEmits<{
+    (e: 'update:modelValue', value: string): void;
+    (e: 'send'): void;
+    (e: 'stop'): void;
+    (e: 'file-upload'): void;
+    (e: 'file-change', event: Event): void;
+    // 拖拽 / 粘贴进来的文件(已从事件里取出,输入区只负责转交)
+    (e: 'file-add', file: File): void;
+    (e: 'remove-pending-file'): void;
+    (e: 'pending-file-preview', file: AttachedFile): void;
+    (e: 'think-level-change', levelKey: string): void;
+    (e: 'edit-mode-change', modeKey: string): void;
+    (e: 'update:selectedSessions', sessions: Array<{ id: string; name: string }>): void;
+    (e: 'update:selectedSkill', skill: { name: string; display_name: string } | null): void;
+  }>();
+
+  const fileInputRef = ref<HTMLInputElement>();
+  const popupRef = ref<HTMLElement>();
+  const showPopup = ref(false);
+
+  // 上下文容量
+  // 思考级别
+  onClickOutside(popupRef, () => {
+    showPopup.value = false;
+  });
+
+  const togglePopup = () => {
+    showPopup.value = !showPopup.value;
+  };
+
+  const handleAddAttachment = () => {
+    showPopup.value = false;
+    emit('file-upload');
+  };
+
+  // 会话列表弹框
+  const textareaRef = ref<InstanceType<any>>();
+
+  // a-textarea 的根节点就是原生 textarea(换行/自动高度等场景下由内部包装时回去找一层),
+  // 焦点、光标位置、光标归位都基于同一个取法,避免各处各写一份 $el 解析
+  const getTextareaEl = (): HTMLTextAreaElement | undefined => {
+    const root = textareaRef.value?.$el;
+    if (!root) return undefined;
+    if (root instanceof HTMLTextAreaElement) return root;
+    return root.querySelector?.('textarea') || undefined;
+  };
+
+  // —— 会话/技能引用弹层已下沉到 useMentionPopups(模板仍在本组件,状态与交互逻辑在 composable) ——
+  const {
+    showSessionPopup,
+    activeSessionIndex,
+    selectedSessions,
+    showSkillPopup,
+    activeSkillIndex,
+    selectedSkill,
+    filteredSessionList,
+    filteredSkillList,
+    skillListLoadFailed,
+    handleSessionKeydown,
+    handleSelectSession,
+    handleSelectSkill,
+    handleInsertSession,
+    handleInsertSkill,
+    removeSession,
+    removeSkill,
+    handleBackspace,
+    syncPopups,
+    consumeJustSelected,
+    closeSessionPopup,
+    closeSkillPopup,
+  } = useMentionPopups({
+    getModelValue: () => props.modelValue,
+    setModelValue: (value) => emit('update:modelValue', value),
+    getTaskList: () => props.taskList || [],
+    getCurrentTaskId: () => props.currentTaskId || '',
+    isComposing: () => composing,
+    closeAttachPopup: () => {
+      showPopup.value = false;
+    },
+    focusTextarea: () => {
+      getTextareaEl()?.focus();
+    },
+    setSelectedSessions: (sessions) => emit('update:selectedSessions', sessions),
+    setSelectedSkill: (skill) => emit('update:selectedSkill', skill),
+  });
+  // 中文等输入法的组合态标记:拼音候选期间按回车是「候选上屏」,不能当成发送/选中列表项。
+  // 浏览器对该次 keydown 的 event.isComposing 支持不一致(Safari 会提前置 false),
+  // 因此用 compositionstart/end 自行记录;blur 时兜底复位,避免组合态卡住导致回车失效。
+  let composing = false;
+
+  const handleCompositionStart = () => {
+    composing = true;
+  };
+
+  const handleCompositionEnd = () => {
+    composing = false;
+  };
+
+  // —— ↑/↓ 历史回溯:历史取当前会话里已发送的用户输入(原始文本,不含注入的引用 HTML) ——
+  const userInputHistory = computed(() => {
+    const task = (props.taskList || []).find((t) => t.id === props.currentTaskId);
+    if (!task) return [];
+    return task.messages
+      .filter((m) => m.type === 'user')
+      .map((m) => getUserInputText(m))
+      .filter((text) => !!text);
+  });
+
+  // 冷启动判据:光标在文本最开头(或输入框为空)才认 ↑。多行草稿里 ↑ 仍归 textarea 移动光标。
+  // 取不到元素时不拦,宁可让它走历史,也不要变成按了没反应的死键。
+  const isCaretAtStart = () => {
+    const el = getTextareaEl();
+    if (!el) return true;
+    return el.selectionStart === 0 && el.selectionEnd === 0;
+  };
+
+  const moveCaretToEnd = () => {
+    // 回填的值要先经父组件 v-model 写回 textarea,必须等一帧再定位光标,否则长度还是旧值
+    nextTick(() => {
+      const el = getTextareaEl();
+      if (!el) return;
+      const end = el.value.length;
+      el.setSelectionRange(end, end);
+    });
+  };
+
+  const {
+    handleKeydown: handleHistoryKeydown,
+    notifyValueChanged: notifyHistoryValueChanged,
+    reset: resetInputHistory,
+  } = useInputHistory({
+    getHistory: () => userInputHistory.value,
+    getModelValue: () => props.modelValue,
+    setModelValue: (value) => emit('update:modelValue', value),
+    isComposing: () => composing,
+    isPopupOpen: () => showSessionPopup.value || showSkillPopup.value,
+    isCaretAtStart,
+    moveCaretToEnd,
+  });
+
+  // 切会话后历史整条换掉,回看位置不再有意义
+  watch(
+    () => props.currentTaskId,
+    () => resetInputHistory()
+  );
+
+  /**
+   * 方向键有两个归属:会话/技能弹层打开时归弹层列表导航,否则归输入框历史回溯。
+   * 合成一个入口显式定序(弹层优先),而不是在同一元素上挂两个 @keydown 依赖编译器的合并顺序。
+   */
+  const handleInputKeydown = (e: KeyboardEvent) => {
+    handleSessionKeydown(e);
+    handleHistoryKeydown(e);
+  };
+
+  const handleTextareaInput = (val: string) => {
+    emit('update:modelValue', val);
+    syncPopups(val);
+    // 用户手动改过内容就退出历史回看,否则后续 ↑ 会把他刚改的字覆盖掉
+    notifyHistoryValueChanged(val);
+  };
+
+  /**
+   * 粘贴上传:剪贴板里带文件(截图、复制的文件)时按附件处理,
+   * 图文混排的判定收敛在 lib/clipboard.getClipboardFile 内(有文本就不接管)。
+   */
+  const handlePaste = (e: ClipboardEvent) => {
+    const file = getClipboardFile(e.clipboardData);
+    if (!file) return;
+    e.preventDefault();
+    emit('file-add', file);
+  };
+
+  const handleEnterKey = (e: KeyboardEvent) => {
+    // 输入法组合态下的回车只用于上屏候选词:既不发送,也不阻止默认行为(否则会打断候选上屏)
+    if (composing || e.isComposing) return;
+    // 弹层里被消费的回车由 useMentionPopups 自己 preventDefault,这里不再插手
+    if (showSessionPopup.value || showSkillPopup.value) return;
+    // Shift+Enter 留给多行输入:不阻止默认行为,让 textarea 正常插入换行
+    if (e.shiftKey) return;
+    // 其余情况沿用「回车不插入换行」:不能发送(内容为空 / 生成中)时也保持这一行为
+    e.preventDefault();
+    if (consumeJustSelected()) return;
+    handleSend();
+  };
+
+  const handleSend = () => {
+    if (props.modelValue.trim() && !props.loading) {
+      // 发送后输入框会被清空,历史回看位置一并作废
+      resetInputHistory();
+      emit('send');
+    }
+  };
+
+  const handleSendClick = () => {
+    if (props.streaming) {
+      emit('stop');
+      return;
+    }
+    handleSend();
+  };
+
+  defineExpose({ fileInputRef });
+</script>
+
+<style scoped lang="less">
+  .chat-input-area {
+    position: relative;
+    padding: 5px 10px;
+    flex-shrink: 0;
+    background-color: #002f4a;
+    border: 1px solid #005482;
+    border-radius: 4px;
+    margin: 10px 16px 16px 16px;
+
+    .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;
+        cursor: pointer;
+
+        &: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: space-between;
+      align-items: center;
+      margin-top: 12px;
+
+      .action-left,
+      .action-right {
+        display: flex;
+        gap: 8px;
+        .action-divider {
+          width: 1px;
+          height: 18px;
+          background: rgba(255, 255, 255, 0.15);
+          align-self: center;
+          flex-shrink: 0;
+        }
+        .btn-upload-wrapper {
+          position: relative;
+        }
+        .btn-upload {
+          width: 30px;
+          height: 30px;
+          border-radius: 6px;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          cursor: pointer;
+        }
+        .plus-icon {
+          width: 20px;
+          height: 20px;
+          background-image: var(--img-chat-input-bg);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          cursor: pointer;
+        }
+        .upload-popup {
+          position: absolute;
+          bottom: 38px;
+          left: 0;
+          background: #0a2a3f;
+          border: 1px solid #1a4a6f;
+          border-radius: 8px;
+          min-width: 160px;
+          z-index: 1000;
+          box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+
+          .popup-item {
+            display: flex;
+            align-items: center;
+            gap: 10px;
+            padding: 9px 16px;
+            cursor: pointer;
+            transition: background 0.2s;
+            border-radius: 8px;
+
+            &:hover {
+              background: rgba(10, 132, 255, 0.15);
+            }
+
+            .popup-item-icon {
+              width: 18px;
+              height: 18px;
+              background-repeat: no-repeat;
+              background-size: 100% 100%;
+              flex-shrink: 0;
+            }
+
+            .attach-icon {
+              background-image: var(--img-chat-popup-attach-icon);
+            }
+
+            .session-icon {
+              background-image: var(--img-chat-popup-session-icon);
+            }
+
+            .skill-icon {
+              background-image: var(--img-chat-options-skill-icon);
+            }
+
+            .popup-item-text {
+              font-size: 13px;
+              color: #e0e6ed;
+              white-space: nowrap;
+            }
+          }
+        }
+        .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%;
+
+            &.stop-icon {
+              background-image: var(--img-chat-stop-icon);
+            }
+          }
+        }
+      }
+    }
+
+    .session-chips {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 6px;
+      margin-bottom: 5px;
+
+      .session-chip {
+        display: inline-flex;
+        align-items: center;
+        gap: 4px;
+        padding: 3px 6px 3px 4px;
+        background: rgba(10, 132, 255, 0.15);
+        border: 1px solid rgba(10, 132, 255, 0.3);
+        border-radius: 4px;
+        font-size: 12px;
+        color: #e0e6ed;
+        cursor: default;
+
+        .session-chip-icon {
+          width: 16px;
+          height: 16px;
+          background-image: var(--img-chat-popup-session-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          flex-shrink: 0;
+        }
+
+        .session-chip-name {
+          max-width: 150px;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+
+        .session-chip-remove {
+          font-size: 10px;
+          color: #8b949e;
+          cursor: pointer;
+          padding: 0 2px;
+          transition: color 0.2s;
+
+          &:hover {
+            color: #ff4d4f;
+          }
+        }
+      }
+    }
+
+    .skill-chips {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 6px;
+      margin-bottom: 5px;
+
+      .skill-chip {
+        display: inline-flex;
+        align-items: center;
+        gap: 4px;
+        padding: 3px 6px 3px 4px;
+        background: rgba(10, 132, 255, 0.15);
+        border: 1px solid rgba(10, 132, 255, 0.3);
+        border-radius: 4px;
+        font-size: 12px;
+        color: #e0e6ed;
+        cursor: default;
+
+        .skill-chip-icon {
+          width: 16px;
+          height: 16px;
+          background-image: var(--img-chat-options-skill-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          flex-shrink: 0;
+        }
+
+        .skill-chip-name {
+          max-width: 150px;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+
+        .skill-chip-remove {
+          font-size: 10px;
+          color: #8b949e;
+          cursor: pointer;
+          padding: 0 2px;
+          transition: color 0.2s;
+
+          &:hover {
+            color: #ff4d4f;
+          }
+        }
+      }
+    }
+  }
+</style>

+ 1309 - 0
src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue

@@ -0,0 +1,1309 @@
+<template>
+  <div class="chat-messages-container">
+    <!-- 左侧对话缩略导航(悬浮提示与滚动跟随内聚在组件内部) -->
+    <ConversationNav
+      ref="conversationNavRef"
+      :messages="messages"
+      :is-at-bottom="isAtBottom"
+      :get-container="() => messagesRef"
+      :scroll-to-message="scrollToMessage"
+    />
+
+    <div class="chat-messages" ref="messagesRef">
+      <!-- 历史加载中:切换任务后新任务的 messages 要等接口返回才填充,
+           这里必须先占位,否则会先闪一屏「新对话缺省图」再突然出现内容 -->
+      <div v-if="messages.length === 0 && historyLoading" class="history-loading">
+        <a-spin size="large" />
+      </div>
+      <!-- 新对话缺省图 -->
+      <div v-else-if="messages.length === 0" class="welcome-empty">
+        <img :src="getCssUrl('--img-chat-welcome-img')" class="welcome-img" />
+      </div>
+      <div
+        v-for="(message, index) in messages"
+        :key="getMessageKey(message)"
+        :class="['message-wrapper', message.type, { 'answer-hidden': isHiddenAnswer(index) }]"
+        @mouseleave="copiedMessageKey = ''"
+      >
+        <!-- AI消息 -->
+        <div v-if="message.type === 'ai'" class="ai-message">
+          <div class="message-body">
+            <ThinkingCard
+              v-if="message.thinkingSteps && message.thinkingSteps.length > 0"
+              :message="message"
+              :collapsed="!!collapsedThinking[getMessageKey(message)]"
+              :content-collapsed="!!collapsedThinkingContent[getMessageKey(message)]"
+              @update:collapsed="collapsedThinking[getMessageKey(message)] = $event"
+              @update:content-collapsed="collapsedThinkingContent[getMessageKey(message)] = $event"
+              @agent-click="handleAgentGroupClick(index, $event)"
+            />
+
+            <div
+              v-if="message.content"
+              class="message-text"
+              v-html="
+                renderMarkdownCached(
+                  message.content,
+                  tableIcons,
+                  message.citations?.items?.length ? Math.max(...message.citations.items.map((i) => i.id)) : undefined,
+                  !message.isLoading
+                )
+              "
+              @click="handleMessageTextClick($event, index)"
+            ></div>
+
+            <!-- 数据来源卡片 -->
+            <CitationsCard
+              v-if="message.citations"
+              :message="message"
+              :index="index"
+              :collapsed="collapsedCitations[getMessageKey(message)] ?? true"
+              @update:collapsed="collapsedCitations[getMessageKey(message)] = $event"
+            />
+
+            <div v-if="message.wordDownloadUrl" class="word-download-area">
+              <a-button type="link" @click="emit('download-word', message.wordDownloadUrl)"> 下载报告文件 (.docx) </a-button>
+            </div>
+
+            <!-- 文件操作卡片 -->
+            <DownloadFiles
+              v-if="message.downloadFiles?.length"
+              :files="message.downloadFiles"
+              @file-preview="emit('file-preview', $event)"
+              @open-file-preview="emit('open-file-preview', $event)"
+            />
+
+            <div v-show="message.isLoading" class="loading-dots">
+              <span class="dot"></span>
+              <span class="dot"></span>
+              <span class="dot"></span>
+            </div>
+
+            <!-- ask_user 交互提问 UI(分步向导):ask_user 存在但 questions 缺失时不渲染,避免模板访问异常 -->
+            <AskUserCard
+              v-if="message.isPendingApproval && message.pendingApprovalData?.ask_user?.questions?.length"
+              :ref="(el) => setAskCardRef(message, el)"
+              :data="message.pendingApprovalData"
+              @submit="emit('ask-user-submit', $event)"
+              @cancel="emit('ask-user-cancel')"
+            />
+          </div>
+          <!-- AI消息操作栏 -->
+          <MessageActions
+            :message="message"
+            :copied="copiedMessageKey === getMessageKey(message)"
+            :can-regenerate="canRegenerate(index, message)"
+            :show-answer-switcher="showAnswerSwitcher(index)"
+            :can-prev="canPrevAnswer(index)"
+            :can-next="canNextAnswer(index)"
+            :answer-position="answerPositionText(index)"
+            @copy="copyMessageContent(message.content, getMessageKey(message))"
+            @delete="emit('delete-message', message)"
+            @regenerate="emit('regenerate')"
+            @prev-answer="prevAnswer(index)"
+            @next-answer="nextAnswer(index)"
+          />
+        </div>
+
+        <!-- 用户消息 -->
+        <div v-else class="user-message">
+          <div class="message-body">
+            <template v-if="editingKey === getMessageKey(message)">
+              <textarea
+                v-model="editDraft"
+                class="message-edit-textarea"
+                spellcheck="false"
+                @keydown.enter.exact.prevent="submitEdit(index)"
+                @keydown.escape.prevent="cancelEdit"
+              ></textarea>
+              <div class="message-edit-actions">
+                <button class="message-edit-btn confirm" @click="submitEdit(index)">重发</button>
+                <button class="message-edit-btn cancel" @click="cancelEdit">取消</button>
+              </div>
+            </template>
+            <template v-else>
+              <div
+                class="message-text"
+                v-html="renderMarkdownCached(message.content, tableIcons)"
+                @click="handleMessageTextClick($event, index)"
+              ></div>
+              <div v-if="message.attachedFile" class="attached-file-card" @click="emit('file-preview', message.attachedFile)">
+                <div class="file-icon-card" :style="{ backgroundImage: getFileIconVar(message.attachedFile.name) }"></div>
+                <div 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>
+            </template>
+          </div>
+          <!-- 用户消息操作栏 -->
+          <MessageActions
+            :message="message"
+            :copied="copiedMessageKey === getMessageKey(message)"
+            :can-edit="canEdit(index, message)"
+            @copy="copyMessageContent(message.content, getMessageKey(message))"
+            @delete="emit('delete-message', message)"
+            @edit="startEdit(index)"
+          />
+        </div>
+      </div>
+    </div>
+
+    <!-- 回到底部按钮 -->
+    <div v-if="!isAtBottom" class="scroll-to-bottom" @click="scrollToBottom">
+      <div class="scroll-to-bottom-icon"></div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue';
+  import ConversationNav from './ConversationNav.vue';
+  import CitationsCard from './CitationsCard.vue';
+  import AskUserCard from './AskUserCard.vue';
+  import DownloadFiles from './DownloadFiles.vue';
+  import MessageActions from './MessageActions.vue';
+  import ThinkingCard from './ThinkingCard.vue';
+  import {
+    renderMarkdownCached,
+    formatFileSize,
+    tableHtmlToMarkdown,
+    tableHtmlToCsv,
+    getFileIconVar,
+    getCssUrl,
+    getMessageKey,
+    getTableIcons,
+    copyToClipboard,
+    flashCopySuccess,
+    getUserInputText,
+    panelOpenableUrl,
+    webTabTitle,
+  } from '/@/views/ventAI/manageAssistent/lib/index';
+  import type { AgentGroup } from '/@/views/ventAI/manageAssistent/lib/index';
+  import type { AgentDetailData, AskUserQuestion, Message, AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
+  export type { AgentDetailData };
+
+  const tableIcons = getTableIcons();
+
+  const props = defineProps<{
+    messages: Message[];
+    /** 当前任务的历史是否正在拉取:为真且暂无消息时显示加载态,而不是空会话缺省图 */
+    historyLoading?: boolean;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'file-preview', file: AttachedFile): void;
+    (e: 'download-word', url: string): void;
+    (e: 'open-file-preview', file: AttachedFile): void;
+    (e: 'ask-user-submit', answers: string[]): void;
+    (e: 'ask-user-cancel'): void;
+    (e: 'agent-detail-click', data: AgentDetailData): void;
+    (e: 'open-markdown-tab', payload: { title: string; content: string }): void;
+    (e: 'open-web', payload: { url: string; title?: string }): void;
+    (e: 'delete-message', message: Message): void;
+    (e: 'regenerate'): void;
+    (e: 'edit-resend', index: number, newContent: string): void;
+  }>();
+
+  // 用户消息内联编辑状态
+  const editingKey = ref('');
+  const editDraft = ref('');
+
+  // ===== 多回答折叠:重新生成产生的连续多条 AI 消息在视觉上合并为一条,可通过 prev/next 切换查看 =====
+  interface AnswerGroup {
+    indices: number[];
+  }
+
+  // 将「同一次提问的多个回答」划分为若干回答组:一条回答连同它后面**由重新生成追加的**回答算一组
+  // (见 Message.isRegeneratedAnswer)。不能按「连续 AI 消息」推断分组 —— 删掉中间的提问后,上下两条
+  // 互不相干的回答会变成连续消息,那样会被错误归成一组、用切换器藏起一条。
+  const answerGroups = computed<AnswerGroup[]>(() => {
+    const groups: AnswerGroup[] = [];
+    let current: AnswerGroup | null = null;
+    props.messages.forEach((msg, index) => {
+      if (msg.type === 'ai') {
+        // 组内除首条外都必须带「重新生成」标记,否则另起一组
+        if (!current || !msg.isRegeneratedAnswer) {
+          current = { indices: [] };
+          groups.push(current);
+        }
+        current.indices.push(index);
+      } else {
+        current = null;
+      }
+    });
+    return groups;
+  });
+
+  // 每条 AI 消息 → 所属组 key(组首条 index)、组内偏移、组大小
+  const aiGroupMeta = computed(() => {
+    const meta = new Map<number, { groupKey: string; offset: number; groupLen: number }>();
+    for (const group of answerGroups.value) {
+      const groupKey = getMessageKey(props.messages[group.indices[0]]);
+      const groupLen = group.indices.length;
+      group.indices.forEach((idx, offset) => {
+        meta.set(idx, { groupKey, offset, groupLen });
+      });
+    }
+    return meta;
+  });
+
+  // 每个回答组当前选中的回答偏移(默认组内最后一条,即最新生成)
+  const activeAnswerOffset = reactive<Record<string, number>>({});
+
+  const currentAnswerOffset = (groupKey: string, groupLen: number): number => {
+    const val = activeAnswerOffset[groupKey];
+    if (val == null) return groupLen - 1;
+    return Math.min(Math.max(val, 0), groupLen - 1);
+  };
+
+  // 末尾回答组内是否存在正在生成(isLoading)的消息(按回答组判定,与分组规则保持一致)
+  const lastAnswerGroupHasLoading = computed(() => {
+    const msgs = props.messages;
+    if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
+    const lastMeta = aiGroupMeta.value.get(msgs.length - 1);
+    if (!lastMeta) return false;
+    const group = answerGroups.value.find((g) => getMessageKey(msgs[g.indices[0]]) === lastMeta.groupKey);
+    return !!group && group.indices.some((i) => msgs[i].isLoading);
+  });
+
+  // 新回答开始生成(false → true)时,自动切换到最新一条正在生成的消息所属的回答组
+  watch(lastAnswerGroupHasLoading, (loading, wasLoading) => {
+    if (loading && !wasLoading) {
+      const lastMeta = aiGroupMeta.value.get(props.messages.length - 1);
+      if (lastMeta) delete activeAnswerOffset[lastMeta.groupKey];
+    }
+  });
+
+  // 该 AI 消息是否应被折叠隐藏(属于多回答组且非当前选中项)
+  const isHiddenAnswer = (index: number): boolean => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta || meta.groupLen <= 1) return false;
+    return currentAnswerOffset(meta.groupKey, meta.groupLen) !== meta.offset;
+  };
+
+  // 该消息是否为当前选中显示的回答(供父组件标记折叠回答中的条目,如胶囊面板的智能体记录)
+  const isAnswerVisible = (index: number): boolean => !isHiddenAnswer(index);
+
+  // 该 AI 消息是否显示回答切换器(多回答组且为当前选中项)
+  const showAnswerSwitcher = (index: number): boolean => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta || meta.groupLen <= 1) return false;
+    return currentAnswerOffset(meta.groupKey, meta.groupLen) === meta.offset;
+  };
+
+  const answerPositionText = (index: number): string => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta) return '';
+    return `${currentAnswerOffset(meta.groupKey, meta.groupLen) + 1}/${meta.groupLen}`;
+  };
+
+  const canPrevAnswer = (index: number): boolean => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta) return false;
+    return currentAnswerOffset(meta.groupKey, meta.groupLen) > 0;
+  };
+
+  const canNextAnswer = (index: number): boolean => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta) return false;
+    return currentAnswerOffset(meta.groupKey, meta.groupLen) < meta.groupLen - 1;
+  };
+
+  const prevAnswer = (index: number) => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta) return;
+    const cur = currentAnswerOffset(meta.groupKey, meta.groupLen);
+    if (cur > 0) activeAnswerOffset[meta.groupKey] = cur - 1;
+  };
+
+  const nextAnswer = (index: number) => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta) return;
+    const cur = currentAnswerOffset(meta.groupKey, meta.groupLen);
+    if (cur < meta.groupLen - 1) activeAnswerOffset[meta.groupKey] = cur + 1;
+  };
+
+  // 该消息是否属于末尾回答组:重新生成按钮只对最新一轮回答(含回答组内任意一条)显示
+  const isInLastAnswerGroup = (index: number): boolean => {
+    const msgs = props.messages;
+    if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
+    if (msgs[index]?.type !== 'ai') return false;
+    const meta = aiGroupMeta.value.get(index);
+    const lastMeta = aiGroupMeta.value.get(msgs.length - 1);
+    return !!meta && !!lastMeta && meta.groupKey === lastMeta.groupKey;
+  };
+
+  const canRegenerate = (index: number, message: Message): boolean => isInLastAnswerGroup(index) && !message.isLoading && !message.isPendingApproval;
+
+  // 最后一条用户消息的索引(编辑重发只作用于最后一条用户消息,而非整段对话的最后一条)
+  const lastUserMessageIndex = computed(() => {
+    for (let i = props.messages.length - 1; i >= 0; i--) {
+      if (props.messages[i].type === 'user') return i;
+    }
+    return -1;
+  });
+
+  const canEdit = (index: number, message: Message): boolean =>
+    message.type === 'user' && index === lastUserMessageIndex.value && editingKey.value !== getMessageKey(message);
+
+  const startEdit = (index: number) => {
+    const msg = props.messages[index];
+    editingKey.value = getMessageKey(msg);
+    editDraft.value = getUserInputText(msg);
+  };
+
+  const cancelEdit = () => {
+    editingKey.value = '';
+    editDraft.value = '';
+  };
+
+  const submitEdit = (index: number) => {
+    const content = editDraft.value;
+    editingKey.value = '';
+    editDraft.value = '';
+    emit('edit-resend', index, content);
+  };
+
+  // 点击文件卡片:按类型拉取内容并在右侧辅助面板打开预览标签
+  // ask_user 答案状态(按消息对象索引)
+  // 审批卡实例按消息对象索引:SSE 侧直接以消息对象路由到对应卡片(卡片自身也会在 data 变化时重建答案)
+  const askCardRefs = new Map<Message, InstanceType<typeof AskUserCard>>();
+  const setAskCardRef = (msg: Message, el: unknown) => {
+    if (el) askCardRefs.set(msg, el as InstanceType<typeof AskUserCard>);
+    else askCardRefs.delete(msg);
+  };
+
+  const initAskUserAnswers = (msg: Message, questions: AskUserQuestion[]) => {
+    askCardRefs.get(msg)?.init(questions);
+  };
+
+  const messagesRef = ref<HTMLElement>();
+  const conversationNavRef = ref<InstanceType<typeof ConversationNav>>();
+  const collapsedThinking = reactive<Record<string, boolean>>({});
+  const collapsedThinkingContent = reactive<Record<string, boolean>>({});
+  const collapsedCitations = reactive<Record<string, boolean>>({});
+  const isAtBottom = ref(true);
+  const copiedMessageKey = ref('');
+
+  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': {
+        copyToClipboard(tableHtmlToMarkdown(tableHtml)).then(() => flashCopySuccess(target));
+        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': {
+        // 在右侧面板新建标签页展示表格渲染效果
+        emit('open-markdown-tab', { title: '表格预览', content: tableHtmlToMarkdown(tableHtml) });
+        break;
+      }
+    }
+  };
+
+  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 - el.clientHeight;
+    }
+  };
+
+  const forceScrollToBottom = () => {
+    const el = messagesRef.value;
+    if (el) {
+      el.scrollTop = el.scrollHeight - el.clientHeight;
+      // 使用 requestAnimationFrame 确保 DOM 渲染完成后再滚动一次
+      requestAnimationFrame(() => {
+        el.scrollTop = el.scrollHeight - el.clientHeight;
+      });
+    }
+    isAtBottom.value = true;
+  };
+
+  onMounted(() => {
+    messagesRef.value?.addEventListener('click', handleTableAction);
+    messagesRef.value?.addEventListener('scroll', handleMessagesScroll);
+  });
+
+  onBeforeUnmount(() => {
+    messagesRef.value?.removeEventListener('click', handleTableAction);
+    messagesRef.value?.removeEventListener('scroll', handleMessagesScroll);
+  });
+
+  const handleAgentGroupClick = (messageIndex: number, group: AgentGroup) => {
+    emit('agent-detail-click', {
+      messageIndex,
+      agent: group.agent,
+      agentId: group.agentId,
+    });
+  };
+
+  const handleFootnoteClick = (e: Event, msgIndex: number) => {
+    const target = (e.target as HTMLElement).closest('.footnote-ref') as HTMLElement;
+    if (!target) return;
+    const n = target.dataset.n;
+    if (!n) return;
+    const el = document.getElementById(`citation-${msgIndex}-${n}`);
+    if (el) {
+      scrollMessagesTo(el, 'center');
+      el.classList.add('citation-highlight');
+      setTimeout(() => el.classList.remove('citation-highlight'), 1500);
+    }
+  };
+
+  /**
+   * 消息正文的点击分发:http(s) 链接一律改在右侧面板打开(不区分站内站外,判定见 lib/webLink.ts),
+   * 按住 Ctrl/Cmd/Shift 或点非 http 协议链接时保留原行为(新窗口)。
+   * 没点到链接时按原逻辑处理脚注跳转。
+   * 正文是 v-html 渲染出来的,只能靠容器上的事件委托。
+   */
+  const handleMessageTextClick = (e: Event, msgIndex: number) => {
+    const anchor = (e.target as HTMLElement).closest('a[href]') as HTMLAnchorElement | null;
+    if (anchor) {
+      // 组合键留给用户的习惯操作(浏览器里 Ctrl/Cmd+点击就是「新标签页打开」)
+      const ke = e as MouseEvent;
+      if (ke.ctrlKey || ke.metaKey || ke.shiftKey) return;
+      const url = panelOpenableUrl(anchor.getAttribute('href'));
+      if (url) {
+        e.preventDefault();
+        emit('open-web', { url, title: webTabTitle(url, anchor.textContent || '') });
+      }
+      return;
+    }
+    handleFootnoteClick(e, msgIndex);
+  };
+
+  const copyMessageContent = async (content: string, messageKey: string) => {
+    if (!content) return;
+    await copyToClipboard(content);
+    copiedMessageKey.value = messageKey;
+  };
+
+  // 仅滚动消息列表容器:scrollIntoView 会联动滚动 #adaptive-container 等祖先,导致整页上移、底部露出空白
+  const scrollMessagesTo = (el: HTMLElement, block: 'start' | 'center' = 'start') => {
+    const container = messagesRef.value;
+    if (!container) return;
+    // 容器处于自适应缩放环境:getBoundingClientRect 是缩放后的视口坐标,scrollTop 是未缩放的布局坐标,
+    // 直接混算在缩放比非 1(打包部署后的其他分辨率)时定位偏差,需除以纵向缩放比换算回布局坐标
+    //(与左侧对话缩略导航 tooltip 的换算方式一致)
+    const rect = container.getBoundingClientRect();
+    const scale = container.offsetHeight ? rect.height / container.offsetHeight : 1;
+    const safeScale = scale > 0 && Number.isFinite(scale) ? scale : 1;
+    const top = (el.getBoundingClientRect().top - rect.top) / safeScale + container.scrollTop;
+    const offset = block === 'center' ? (container.clientHeight - el.offsetHeight) / 2 : 0;
+    container.scrollTo({ top: Math.max(0, top - offset), behavior: 'smooth' });
+  };
+
+  const scrollToMessage = (index: number, block: 'start' | 'center' = 'start') => {
+    const wrappers = messagesRef.value?.querySelectorAll('.message-wrapper');
+    let el = wrappers?.[index] as HTMLElement | undefined;
+    // 多回答折叠组中被隐藏的消息没有几何信息,回退到其前面最近一条可见消息(通常是该轮的用户提问)
+    if (el && el.offsetParent === null) {
+      el = undefined;
+      for (let i = index - 1; i >= 0; i--) {
+        const w = wrappers?.[i] as HTMLElement | undefined;
+        if (w && w.offsetParent !== null) {
+          el = w;
+          break;
+        }
+      }
+    }
+    if (el) scrollMessagesTo(el, block);
+  };
+
+  const handleMessagesScroll = () => {
+    checkScrollBottom();
+    conversationNavRef.value?.onContainerScroll();
+  };
+
+  defineExpose({ messagesRef, isAtBottom, forceScrollToBottom, scrollToMessage, initAskUserAnswers, isAnswerVisible });
+</script>
+
+<style scoped lang="less">
+  .chat-messages-container {
+    flex: 1;
+    position: relative;
+    display: flex;
+    flex-direction: row;
+    overflow: hidden;
+  }
+
+  .chat-messages {
+    flex: 1;
+    overflow-y: auto;
+    padding: 20px;
+    position: relative;
+
+    .welcome-empty {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      .welcome-img {
+        max-width: 300px;
+        max-height: 300px;
+        object-fit: contain;
+      }
+    }
+
+    /* 历史加载中的占位:与缺省图同样居中,避免两态之间出现位置跳动。
+       antd 3.x 的点阵是固定像素(large 为 32px 框 + 14px 点),不随 font-size 缩放,
+       因此整体放大用 transform,既不动 antd 内部类名,比例也不会走样。 */
+    .history-loading {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%) scale(2.5);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+
+    .message-wrapper {
+      // margin-bottom: 20px;
+
+      &.answer-hidden {
+        display: none;
+      }
+
+      &.nav-located-highlight {
+        background: rgba(10, 132, 255, 0.08);
+        border-radius: 6px;
+      }
+
+      .ai-message {
+        display: flex;
+        flex-wrap: wrap;
+        .message-agent {
+          display: flex;
+          margin-bottom: 8px;
+          opacity: 0;
+        }
+
+        .message-body {
+          padding: 12px 12px 4px 12px;
+          border-radius: 6px;
+          width: 100%;
+          min-width: 0;
+          overflow: hidden;
+
+          .message-text {
+            font-size: 14px;
+            font-weight: 400;
+            line-height: 1.6;
+            color: #e0e6ed;
+            word-break: break-word;
+            overflow-wrap: break-word;
+
+            :deep(.session-ref-icon) {
+              width: 14px;
+              height: 14px;
+              vertical-align: -2px;
+            }
+
+            :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;
+              max-width: 100%;
+              overflow-x: auto;
+
+              .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;
+          min-height: 16px;
+
+          .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;
+          }
+        }
+
+        .citations-card {
+          overflow: hidden;
+
+          .citations-header {
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            padding: 8px 14px;
+            border-bottom: 1px solid rgba(63, 80, 106, 0.3);
+            cursor: pointer;
+            user-select: none;
+            transition: background 0.2s;
+
+            &:hover {
+              background: rgba(10, 132, 255, 0.08);
+            }
+
+            &.citations-header-simple {
+              cursor: default;
+
+              &:hover {
+                background: transparent;
+              }
+            }
+
+            .citations-header-left {
+              display: flex;
+              align-items: center;
+              gap: 8px;
+            }
+
+            .citations-title {
+              font-size: 13px;
+              font-weight: 500;
+              color: #8b949e;
+            }
+
+            .citations-count {
+              font-size: 12px;
+              color: #8b949e;
+              background: rgba(10, 132, 255, 0.1);
+              padding: 1px 6px;
+              border-radius: 10px;
+            }
+
+            .thinking-arrow {
+              display: flex;
+              align-items: center;
+              transition: transform 0.2s;
+
+              &.expanded {
+                transform: rotate(180deg);
+              }
+            }
+          }
+
+          .citations-body {
+            padding: 4px 14px;
+
+            .citation-item {
+              padding: 6px 0;
+              border-bottom: 1px solid rgba(63, 80, 106, 0.15);
+
+              &:last-child {
+                border-bottom: none;
+              }
+
+              &.citation-highlight {
+                background: rgba(10, 132, 255, 0.08);
+                border-radius: 4px;
+                padding: 6px 8px;
+                margin: 0 -8px;
+              }
+
+              .citation-item-row {
+                display: flex;
+                align-items: center;
+                gap: 8px;
+                line-height: 1.5;
+
+                .citation-badge {
+                  font-size: 12px;
+                  color: #79c0ff;
+                  font-family: monospace;
+                  background: rgba(10, 132, 255, 0.1);
+                  padding: 1px 5px;
+                  border-radius: 3px;
+                  white-space: nowrap;
+                  flex-shrink: 0;
+                }
+
+                .citation-badge-knowledge_base {
+                  color: #3fb950;
+                  background: rgba(46, 160, 67, 0.1);
+                }
+
+                .citation-badge-web {
+                  color: #d29922;
+                  background: rgba(210, 153, 34, 0.1);
+                }
+
+                .citation-badge-calc {
+                  color: #a371f7;
+                  background: rgba(163, 113, 247, 0.1);
+                }
+
+                .citation-title-text {
+                  font-size: 13px;
+                  color: #b0b8c4;
+                  overflow: hidden;
+                  text-overflow: ellipsis;
+                  white-space: nowrap;
+                  min-width: 0;
+                }
+
+                .citation-link {
+                  font-size: 12px;
+                  color: #0a84ff;
+                  text-decoration: none;
+                  white-space: nowrap;
+                  flex-shrink: 0;
+                }
+              }
+
+              .citation-item-attrs {
+                display: flex;
+                align-items: center;
+                flex-wrap: wrap;
+                gap: 4px 10px;
+                padding-left: 0;
+                margin-top: 2px;
+
+                .citation-attr {
+                  font-size: 12px;
+                  color: #8b949e;
+                  white-space: nowrap;
+                }
+
+                .citation-low-score {
+                  color: #d29922;
+                }
+              }
+
+              .citation-snippet {
+                margin-top: 4px;
+                font-size: 12px;
+                color: #8b949e;
+                line-height: 1.5;
+                overflow: hidden;
+                text-overflow: ellipsis;
+                white-space: nowrap;
+              }
+            }
+          }
+        }
+
+        .download-file-cards {
+          display: flex;
+          flex-direction: column;
+          gap: 8px;
+          margin-top: 12px;
+
+          .download-file-card {
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            padding: 12px 16px;
+            background: linear-gradient(135deg, rgba(0, 47, 74, 0.8) 0%, rgba(0, 60, 90, 0.6) 100%);
+            border: 1px solid rgba(0, 84, 130, 0.6);
+            border-radius: 10px;
+            cursor: pointer;
+            transition: all 0.2s ease;
+
+            &:hover {
+              border-color: rgba(10, 132, 255, 0.4);
+              box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+            }
+
+            .file-card-info {
+              display: flex;
+              align-items: center;
+              gap: 12px;
+              min-width: 0;
+              flex: 1;
+
+              .file-card-icon {
+                width: 36px;
+                height: 36px;
+                background-image: var(--img-chat-download-icon);
+                background-repeat: no-repeat;
+                background-size: 100% 100%;
+                flex-shrink: 0;
+                opacity: 0.9;
+              }
+
+              .file-card-text {
+                display: flex;
+                flex-direction: column;
+                gap: 2px;
+                min-width: 0;
+
+                .file-card-name {
+                  font-size: 13px;
+                  font-weight: 500;
+                  color: #e0e6ed;
+                  overflow: hidden;
+                  text-overflow: ellipsis;
+                  white-space: nowrap;
+                }
+
+                .file-card-hint {
+                  font-size: 11px;
+                  color: #6e7681;
+                }
+              }
+            }
+
+            .file-card-actions {
+              display: flex;
+              gap: 8px;
+              flex-shrink: 0;
+
+              .file-action-btn {
+                display: inline-flex;
+                align-items: center;
+                gap: 5px;
+                padding: 6px 14px;
+                border-radius: 7px;
+                font-size: 12px;
+                font-weight: 500;
+                cursor: pointer;
+                border: none;
+                transition: all 0.2s ease;
+                white-space: nowrap;
+
+                &.download-btn {
+                  background: rgba(255, 255, 255, 0.08);
+                  color: #c9d1d9;
+                  border: 1px solid rgba(63, 80, 106, 0.5);
+
+                  &:hover {
+                    background: rgba(255, 255, 255, 0.14);
+                    color: #e0e6ed;
+                    border-color: rgba(63, 80, 106, 0.8);
+                  }
+                }
+
+                &.edit-btn {
+                  background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
+                  color: #fff;
+                  border: 1px solid rgba(10, 132, 255, 0.5);
+                  box-shadow: 0 2px 6px rgba(10, 132, 255, 0.2);
+
+                  &:hover {
+                    background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
+                    box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+
+      .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;
+        }
+
+        .message-body {
+          background: rgba(10, 132, 255, 0.15);
+          padding: 12px;
+          border-radius: 6px 0 6px 6px;
+          max-width: 700px;
+          min-width: 0;
+          overflow: hidden;
+
+          .message-text {
+            font-size: 14px;
+            font-weight: 400;
+            line-height: 1.6;
+            color: #e0e6ed;
+            word-break: break-word;
+            overflow-wrap: break-word;
+
+            :deep(.session-ref-icon) {
+              width: 14px;
+              height: 14px;
+              vertical-align: -2px;
+            }
+
+            :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;
+          }
+
+          .answer-switcher {
+            display: flex;
+            align-items: center;
+            gap: 2px;
+            padding: 0 4px;
+            border-radius: 4px;
+            background: rgba(255, 255, 255, 0.04);
+
+            .answer-nav-btn {
+              width: 22px;
+              height: 22px;
+
+              &.is-disabled {
+                opacity: 0.35;
+                cursor: not-allowed;
+
+                &:hover {
+                  background: transparent;
+                  color: #8b949e;
+                }
+              }
+            }
+
+            .answer-switcher-text {
+              font-size: 12px;
+              color: #8b949e;
+              min-width: 32px;
+              text-align: center;
+              user-select: none;
+            }
+          }
+        }
+
+        &:hover .message-actions {
+          opacity: 1;
+        }
+      }
+
+      .ai-message .message-actions {
+        padding: 0 0 0 10px;
+      }
+
+      .user-message .message-actions {
+        padding: 4px 0;
+        justify-content: flex-end;
+      }
+    }
+  }
+
+  .message-edit-textarea {
+    width: 100%;
+    min-width: 500px;
+    min-height: 80px;
+    padding: 8px 12px;
+    font-size: 14px;
+    line-height: 1.6;
+    color: #e0e6ed;
+    background: rgba(0, 0, 0, 0.2);
+    border: 1px solid rgba(10, 132, 255, 0.5);
+    border-radius: 6px;
+    outline: none;
+    resize: vertical;
+    font-family: inherit;
+
+    &:focus {
+      border-color: #0a84ff;
+      background: rgba(0, 0, 0, 0.3);
+    }
+  }
+
+  .message-edit-actions {
+    display: flex;
+    gap: 8px;
+    margin-top: 8px;
+    justify-content: flex-end;
+
+    .message-edit-btn {
+      padding: 3px 14px;
+      font-size: 12px;
+      border-radius: 4px;
+      cursor: pointer;
+      border: 1px solid transparent;
+      transition: all 0.2s;
+
+      &.confirm {
+        color: #fff;
+        background: linear-gradient(135deg, #0a84ff 0%, #06c 100%);
+        border-color: rgba(10, 132, 255, 0.5);
+
+        &:hover {
+          background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
+        }
+      }
+
+      &.cancel {
+        color: #8b949e;
+        background: rgba(255, 255, 255, 0.06);
+        border-color: rgba(63, 80, 106, 0.4);
+
+        &:hover {
+          color: #c9d1d9;
+          background: rgba(255, 255, 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>

+ 211 - 0
src/views/ventAI/manageAssistent/components/chat/CitationsCard.vue

@@ -0,0 +1,211 @@
+<template>
+  <!-- 数据来源卡片:引用条目可折叠,脚注点击靠父组件的 citation-{消息下标}-{序号} 锚点定位 -->
+  <div class="citations-card">
+    <div v-if="message.citations?.model_only" class="citations-header citations-header-simple">
+      <span class="citations-title">本回答依据模型知识生成,仅供参考</span>
+    </div>
+    <template v-else-if="message.citations?.items?.length">
+      <div class="citations-header" @click="emit('update:collapsed', !collapsed)">
+        <div class="citations-header-left">
+          <span class="citations-title">数据来源</span>
+          <span class="citations-count">{{ message.citations.items.length }}</span>
+        </div>
+        <div :class="['thinking-arrow', { expanded: !collapsed }]">
+          <img :src="getCssUrl('--img-chat-arrow-icon')" width="10" height="10" />
+        </div>
+      </div>
+      <div v-show="!collapsed" class="citations-body">
+        <div v-for="item in message.citations.items" :key="item.id" :id="`citation-${index}-${item.id}`" class="citation-item">
+          <div class="citation-item-row">
+            <span :class="['citation-badge', `citation-badge-${item.type}`]">{{ item.type_cn }}</span>
+            <span class="citation-title-text">{{ item.title }}</span>
+            <span v-if="item.search_url" class="citation-link">知识库检索</span>
+            <span v-if="item.url" class="citation-link">链接</span>
+          </div>
+          <div class="citation-item-attrs">
+            <span v-if="item.source_system" class="citation-attr">{{ item.source_system }}</span>
+            <span v-if="item.fetched_at" class="citation-attr">{{ item.fetched_at }}</span>
+          </div>
+          <div v-if="item.snippet" class="citation-snippet" :title="item.snippet">{{ item.snippet }}</div>
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import type { Message } from '/@/views/ventAI/manageAssistent/types/index';
+  import { getCssUrl } from '/@/views/ventAI/manageAssistent/lib/index';
+
+  /**
+   * 数据来源(citations)卡片。折叠态由父组件按消息下标持有,这里受控显示。
+   * index 需要透传:条目上的 citation-{index}-{id} 锚点供正文脚注 [^n] 定位使用。
+   */
+  defineProps<{
+    message: Message;
+    index: number;
+    collapsed: boolean;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'update:collapsed', value: boolean): void;
+  }>();
+</script>
+
+<style scoped lang="less">
+  .citations-card {
+    overflow: hidden;
+
+    .citations-header {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 8px 14px;
+      border-bottom: 1px solid rgba(63, 80, 106, 0.3);
+      cursor: pointer;
+      user-select: none;
+      transition: background 0.2s;
+
+      &:hover {
+        background: rgba(10, 132, 255, 0.08);
+      }
+
+      &.citations-header-simple {
+        cursor: default;
+
+        &:hover {
+          background: transparent;
+        }
+      }
+
+      .citations-header-left {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+      }
+
+      .citations-title {
+        font-size: 13px;
+        font-weight: 500;
+        color: #8b949e;
+      }
+
+      .citations-count {
+        font-size: 12px;
+        color: #8b949e;
+        background: rgba(10, 132, 255, 0.1);
+        padding: 1px 6px;
+        border-radius: 10px;
+      }
+
+      .thinking-arrow {
+        display: flex;
+        align-items: center;
+        transition: transform 0.2s;
+
+        &.expanded {
+          transform: rotate(180deg);
+        }
+      }
+    }
+
+    .citations-body {
+      padding: 4px 14px;
+
+      .citation-item {
+        padding: 6px 0;
+        border-bottom: 1px solid rgba(63, 80, 106, 0.15);
+
+        &:last-child {
+          border-bottom: none;
+        }
+
+        &.citation-highlight {
+          background: rgba(10, 132, 255, 0.08);
+          border-radius: 4px;
+          padding: 6px 8px;
+          margin: 0 -8px;
+        }
+
+        .citation-item-row {
+          display: flex;
+          align-items: center;
+          gap: 8px;
+          line-height: 1.5;
+
+          .citation-badge {
+            font-size: 12px;
+            color: #79c0ff;
+            font-family: monospace;
+            background: rgba(10, 132, 255, 0.1);
+            padding: 1px 5px;
+            border-radius: 3px;
+            white-space: nowrap;
+            flex-shrink: 0;
+          }
+
+          .citation-badge-knowledge_base {
+            color: #3fb950;
+            background: rgba(46, 160, 67, 0.1);
+          }
+
+          .citation-badge-web {
+            color: #d29922;
+            background: rgba(210, 153, 34, 0.1);
+          }
+
+          .citation-badge-calc {
+            color: #a371f7;
+            background: rgba(163, 113, 247, 0.1);
+          }
+
+          .citation-title-text {
+            font-size: 13px;
+            color: #b0b8c4;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+            min-width: 0;
+          }
+
+          .citation-link {
+            font-size: 12px;
+            color: #0a84ff;
+            text-decoration: none;
+            white-space: nowrap;
+            flex-shrink: 0;
+          }
+        }
+
+        .citation-item-attrs {
+          display: flex;
+          align-items: center;
+          flex-wrap: wrap;
+          gap: 4px 10px;
+          padding-left: 0;
+          margin-top: 2px;
+
+          .citation-attr {
+            font-size: 12px;
+            color: #8b949e;
+            white-space: nowrap;
+          }
+
+          .citation-low-score {
+            color: #d29922;
+          }
+        }
+
+        .citation-snippet {
+          margin-top: 4px;
+          font-size: 12px;
+          color: #8b949e;
+          line-height: 1.5;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+      }
+    }
+  }
+</style>

+ 481 - 0
src/views/ventAI/manageAssistent/components/chat/ConversationNav.vue

@@ -0,0 +1,481 @@
+<template>
+  <!-- 左侧对话缩略导航:每条横线一轮问答,悬浮显示问/答摘要,滚动时高亮当前阅读的一组 -->
+  <div
+    v-if="qaPairs.length > 0"
+    class="conversation-nav"
+    ref="navRef"
+    @mouseenter="onNavMove"
+    @mousemove="onNavMove"
+    @mouseleave="onNavLeave"
+    @scroll="onNavScroll"
+  >
+    <div
+      v-for="(pair, pIdx) in qaPairs"
+      :key="pIdx"
+      :class="['nav-line', { 'nav-line-hot': nearestLineIndex === pIdx, active: activeMessage && activeMessage === messages[pair.startIndex] }]"
+      :style="{ transform: `scaleX(${navLineScale(pIdx)})` }"
+      @click="scrollToPair(pair)"
+    ></div>
+  </div>
+  <Transition name="tooltip-fade">
+    <div v-if="hoveredPair" ref="tooltipRef" class="conversation-tooltip" :style="{ top: `${tooltipTop}px` }">
+      <span class="tooltip-arrow" :style="{ top: `${tooltipArrowTop}px` }"></span>
+      <div v-if="hoveredPair.question" class="tooltip-row">
+        <span class="tooltip-label tooltip-label-q">问</span>
+        <span class="tooltip-text">{{ hoveredPair.question }}</span>
+      </div>
+      <div v-if="hoveredPair.answer" class="tooltip-row">
+        <span class="tooltip-label tooltip-label-a">答</span>
+        <span class="tooltip-text">{{ hoveredPair.answer }}</span>
+      </div>
+    </div>
+  </Transition>
+</template>
+
+<script setup lang="ts">
+  import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue';
+  import type { Message } from '/@/views/ventAI/manageAssistent/types/index';
+
+  /**
+   * 左侧对话缩略导航。
+   *
+   * 消息容器由父组件持有(滚动、缩放换算等逻辑在那边),这里只通过 getContainer / scrollToMessage
+   * 两个回调拿到容器能力,导航自身的状态(悬浮、提示位置、滚动跟随)全部内聚在本组件。
+   */
+  const props = defineProps<{
+    messages: Message[];
+    isAtBottom: boolean;
+    /** 取消息滚动容器(挂载时机原因由父组件提供,不要在本组件内查询 DOM) */
+    getContainer: () => HTMLElement | null | undefined;
+    /** 按消息下标滚动容器(复用父组件的隐藏消息回退逻辑) */
+    scrollToMessage: (index: number, block?: 'start' | 'center') => void;
+  }>();
+
+  interface QaPair {
+    startIndex: number;
+    question: string;
+    answer: string;
+  }
+
+  const getContainer = () => props.getContainer?.() ?? null;
+
+  const toPlainText = (md: string): string =>
+    md
+      .replace(/```[\s\S]*?```/g, ' [代码] ')
+      .replace(/`[^`]*`/g, ' ')
+      .replace(/<[^>]+>/g, '')
+      .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
+      .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
+      .replace(/^#{1,6}\s*/gm, '')
+      .replace(/[*_~>|]/g, '')
+      .replace(/\s+/g, ' ')
+      .trim();
+
+  // toPlainText 是纯函数,但 qaPairs 在流式期间每个 token 都会重算:历史消息的正文一字未改,
+  // 却要跟着重跑上面整串正则(长会话下等于「每个 token × 全部历史正文」)。按原文记忆化,命中直接返回。
+  // 流式中的消息由调用方传 cacheable=false 排除——它每个 token 都变,写进缓存只会在 FIFO 淘汰下
+  // 把历史消息的条目一条条挤出去,反而让后续 token 全部落空。
+  const PLAIN_TEXT_CACHE_MAX = 200;
+  const plainTextCache = new Map<string, string>();
+
+  const toPlainTextCached = (md: string, cacheable = true): string => {
+    if (!cacheable) return toPlainText(md);
+    const cached = plainTextCache.get(md);
+    if (cached !== undefined) return cached;
+    const plain = toPlainText(md);
+    if (plainTextCache.size >= PLAIN_TEXT_CACHE_MAX) {
+      const oldestKey = plainTextCache.keys().next().value as string | undefined;
+      if (oldestKey !== undefined) plainTextCache.delete(oldestKey);
+    }
+    plainTextCache.set(md, plain);
+    return plain;
+  };
+
+  const truncateText = (text: string, max = 50): string => (text.length > max ? `${text.slice(0, max)}…` : text);
+
+  // 一轮问答 = 一条用户消息 + 其后的连续 AI 消息
+  const qaPairs = computed<QaPair[]>(() => {
+    const pairs: QaPair[] = [];
+    let current: QaPair | null = null;
+    props.messages.forEach((msg, index) => {
+      if (msg.type === 'user') {
+        if (current) pairs.push(current);
+        current = { startIndex: index, question: truncateText(toPlainTextCached(msg.content)), answer: '' };
+      } else {
+        if (!current) current = { startIndex: index, question: '', answer: '' };
+        const part = msg.isLoading && !msg.content ? '正在生成...' : toPlainTextCached(msg.content, !msg.isLoading);
+        if (part) current.answer = truncateText(current.answer ? `${current.answer} ${part}` : part);
+      }
+    });
+    if (current) pairs.push(current);
+    return pairs;
+  });
+
+  const navRef = ref<HTMLElement>();
+  const tooltipRef = ref<HTMLElement>();
+  const hoveredPair = ref<QaPair | null>(null);
+  const tooltipTop = ref(0);
+  const tooltipArrowTop = ref(0);
+  const activeMessage = ref<Message | null>(null);
+
+  // 鼠标在导航列内的 Y 坐标(导航列内容坐标系,随滚动补偿);null 表示未悬浮
+  const navMouseY = ref<number | null>(null);
+  // 各横线中心 Y(导航列内容坐标系),消息变化/列滚动后重建
+  const lineCenters = ref<number[]>([]);
+
+  const updateLineCenters = () => {
+    const nav = navRef.value;
+    if (!nav) {
+      lineCenters.value = [];
+      return;
+    }
+    const centers: number[] = [];
+    nav.querySelectorAll<HTMLElement>('.nav-line').forEach((line) => centers.push(line.offsetTop + line.offsetHeight / 2));
+    lineCenters.value = centers;
+  };
+
+  // 宽度随与鼠标的距离连续衰减(高斯),形成平滑的"长-中-短"波形;
+  // 用 scaleX 缩放(合成器动画)替代逐帧改 width(逐帧重排),滚动/移动时不再卡顿
+  const navLineScale = (pIdx: number): number => {
+    const mouseY = navMouseY.value;
+    if (mouseY === null) return 1;
+    const dist = Math.abs((lineCenters.value[pIdx] ?? 0) - mouseY);
+    return 1 + 0.8 * Math.exp(-(dist * dist) / (2 * 14 * 14));
+  };
+
+  const nearestLineIndex = computed(() => {
+    const mouseY = navMouseY.value;
+    if (mouseY === null) return -1;
+    let best = -1;
+    let bestDist = Infinity;
+    lineCenters.value.forEach((center, i) => {
+      const dist = Math.abs(center - mouseY);
+      if (dist < bestDist) {
+        bestDist = dist;
+        best = i;
+      }
+    });
+    return best;
+  });
+
+  let lastTooltipIndex = -1;
+
+  const applyTooltip = async (pIdx: number) => {
+    const pair = qaPairs.value[pIdx];
+    if (!pair) {
+      hoveredPair.value = null;
+      return;
+    }
+    hoveredPair.value = pair;
+    await nextTick();
+    const tooltip = tooltipRef.value;
+    const nav = navRef.value;
+    const container = nav?.parentElement as HTMLElement | null;
+    if (!tooltip || !nav || !container) return;
+    const lineCenterY = nav.offsetTop + (lineCenters.value[pIdx] ?? 0) - nav.scrollTop;
+    const tooltipH = tooltip.offsetHeight;
+    const maxTop = Math.max(8, container.clientHeight - tooltipH - 8);
+    tooltipTop.value = Math.max(8, Math.min(maxTop, lineCenterY - tooltipH / 2));
+    tooltipArrowTop.value = Math.max(6, Math.min(tooltipH - 16, lineCenterY - tooltipTop.value - 5));
+  };
+
+  let navMoveRaf = 0;
+  let lastNavMoveEvent: MouseEvent | null = null;
+
+  const applyNavMove = (e: MouseEvent) => {
+    const nav = navRef.value;
+    if (!nav) return;
+    // 横线数量变化(新增问答/首次悬浮)时重建坐标
+    if (lineCenters.value.length !== qaPairs.value.length) updateLineCenters();
+    // e.clientY / getBoundingClientRect 是容器 scale 缩放后的视口坐标,需换算回布局坐标
+    // (÷ 容器竖向缩放)后再与 lineCenters / scrollTop 比较,否则不同分辨率下定位错位
+    const scale = nav.offsetHeight ? nav.getBoundingClientRect().height / nav.offsetHeight : 1;
+    navMouseY.value = (e.clientY - nav.getBoundingClientRect().top) / scale + nav.scrollTop;
+    const nearest = nearestLineIndex.value;
+    if (nearest >= 0 && nearest !== lastTooltipIndex) {
+      lastTooltipIndex = nearest;
+      applyTooltip(nearest);
+    }
+  };
+
+  const onNavMove = (e: MouseEvent) => {
+    // 鼠标移动高频触发,合并到每帧处理一次,避免频繁重排造成卡顿
+    lastNavMoveEvent = e;
+    if (navMoveRaf) return;
+    navMoveRaf = requestAnimationFrame(() => {
+      navMoveRaf = 0;
+      if (lastNavMoveEvent) applyNavMove(lastNavMoveEvent);
+    });
+  };
+
+  const onNavLeave = () => {
+    if (navMoveRaf) {
+      cancelAnimationFrame(navMoveRaf);
+      navMoveRaf = 0;
+      lastNavMoveEvent = null;
+    }
+    navMouseY.value = null;
+    hoveredPair.value = null;
+    lastTooltipIndex = -1;
+  };
+
+  let navScrollRaf = 0;
+  const onNavScroll = () => {
+    // 导航列自身滚动时同样按帧合并,保持滚动过程顺滑
+    if (navScrollRaf) return;
+    navScrollRaf = requestAnimationFrame(() => {
+      navScrollRaf = 0;
+      updateLineCenters();
+      if (lastTooltipIndex >= 0) applyTooltip(lastTooltipIndex);
+    });
+  };
+
+  const scrollToPair = (pair: QaPair) => {
+    activeMessage.value = props.messages[pair.startIndex];
+    props.scrollToMessage(pair.startIndex, 'start');
+    const pIdx = qaPairs.value.indexOf(pair);
+    if (pIdx >= 0) ensureNavLineVisible(pIdx);
+    const wrappers = getWrappers();
+    const el = wrappers?.[pair.startIndex] as HTMLElement | undefined;
+    el?.classList.add('nav-located-highlight');
+    setTimeout(() => el?.classList.remove('nav-located-highlight'), 1500);
+  };
+
+  // 选中线保持在导航列可视区内(长对话时导航列自身可滚动):
+  // 仅在本列内部调整 scrollTop,不调用 scrollIntoView 以免联动祖先容器
+  const ensureNavLineVisible = (pIdx: number) => {
+    const nav = navRef.value;
+    const line = nav?.querySelectorAll<HTMLElement>('.nav-line')[pIdx];
+    if (!nav || !line) return;
+    const margin = 12;
+    const top = line.offsetTop;
+    const bottom = top + line.offsetHeight;
+    if (top < nav.scrollTop + margin) {
+      nav.scrollTop = Math.max(0, top - margin);
+    } else if (bottom > nav.scrollTop + nav.clientHeight - margin) {
+      nav.scrollTop = bottom - nav.clientHeight + margin;
+    }
+  };
+
+  // ===== 滚动跟随:消息滚动时,导航选中项实时跟随当前正在阅读的一组问答 =====
+  let spyRaf = 0;
+  // [审计-第四轮 R4-5] wrappers 列表缓存。
+  // 原行为:updateActivePairByScroll 在滚动 rAF 回调里每帧对消息容器 querySelectorAll('.message-wrapper')
+  //         全量查询(消息 >100 条时每帧一次 O(n) DOM 查询)。
+  // 现行为:查询结果缓存为数组,在"消息对数量**或**消息条数"变化时失效重建(watch 源见下方 297-30x 行);
+  //         另加 O(1) 存活校验(容器是否仍持有首个 wrapper),防 DOM 整批替换(如切换任务)后用到游离节点。
+  // 为什么:滚动期每帧省一次 O(n) 查询,rAF 合并不变;失效源必须同时覆盖两类 DOM 变化——
+  //         qaPairs.length 只随 **user** 消息数变化,而删除 AI 消息(ChatMessages 的 delete 按钮)不改它,
+  //         只盯 qaPairs.length 会漏失效、用到陈旧 wrapper 列表(外观级陈旧,非崩溃)。
+  let cachedWrappers: Element[] | null = null;
+  const getWrappers = (): Element[] => {
+    const container = getContainer();
+    if (!container) return [];
+    if (cachedWrappers && cachedWrappers.length > 0 && !container.contains(cachedWrappers[0])) {
+      cachedWrappers = null;
+    }
+    if (!cachedWrappers) {
+      cachedWrappers = Array.from(container.querySelectorAll('.message-wrapper'));
+    }
+    return cachedWrappers;
+  };
+  const updateActivePairByScroll = () => {
+    const pairs = qaPairs.value;
+    const container = getContainer();
+    if (!pairs.length || !container) {
+      activeMessage.value = null;
+      return;
+    }
+    const wrappers = getWrappers();
+    // 以视口上部 1/3 处为判定线:起始位置越过判定线的一组即为当前阅读组;触底时强制选中最后一组
+    const mark = container.scrollTop + container.clientHeight * 0.35;
+    let current = 0;
+    for (let p = 0; p < pairs.length; p++) {
+      const el = wrappers[pairs[p].startIndex] as HTMLElement | undefined;
+      if (!el) continue;
+      if (el.offsetTop <= mark) current = p;
+      else break;
+    }
+    if (props.isAtBottom) current = pairs.length - 1;
+    const target = props.messages[pairs[current].startIndex] ?? null;
+    if (activeMessage.value !== target) {
+      activeMessage.value = target;
+      // 选中项变化时把对应横线滚入导航列可视区,跟随才真正可见
+      ensureNavLineVisible(current);
+    }
+  };
+
+  // 滚动事件高频触发,用 rAF 合并到每帧最多计算一次
+  const scheduleSpy = () => {
+    if (spyRaf) return;
+    spyRaf = requestAnimationFrame(() => {
+      spyRaf = 0;
+      updateActivePairByScroll();
+    });
+  };
+
+  // 消息增减(新问答、加载历史、切换任务、删除单条消息)后同步一次选中状态
+  // [审计-第四轮 R4-5] 缓存失效源=「消息对数量 + 消息条数」:.message-wrapper DOM 随两者增删变化。
+  // 只盯 qaPairs.length 会漏掉"删除 AI 消息"——user 消息数不变 → qaPairs.length 不变,但 DOM 少一个 wrapper。
+  watch(
+    [() => qaPairs.value.length, () => props.messages.length],
+    () => {
+      cachedWrappers = null;
+      nextTick(updateActivePairByScroll);
+    },
+    { immediate: true }
+  );
+
+  onBeforeUnmount(() => {
+    if (spyRaf) {
+      cancelAnimationFrame(spyRaf);
+      spyRaf = 0;
+    }
+    if (navMoveRaf) {
+      cancelAnimationFrame(navMoveRaf);
+      navMoveRaf = 0;
+    }
+    if (navScrollRaf) {
+      cancelAnimationFrame(navScrollRaf);
+      navScrollRaf = 0;
+    }
+  });
+
+  // 父组件在消息容器滚动时调用(容器由父组件监听,挂载时机原因本组件不去绑定)
+  const onContainerScroll = () => {
+    scheduleSpy();
+  };
+
+  defineExpose({ onContainerScroll });
+</script>
+
+<style scoped lang="less">
+  .conversation-nav {
+    position: relative;
+    width: 28px;
+    flex-shrink: 0;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    overflow-y: auto;
+    padding: 6px 0;
+    scrollbar-width: none;
+
+    &::-webkit-scrollbar {
+      display: none;
+    }
+
+    .nav-line {
+      width: 10px;
+      height: 3px;
+      border-radius: 2px;
+      background: rgba(139, 148, 158, 0.35);
+      margin: 5px 0;
+      flex-shrink: 0;
+      cursor: pointer;
+      transform-origin: center;
+      transition:
+        transform 0.18s cubic-bezier(0.33, 1, 0.68, 1),
+        background 0.2s,
+        box-shadow 0.2s;
+
+      &:first-child {
+        margin-top: auto;
+      }
+
+      &:last-child {
+        margin-bottom: auto;
+      }
+
+      &.nav-line-hot {
+        background: linear-gradient(90deg, rgba(10, 132, 255, 0.6) 0%, #0a84ff 100%);
+      }
+
+      &.active {
+        background: linear-gradient(90deg, #2f9bff 0%, #0a84ff 100%);
+        box-shadow: 0 0 6px rgba(10, 132, 255, 0.45);
+      }
+    }
+  }
+
+  .conversation-tooltip {
+    position: absolute;
+    left: 36px;
+    width: 260px;
+    background: linear-gradient(135deg, #0a1628 0%, #0d2137 50%, #0a1e35 100%);
+    border: 1px solid #1a4a6f;
+    border-radius: 10px;
+    box-shadow:
+      0 8px 24px rgba(0, 0, 0, 0.5),
+      0 0 14px rgba(10, 132, 255, 0.08);
+    padding: 10px 12px;
+    z-index: 20;
+    pointer-events: none;
+
+    .tooltip-arrow {
+      position: absolute;
+      left: -6px;
+      width: 10px;
+      height: 10px;
+      transform: rotate(45deg);
+      background: #0d2137;
+      border-left: 1px solid #1a4a6f;
+      border-bottom: 1px solid #1a4a6f;
+      border-top-left-radius: 2px;
+    }
+
+    .tooltip-row {
+      display: flex;
+      align-items: flex-start;
+      gap: 8px;
+      font-size: 12px;
+      line-height: 1.6;
+
+      & + .tooltip-row {
+        margin-top: 8px;
+        padding-top: 8px;
+        border-top: 1px solid rgba(63, 80, 106, 0.35);
+      }
+
+      .tooltip-label {
+        flex-shrink: 0;
+        width: 16px;
+        height: 16px;
+        line-height: 16px;
+        margin-top: 2px;
+        text-align: center;
+        border-radius: 4px;
+        font-size: 11px;
+        font-weight: 500;
+
+        &-q {
+          color: #79c0ff;
+          background: rgba(10, 132, 255, 0.15);
+        }
+
+        &-a {
+          color: #a9b9c9;
+          background: rgba(139, 148, 158, 0.15);
+        }
+      }
+
+      .tooltip-text {
+        color: #d7dee6;
+        word-break: break-word;
+      }
+    }
+  }
+
+  .tooltip-fade-enter-active,
+  .tooltip-fade-leave-active {
+    transition:
+      opacity 0.18s ease,
+      transform 0.18s ease;
+  }
+
+  .tooltip-fade-enter-from,
+  .tooltip-fade-leave-to {
+    opacity: 0;
+    transform: translateX(-4px);
+  }
+</style>

+ 212 - 0
src/views/ventAI/manageAssistent/components/chat/DownloadFiles.vue

@@ -0,0 +1,212 @@
+<template>
+  <!-- AI 回答里的下载文件卡片:点击预览,支持下载与(文本类)编辑 -->
+  <div class="download-file-cards">
+    <div v-for="(file, fIdx) in files" :key="fIdx" class="download-file-card" @click="handlePreviewFile(file)">
+      <div class="file-card-info">
+        <div class="file-card-icon" :style="{ backgroundImage: getFileIconVar(file.filename) }"></div>
+        <div class="file-card-text">
+          <span class="file-card-name">{{ file.filename }}</span>
+          <span class="file-card-hint">点击预览文件</span>
+        </div>
+      </div>
+      <div class="file-card-actions">
+        <button class="file-action-btn download-btn" @click.stop="handleDownloadFile(file.url, file.filename)">
+          <img :src="getCssUrl('--img-chat-download-icon-btn')" width="14" height="14" />
+          <span>下载</span>
+        </button>
+        <button v-if="isEditableFile(file.filename)" class="file-action-btn edit-btn" @click.stop="handleEditFile(file.url, file.filename)">
+          <img :src="getCssUrl('--img-chat-edit-icon')" width="14" height="14" />
+          <span>编辑</span>
+        </button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { message } from 'ant-design-vue';
+  import { getToken } from '/@/utils/auth/index';
+  import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
+  import { fetchPreviewFile, getCssUrl, getFileIconVar, isMdFile, isTextFile } from '/@/views/ventAI/manageAssistent/lib/index';
+
+  /**
+   * 下载文件卡片列表。取内容/预览的副作用在本组件内完成,
+   * 需要打开右侧面板时通过事件交回父组件(面板状态由父组件统一持有)。
+   */
+  defineProps<{
+    files: Array<{ url: string; filename: string }>;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'filePreview', file: AttachedFile): void;
+    (e: 'openFilePreview', file: AttachedFile): void;
+  }>();
+
+  const isEditableFile = (filename: string) => isMdFile(filename) || isTextFile(filename);
+
+  const handleDownloadFile = (url: string, filename: string) => {
+    if (!url) return;
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = filename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+  };
+
+  const handleEditFile = async (url: string, filename: string) => {
+    try {
+      const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
+      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+      const content = await resp.text();
+      const ext = filename.split('.').pop()?.toLowerCase() || '';
+      const mimeMap: Record<string, string> = {
+        md: 'text/markdown',
+        txt: 'text/plain',
+        json: 'application/json',
+        xml: 'text/xml',
+        yaml: 'text/yaml',
+        yml: 'text/yaml',
+        csv: 'text/csv',
+      };
+      const file: AttachedFile = {
+        id: `report-${url}`,
+        name: filename,
+        size: content.length,
+        type: mimeMap[ext] || 'text/plain',
+        content,
+        uploadTime: new Date().toISOString(),
+      };
+      emit('openFilePreview', file);
+    } catch (err) {
+      console.error('获取文件内容失败:', err);
+      message.error('获取文件内容失败');
+    }
+  };
+
+  // 点击文件卡片:按类型拉取内容并在右侧辅助面板打开预览标签
+  const handlePreviewFile = async (file: { url: string; filename: string }) => {
+    if (!file.url) return;
+    try {
+      const previewFile = await fetchPreviewFile(file.url, file.filename);
+      if (!previewFile) {
+        // 不支持的格式回退为直接下载
+        handleDownloadFile(file.url, file.filename);
+        return;
+      }
+      emit('filePreview', previewFile);
+    } catch (err) {
+      console.error('获取文件内容失败:', err);
+      message.error('文件预览失败,请尝试下载查看');
+    }
+  };
+</script>
+
+<style scoped lang="less">
+  .download-file-cards {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    margin-top: 12px;
+
+    .download-file-card {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 12px 16px;
+      background: linear-gradient(135deg, rgba(0, 47, 74, 0.8) 0%, rgba(0, 60, 90, 0.6) 100%);
+      border: 1px solid rgba(0, 84, 130, 0.6);
+      border-radius: 10px;
+      cursor: pointer;
+      transition: all 0.2s ease;
+
+      &:hover {
+        border-color: rgba(10, 132, 255, 0.4);
+        box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+      }
+
+      .file-card-info {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+        min-width: 0;
+        flex: 1;
+
+        .file-card-icon {
+          width: 36px;
+          height: 36px;
+          background-image: var(--img-chat-download-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          flex-shrink: 0;
+          opacity: 0.9;
+        }
+
+        .file-card-text {
+          display: flex;
+          flex-direction: column;
+          gap: 2px;
+          min-width: 0;
+
+          .file-card-name {
+            font-size: 13px;
+            font-weight: 500;
+            color: #e0e6ed;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+          }
+
+          .file-card-hint {
+            font-size: 11px;
+            color: #6e7681;
+          }
+        }
+      }
+
+      .file-card-actions {
+        display: flex;
+        gap: 8px;
+        flex-shrink: 0;
+
+        .file-action-btn {
+          display: inline-flex;
+          align-items: center;
+          gap: 5px;
+          padding: 6px 14px;
+          border-radius: 7px;
+          font-size: 12px;
+          font-weight: 500;
+          cursor: pointer;
+          border: none;
+          transition: all 0.2s ease;
+          white-space: nowrap;
+
+          &.download-btn {
+            background: rgba(255, 255, 255, 0.08);
+            color: #c9d1d9;
+            border: 1px solid rgba(63, 80, 106, 0.5);
+
+            &:hover {
+              background: rgba(255, 255, 255, 0.14);
+              color: #e0e6ed;
+              border-color: rgba(63, 80, 106, 0.8);
+            }
+          }
+
+          &.edit-btn {
+            background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
+            color: #fff;
+            border: 1px solid rgba(10, 132, 255, 0.5);
+            box-shadow: 0 2px 6px rgba(10, 132, 255, 0.2);
+
+            &:hover {
+              background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
+              box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
+            }
+          }
+        }
+      }
+    }
+  }
+</style>

+ 242 - 0
src/views/ventAI/manageAssistent/components/chat/Input/ContextRing.vue

@@ -0,0 +1,242 @@
+<template>
+  <!-- 上下文容量环:悬浮展开明细,点击也可切换 -->
+  <div class="btn-context-wrapper" ref="wrapperRef" @mouseenter="showPopup = true" @mouseleave="showPopup = false">
+    <div class="btn-context" @click="togglePopup">
+      <svg class="context-ring" viewBox="0 0 32 32">
+        <circle class="context-ring-bg" cx="16" cy="16" r="13" />
+        <circle
+          class="context-ring-fill"
+          cx="16"
+          cy="16"
+          r="13"
+          :stroke-dasharray="circumference"
+          :stroke-dashoffset="offset"
+          :style="{ stroke: color }"
+        />
+      </svg>
+      <span class="context-percent">{{ percent }}%</span>
+    </div>
+    <div v-if="showPopup" class="context-popup" @click.stop>
+      <div class="context-popup-title">上下文容量</div>
+      <div class="context-popup-info">
+        <span class="context-used">{{ displayUsed }}</span>
+        <span class="context-sep">/</span>
+        <span class="context-max">{{ displayMax }}</span>
+        <span class="context-pct">({{ percent }}%)</span>
+      </div>
+      <div class="context-popup-bar-track">
+        <div class="context-popup-bar-fill" :style="{ width: percent + '%', background: color }"></div>
+      </div>
+      <div class="context-breakdown">
+        <div class="breakdown-item">
+          <span class="breakdown-label">历史消息</span>
+          <span class="breakdown-value">{{ formatTokens(breakdown.messages) }}</span>
+        </div>
+        <div class="breakdown-item">
+          <span class="breakdown-label">MCP工具</span>
+          <span class="breakdown-value">{{ formatTokens(breakdown.mcp) }}</span>
+        </div>
+        <div class="breakdown-item">
+          <span class="breakdown-label">技能描述</span>
+          <span class="breakdown-value">{{ formatTokens(breakdown.skills) }}</span>
+        </div>
+        <div class="breakdown-item">
+          <span class="breakdown-label">系统提示词</span>
+          <span class="breakdown-value">{{ formatTokens(breakdown.system_prompt) }}</span>
+        </div>
+        <div class="breakdown-item">
+          <span class="breakdown-label">其他</span>
+          <span class="breakdown-value">{{ formatTokens(breakdown.other) }}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, ref } from 'vue';
+  import { onClickOutside } from '@vueuse/core';
+  import { formatTokens } from '/@/views/ventAI/manageAssistent/lib/index';
+
+  /** 上下文容量环 + 悬浮明细。数值全部来自父组件(会话维度),弹层开关是本组件内部状态。 */
+  const props = withDefaults(
+    defineProps<{
+      used?: number;
+      max?: number;
+      percent?: number;
+      breakdown?: { messages: number; mcp: number; skills: number; system_prompt: number; other: number };
+    }>(),
+    {
+      used: 0,
+      max: 1000000,
+      percent: 0,
+      breakdown: () => ({ messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 }),
+    }
+  );
+
+  const wrapperRef = ref<HTMLElement>();
+  const showPopup = ref(false);
+
+  const circumference = computed(() => 2 * Math.PI * 13);
+  const offset = computed(() => circumference.value * (1 - props.percent / 100));
+
+  const color = computed(() => {
+    if (props.percent >= 90) return '#ff4d4f';
+    if (props.percent >= 70) return '#faad14';
+    return '#52c41a';
+  });
+
+  const displayUsed = computed(() => (props.used >= 10000 ? (props.used / 10000).toFixed(1) + '万' : props.used.toLocaleString()));
+  const displayMax = computed(() => (props.max >= 10000 ? (props.max / 10000).toFixed(0) + '万' : props.max.toLocaleString()));
+
+  const togglePopup = () => {
+    showPopup.value = !showPopup.value;
+  };
+
+  onClickOutside(wrapperRef, () => {
+    showPopup.value = false;
+  });
+</script>
+
+<style scoped lang="less">
+  .btn-context-wrapper {
+    position: relative;
+    display: flex;
+    align-items: center;
+  }
+  .btn-context {
+    width: 30px;
+    height: 30px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    position: relative;
+    border-radius: 6px;
+    transition: background 0.2s;
+
+    &:hover {
+      background: rgba(10, 132, 255, 0.1);
+    }
+
+    .context-ring {
+      width: 24px;
+      height: 24px;
+      transform: rotate(-90deg);
+
+      .context-ring-bg {
+        fill: none;
+        stroke: rgba(255, 255, 255, 0.1);
+        stroke-width: 3;
+      }
+
+      .context-ring-fill {
+        fill: none;
+        stroke-width: 3;
+        stroke-linecap: round;
+        transition: stroke-dashoffset 0.3s ease;
+      }
+    }
+
+    .context-percent {
+      position: absolute;
+      font-size: 8px;
+      color: #e0e6ed;
+      font-weight: 500;
+      line-height: 1;
+    }
+  }
+  .context-popup {
+    position: absolute;
+    bottom: 42px;
+    left: 50%;
+    transform: translateX(-50%);
+    background: #0a2a3f;
+    border: 1px solid #1a4a6f;
+    border-radius: 8px;
+    min-width: 200px;
+    padding: 14px 16px;
+
+    &::before {
+      content: '';
+      position: absolute;
+      bottom: -12px;
+      left: 0;
+      right: 0;
+      height: 12px;
+    }
+    z-index: 1000;
+    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+
+    .context-popup-title {
+      font-size: 13px;
+      color: #8b949e;
+      margin-bottom: 8px;
+    }
+
+    .context-popup-info {
+      display: flex;
+      align-items: baseline;
+      gap: 2px;
+      margin-bottom: 10px;
+
+      .context-used {
+        font-size: 18px;
+        font-weight: 600;
+        color: #e0e6ed;
+      }
+
+      .context-sep {
+        font-size: 14px;
+        color: #8b949e;
+      }
+
+      .context-max {
+        font-size: 14px;
+        color: #8b949e;
+      }
+
+      .context-pct {
+        font-size: 13px;
+        color: #8b949e;
+      }
+    }
+
+    .context-popup-bar-track {
+      width: 100%;
+      height: 6px;
+      background: rgba(255, 255, 255, 0.08);
+      border-radius: 3px;
+      overflow: hidden;
+
+      .context-popup-bar-fill {
+        height: 100%;
+        border-radius: 3px;
+        transition: width 0.3s ease;
+      }
+    }
+
+    .context-breakdown {
+      margin-top: 10px;
+      padding-top: 10px;
+      border-top: 1px solid rgba(255, 255, 255, 0.08);
+
+      .breakdown-item {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        padding: 3px 0;
+
+        .breakdown-label {
+          font-size: 12px;
+          color: #8b949e;
+        }
+
+        .breakdown-value {
+          font-size: 12px;
+          color: #e0e6ed;
+        }
+      }
+    }
+  }
+</style>

+ 209 - 0
src/views/ventAI/manageAssistent/components/chat/Input/ModePopup.vue

@@ -0,0 +1,209 @@
+<template>
+  <!-- 模式选择器:计划模式 / 完全访问 -->
+  <div class="btn-edit-mode-wrapper" ref="wrapperRef">
+    <a-tooltip title="切换模式">
+      <div class="btn-edit-mode" @click="togglePopup">
+        <div class="btn-icon" :class="`mode-icon-${selected.key}`"></div>
+        <span class="btn-text">{{ selected.label }}</span>
+        <div class="btn-arrow"></div>
+      </div>
+    </a-tooltip>
+    <div v-if="showPopup" class="edit-mode-popup">
+      <div v-for="item in options" :key="item.key" class="edit-mode-item" :class="{ active: selected.key === item.key }" @click="selectItem(item)">
+        <div class="edit-mode-item-icon" :class="`mode-icon-${item.key}`"></div>
+        <div class="edit-mode-item-main">
+          <span class="edit-mode-item-label">{{ item.label }}</span>
+          <span class="edit-mode-item-desc">{{ item.desc }}</span>
+        </div>
+        <div class="edit-mode-check" :class="{ visible: selected.key === item.key }"></div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, watch } from 'vue';
+  import { onClickOutside } from '@vueuse/core';
+
+  interface EditModeOption {
+    key: string;
+    label: string;
+    desc: string;
+  }
+
+  /**
+   * 模式选择器。选中项是本组件状态(仅供展示),切换时通过 change 事件通知父组件:
+   * 父组件持有真正用于请求的 editMode,并负责调接口持久化会话模式。
+   */
+  const props = withDefaults(defineProps<{ initialMode?: string }>(), { initialMode: 'full' });
+  const emit = defineEmits<{
+    (e: 'change', key: string): void;
+  }>();
+
+  const options: EditModeOption[] = [
+    // { key: 'confirm', label: '变更前确认', desc: '改文件前先问我' },
+    // { key: 'auto', label: '自动编辑', desc: '自动编辑文件' },
+    { key: 'plan', label: '计划模式', desc: '编辑前先出计划' },
+    { key: 'full', label: '完全访问', desc: '减少确认次数' },
+  ];
+
+  const selected = ref<EditModeOption>(options[1]);
+  const showPopup = ref(false);
+  const wrapperRef = ref<HTMLElement>();
+
+  watch(
+    () => props.initialMode,
+    (mode) => {
+      const found = options.find((opt) => opt.key === mode);
+      if (found) selected.value = found;
+    },
+    { immediate: true }
+  );
+
+  const togglePopup = () => {
+    showPopup.value = !showPopup.value;
+  };
+
+  const selectItem = (item: EditModeOption) => {
+    selected.value = item;
+    showPopup.value = false;
+    emit('change', item.key);
+  };
+
+  onClickOutside(wrapperRef, () => {
+    showPopup.value = false;
+  });
+</script>
+
+<style scoped lang="less">
+  /* 以下 .btn-icon/.btn-text/.btn-arrow/.mode-icon-* 与原输入区内联按钮原语一致:
+     按钮下移到组件后,父组件的 scoped 样式不再作用于这里的后代元素,故随组件复制一份。 */
+  .btn-icon {
+    width: 14px;
+    height: 14px;
+    background-repeat: no-repeat;
+    background-size: 100% 100%;
+    flex-shrink: 0;
+  }
+  .btn-text {
+    font-size: 12px;
+    color: #e0e6ed;
+    white-space: nowrap;
+  }
+  .btn-arrow {
+    width: 0;
+    height: 0;
+    border-left: 4px solid transparent;
+    border-right: 4px solid transparent;
+    border-top: 4px solid #8b949e;
+    flex-shrink: 0;
+  }
+  .mode-icon-confirm {
+    background-image: var(--img-chat-mode-confirm-icon);
+  }
+  .mode-icon-auto {
+    background-image: var(--img-chat-mode-auto-icon);
+  }
+  .mode-icon-plan {
+    background-image: var(--img-chat-mode-plan-icon);
+  }
+  .mode-icon-full {
+    background-image: var(--img-chat-mode-full-icon);
+  }
+
+  .btn-edit-mode-wrapper {
+    position: relative;
+  }
+  .btn-edit-mode {
+    height: 28px;
+    padding: 0 10px;
+    border-radius: 6px;
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    cursor: pointer;
+    transition: all 0.2s;
+
+    &:hover {
+      border-color: #3a8fd4;
+      background: rgba(10, 132, 255, 0.1);
+    }
+  }
+  .edit-mode-popup {
+    position: absolute;
+    bottom: 38px;
+    left: 50%;
+    transform: translateX(-50%);
+    background: #0a2a3f;
+    border: 1px solid #1a4a6f;
+    border-radius: 8px;
+    min-width: 200px;
+    z-index: 1000;
+    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+
+    .edit-mode-item {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 10px 12px;
+      cursor: pointer;
+      transition: background 0.2s;
+      border-radius: 6px;
+
+      &:hover {
+        background: rgba(10, 132, 255, 0.15);
+      }
+
+      &.active {
+        background: rgba(10, 132, 255, 0.1);
+      }
+
+      .edit-mode-item-icon {
+        width: 18px;
+        height: 18px;
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+        flex-shrink: 0;
+        margin-right: 10px;
+      }
+
+      .edit-mode-item-main {
+        display: flex;
+        flex-direction: column;
+        gap: 2px;
+
+        .edit-mode-item-label {
+          font-size: 13px;
+          color: #e0e6ed;
+          white-space: nowrap;
+        }
+
+        .edit-mode-item-desc {
+          font-size: 11px;
+          color: #8b949e;
+          white-space: nowrap;
+        }
+      }
+
+      .edit-mode-check {
+        width: 16px;
+        height: 16px;
+        flex-shrink: 0;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        visibility: hidden;
+
+        &.visible {
+          visibility: visible;
+        }
+
+        &::after {
+          content: '✓';
+          font-size: 12px;
+          color: #3a8fd4;
+        }
+      }
+    }
+  }
+</style>

+ 186 - 0
src/views/ventAI/manageAssistent/components/chat/Input/SessionPopup.vue

@@ -0,0 +1,186 @@
+<template>
+  <!-- 会话引用选择弹层:由父组件用 # 触发,多选上限 5;已选状态与高亮项都由父组件传入 -->
+  <div ref="rootEl" class="session-popup" @click.stop @pointerdown.stop>
+    <div class="session-popup-header">
+      <span>选择会话</span>
+      <span class="session-count">{{ selectedCount }}/5</span>
+    </div>
+    <div class="session-list">
+      <div
+        v-for="(task, index) in list"
+        :key="task.id"
+        class="session-item"
+        :class="{
+          active: index === activeIndex,
+          selected: isSelected(task.id),
+          disabled: (selectedCount >= 5 && !isSelected(task.id)) || task.id === currentTaskId,
+        }"
+        @click="emit('select', task)"
+      >
+        <div class="session-checkbox" :class="{ checked: isSelected(task.id) }">
+          <span v-if="isSelected(task.id)" class="check-mark">✓</span>
+        </div>
+        <div class="session-item-icon"></div>
+        <span class="session-item-name">{{ task.name }}</span>
+      </div>
+      <EmptyState v-if="list.length === 0" description="暂无匹配会话" />
+    </div>
+    <div class="session-popup-footer">
+      <span v-if="selectedCount >= 5" class="max-hint">最多选择5个会话</span>
+      <span v-else>点击选择会话(最多5个)</span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, ref } from 'vue';
+  import { onClickOutside } from '@vueuse/core';
+  import EmptyState from '/@/views/ventAI/manageAssistent/components/common/EmptyState.vue';
+
+  interface SessionOption {
+    id: string;
+    name: string;
+  }
+
+  const props = defineProps<{
+    list: SessionOption[];
+    activeIndex: number;
+    selectedIds: string[];
+    currentTaskId: string;
+  }>();
+
+  const emit = defineEmits<{ (e: 'select', task: SessionOption): void; (e: 'close'): void }>();
+
+  const selectedCount = computed(() => props.selectedIds.length);
+
+  const isSelected = (id: string) => props.selectedIds.includes(id);
+
+  // 组件自己持有根元素,外部点击由这里判定后回调父组件
+  const rootEl = ref<HTMLElement>();
+  onClickOutside(rootEl, () => emit('close'));
+</script>
+
+<style scoped lang="less">
+  .session-popup {
+    position: absolute;
+    bottom: 100%;
+    left: 0;
+    right: 0;
+    margin-bottom: 8px;
+    background: #0a2a3f;
+    border: 1px solid #1a4a6f;
+    border-radius: 8px;
+    max-height: 300px;
+    display: flex;
+    flex-direction: column;
+    z-index: 1000;
+    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+
+    .session-popup-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 10px 14px 8px;
+      font-size: 13px;
+      color: #e0e6ed;
+      border-bottom: 1px solid rgba(63, 80, 106, 0.3);
+      flex-shrink: 0;
+
+      .session-count {
+        font-size: 12px;
+        color: #3a8fd4;
+        font-weight: 500;
+      }
+    }
+
+    .session-list {
+      flex: 1;
+      overflow-y: auto;
+      padding: 6px;
+
+      .session-item {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        padding: 8px 10px;
+        cursor: pointer;
+        border-radius: 6px;
+        transition: background 0.2s;
+
+        &:hover {
+          background: rgba(10, 132, 255, 0.15);
+        }
+
+        &.active {
+          background: rgba(10, 132, 255, 0.2);
+        }
+
+        &.selected {
+          background: rgba(10, 132, 255, 0.12);
+        }
+
+        &.disabled {
+          opacity: 0.4;
+          cursor: not-allowed;
+
+          &:hover {
+            background: transparent;
+          }
+        }
+
+        .session-checkbox {
+          width: 16px;
+          height: 16px;
+          border: 1.5px solid rgba(255, 255, 255, 0.25);
+          border-radius: 3px;
+          flex-shrink: 0;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          transition: all 0.2s;
+
+          &.checked {
+            background: #3a8fd4;
+            border-color: #3a8fd4;
+          }
+
+          .check-mark {
+            font-size: 11px;
+            color: #fff;
+            line-height: 1;
+          }
+        }
+
+        .session-item-icon {
+          width: 18px;
+          height: 18px;
+          background-image: var(--img-chat-popup-session-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          flex-shrink: 0;
+        }
+
+        .session-item-name {
+          font-size: 13px;
+          color: #e0e6ed;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+      }
+
+    }
+    .session-popup-footer {
+      padding: 8px 14px;
+      font-size: 12px;
+      color: #8b949e;
+      text-align: center;
+      border-top: 1px solid rgba(63, 80, 106, 0.3);
+      flex-shrink: 0;
+
+      .max-hint {
+        color: #faad14;
+      }
+    }
+  }
+</style>

+ 171 - 0
src/views/ventAI/manageAssistent/components/chat/Input/SkillPopup.vue

@@ -0,0 +1,171 @@
+<template>
+  <!-- 技能引用选择弹层:由父组件用 $ 触发,单选;已选状态与高亮项都由父组件传入 -->
+  <div ref="rootEl" class="skill-popup" @click.stop @pointerdown.stop>
+    <div class="skill-popup-header">
+      <span>选择技能</span>
+      <span class="skill-count">0/1</span>
+    </div>
+    <div class="skill-list">
+      <div
+        v-for="(skill, index) in list"
+        :key="skill.name"
+        class="skill-item"
+        :class="{
+          active: index === activeIndex,
+          selected: isSelected(skill.name),
+        }"
+        @click="emit('select', skill)"
+      >
+        <div class="skill-item-checkbox" :class="{ checked: isSelected(skill.name) }">
+          <span v-if="isSelected(skill.name)" class="check-mark">✓</span>
+        </div>
+        <div class="skill-item-icon"></div>
+        <span class="skill-item-name">{{ skill.display_name }}</span>
+      </div>
+      <EmptyState v-if="list.length === 0" :description="loadFailed ? '技能列表加载失败,请稍后重试' : '暂无匹配技能'" />
+    </div>
+    <div class="skill-popup-footer">
+      <span>点击选择技能(仅可选1个)</span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import EmptyState from '/@/views/ventAI/manageAssistent/components/common/EmptyState.vue';
+  import { ref } from 'vue';
+  import { onClickOutside } from '@vueuse/core';
+
+  interface SkillOption {
+    name: string;
+    display_name: string;
+  }
+
+  const props = defineProps<{
+    list: SkillOption[];
+    activeIndex: number;
+    selectedName: string;
+    /** 技能列表是否加载失败:失败时空态文案要与「确实没有技能」区分开 */
+    loadFailed?: boolean;
+  }>();
+
+  const emit = defineEmits<{ (e: 'select', skill: SkillOption): void; (e: 'close'): void }>();
+
+  const isSelected = (name: string) => props.selectedName === name;
+
+  // 组件自己持有根元素,外部点击由这里判定后回调父组件
+  const rootEl = ref<HTMLElement>();
+  onClickOutside(rootEl, () => emit('close'));
+</script>
+
+<style scoped lang="less">
+  .skill-popup {
+    position: absolute;
+    bottom: 100%;
+    left: 0;
+    right: 0;
+    margin-bottom: 8px;
+    background: #0a2a3f;
+    border: 1px solid #1a4a6f;
+    border-radius: 8px;
+    max-height: 300px;
+    display: flex;
+    flex-direction: column;
+    z-index: 1000;
+    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+
+    .skill-popup-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 10px 14px 8px;
+      font-size: 13px;
+      color: #e0e6ed;
+      border-bottom: 1px solid rgba(63, 80, 106, 0.3);
+      flex-shrink: 0;
+
+      .skill-count {
+        font-size: 12px;
+        color: #3a8fd4;
+        font-weight: 500;
+      }
+    }
+
+    .skill-list {
+      flex: 1;
+      overflow-y: auto;
+      padding: 6px;
+
+      .skill-item {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        padding: 8px 10px;
+        cursor: pointer;
+        border-radius: 6px;
+        transition: background 0.2s;
+
+        &:hover {
+          background: rgba(10, 132, 255, 0.15);
+        }
+
+        &.active {
+          background: rgba(10, 132, 255, 0.2);
+        }
+
+        &.selected {
+          background: rgba(10, 132, 255, 0.12);
+        }
+
+        .skill-item-checkbox {
+          width: 16px;
+          height: 16px;
+          border: 1.5px solid rgba(255, 255, 255, 0.25);
+          border-radius: 3px;
+          flex-shrink: 0;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          transition: all 0.2s;
+
+          &.checked {
+            background: #3a8fd4;
+            border-color: #3a8fd4;
+          }
+
+          .check-mark {
+            font-size: 11px;
+            color: #fff;
+            line-height: 1;
+          }
+        }
+
+        .skill-item-icon {
+          width: 18px;
+          height: 18px;
+          background-image: var(--img-chat-options-skill-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          flex-shrink: 0;
+        }
+
+        .skill-item-name {
+          font-size: 13px;
+          color: #e0e6ed;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+      }
+
+    }
+
+    .skill-popup-footer {
+      padding: 8px 14px;
+      font-size: 12px;
+      color: #8b949e;
+      text-align: center;
+      border-top: 1px solid rgba(63, 80, 106, 0.3);
+      flex-shrink: 0;
+    }
+  }
+</style>

+ 211 - 0
src/views/ventAI/manageAssistent/components/chat/Input/ThinkLevelPopup.vue

@@ -0,0 +1,211 @@
+<template>
+  <!-- 思考级别选择器:关闭 / 高 / 最高 -->
+  <div class="btn-think-wrapper" ref="wrapperRef">
+    <a-tooltip :title="props.disabled ? '思考级别(只读:仅管理员可修改)' : '思考级别'">
+      <div class="btn-think" :class="{ 'is-readonly': props.disabled }" @click="togglePopup">
+        <div class="btn-icon think-icon"></div>
+        <span class="btn-text">{{ selected.label }}</span>
+        <div class="btn-arrow"></div>
+      </div>
+    </a-tooltip>
+    <div v-if="showPopup" class="think-popup" @click.stop>
+      <div v-for="item in options" :key="item.key" class="think-popup-item" :class="{ active: selected.key === item.key }" @click="selectItem(item)">
+        <div class="think-item-main">
+          <span class="think-item-label">{{ item.label }}</span>
+          <span class="think-item-desc">{{ item.desc }}</span>
+        </div>
+        <div class="think-item-check" :class="{ visible: selected.key === item.key }"></div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, watch } from 'vue';
+  import { onClickOutside } from '@vueuse/core';
+
+  interface ThinkLevelOption {
+    key: string;
+    label: string;
+    desc: string;
+  }
+
+  /**
+   * 思考级别选择器。选中项是本组件状态(仅供展示),切换时通过 change 事件通知父组件调接口。
+   *
+   * disabled 用于「非管理员只读」:控件仍然显示(当前级别本身是需要被看到的状态),
+   * 但不展开菜单、不派发 change,因此不会触发切换接口。
+   */
+  const props = withDefaults(defineProps<{ initialLevel?: string; disabled?: boolean }>(), {
+    initialLevel: 'off',
+    disabled: false,
+  });
+  const emit = defineEmits<{
+    (e: 'change', key: string): void;
+  }>();
+
+  const options: ThinkLevelOption[] = [
+    { key: 'off', label: '关闭', desc: '不使用深度思考,响应最快' },
+    { key: 'high', label: '高', desc: '深度思考,平衡速度与质量' },
+    { key: 'highest', label: '最高', desc: '极致思考,质量最高但较慢' },
+  ];
+
+  const selected = ref<ThinkLevelOption>(options[1]);
+  const showPopup = ref(false);
+  const wrapperRef = ref<HTMLElement>();
+
+  watch(
+    () => props.initialLevel,
+    (level) => {
+      const found = options.find((opt) => opt.key === level);
+      if (found) selected.value = found;
+    },
+    { immediate: true }
+  );
+
+  const togglePopup = () => {
+    // 只读:不展开菜单(展示当前级别即可,不允许改)
+    if (props.disabled) return;
+    showPopup.value = !showPopup.value;
+  };
+
+  const selectItem = (item: ThinkLevelOption) => {
+    // 只读:不派发 change,避免触发切换接口
+    if (props.disabled) return;
+    selected.value = item;
+    showPopup.value = false;
+    emit('change', item.key);
+  };
+
+  onClickOutside(wrapperRef, () => {
+    showPopup.value = false;
+  });
+</script>
+
+<style scoped lang="less">
+  /* 以下 .btn-icon/.btn-text/.btn-arrow/.think-icon 与原输入区内联按钮原语一致:
+     按钮下移到组件后,父组件的 scoped 样式不再作用于这里的后代元素,故随组件复制一份。 */
+  .btn-icon {
+    width: 14px;
+    height: 14px;
+    background-repeat: no-repeat;
+    background-size: 100% 100%;
+    flex-shrink: 0;
+  }
+  .think-icon {
+    background-image: var(--img-chat-thinking-icon);
+  }
+  .btn-text {
+    font-size: 12px;
+    color: #e0e6ed;
+    white-space: nowrap;
+  }
+  .btn-arrow {
+    width: 0;
+    height: 0;
+    border-left: 4px solid transparent;
+    border-right: 4px solid transparent;
+    border-top: 4px solid #8b949e;
+    flex-shrink: 0;
+  }
+
+  .btn-think-wrapper {
+    position: relative;
+    display: flex;
+    align-items: center;
+  }
+  .btn-think {
+    height: 28px;
+    padding: 0 10px;
+    border-radius: 6px;
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    cursor: pointer;
+    transition: all 0.2s;
+
+    &:hover {
+      border-color: #3a8fd4;
+      background: rgba(10, 132, 255, 0.1);
+    }
+
+    /* 只读态:保留可读的当前级别,但不给出可点击的反馈 */
+    &.is-readonly {
+      cursor: not-allowed;
+      opacity: 0.6;
+
+      &:hover {
+        border-color: transparent;
+        background: transparent;
+      }
+    }
+  }
+  .think-popup {
+    position: absolute;
+    bottom: 38px;
+    left: 50%;
+    transform: translateX(-50%);
+    background: #0a2a3f;
+    border: 1px solid #1a4a6f;
+    border-radius: 8px;
+    min-width: 200px;
+    z-index: 1000;
+    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+
+    .think-popup-item {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 10px 12px;
+      cursor: pointer;
+      transition: background 0.2s;
+      border-radius: 6px;
+
+      &:hover {
+        background: rgba(10, 132, 255, 0.15);
+      }
+
+      &.active {
+        background: rgba(10, 132, 255, 0.1);
+      }
+
+      .think-item-main {
+        display: flex;
+        flex-direction: column;
+        gap: 2px;
+
+        .think-item-label {
+          font-size: 13px;
+          color: #e0e6ed;
+          white-space: nowrap;
+        }
+
+        .think-item-desc {
+          font-size: 11px;
+          color: #8b949e;
+          white-space: nowrap;
+        }
+      }
+
+      .think-item-check {
+        width: 16px;
+        height: 16px;
+        flex-shrink: 0;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        visibility: hidden;
+
+        &.visible {
+          visibility: visible;
+        }
+
+        &::after {
+          content: '✓';
+          font-size: 12px;
+          color: #3a8fd4;
+        }
+      }
+    }
+  }
+</style>

+ 266 - 0
src/views/ventAI/manageAssistent/components/chat/Input/UsageRing.vue

@@ -0,0 +1,266 @@
+<template>
+  <!-- 今日用量环:悬浮展开明细(按模型分组的 token 明细) -->
+  <div class="btn-usage-wrapper" ref="wrapperRef" @mouseenter="showPopup = true" @mouseleave="showPopup = false">
+    <a-tooltip>
+      <div class="btn-usage" @click="togglePopup">
+        <svg class="usage-ring" viewBox="0 0 32 32">
+          <circle class="usage-ring-bg" cx="16" cy="16" r="13" />
+          <circle
+            class="usage-ring-fill"
+            cx="16"
+            cy="16"
+            r="13"
+            :stroke-dasharray="circumference"
+            :stroke-dashoffset="offset"
+            :style="{ stroke: color }"
+          />
+        </svg>
+        <span class="usage-symbol">T</span>
+      </div>
+    </a-tooltip>
+    <div v-if="showPopup" class="usage-popup" @click.stop>
+      <div class="usage-popup-title">今日用量</div>
+      <div class="usage-popup-info">
+        <span class="usage-spent">{{ spentTokens }} </span>
+        <span class="usage-sep">/</span>
+        <span class="usage-limit">{{ limitTokens }} </span>
+        <span class="usage-pct">({{ percent }}%)</span>
+      </div>
+      <div class="usage-popup-bar-track">
+        <div class="usage-popup-bar-fill" :style="{ width: percent + '%', background: color }"></div>
+      </div>
+      <div class="usage-remaining">剩余额度 {{ remainingTokens }}</div>
+      <div v-if="byModel.length > 0" class="usage-breakdown">
+        <div v-for="item in byModel" :key="item.model" class="usage-model-item">
+          <div class="usage-model-row">
+            <span class="usage-model-label">缓存命中</span>
+            <span class="usage-model-value">{{ formatTokens(item.cache_hit_tokens) }}</span>
+          </div>
+          <div class="usage-model-row">
+            <span class="usage-model-label">缓存未命中</span>
+            <span class="usage-model-value">{{ formatTokens(item.cache_miss_tokens) }}</span>
+          </div>
+          <div class="usage-model-row">
+            <span class="usage-model-label">输出</span>
+            <span class="usage-model-value">{{ formatTokens(item.output_tokens) }}</span>
+          </div>
+          <div class="usage-model-row">
+            <span class="usage-model-label">请求次数</span>
+            <span class="usage-model-value">{{ item.request_count }} 次</span>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, ref } from 'vue';
+  import { onClickOutside } from '@vueuse/core';
+  import type { UsageByModel } from '/@/views/ventAI/manageAssistent/api';
+  import { formatTokens } from '/@/views/ventAI/manageAssistent/lib/index';
+
+  /** 今日用量环 + 明细弹层。额度数值来自父组件,弹层开关是本组件内部状态。 */
+  const props = withDefaults(
+    defineProps<{
+      limit?: number;
+      spent?: number;
+      remaining?: number;
+      byModel?: UsageByModel[];
+    }>(),
+    {
+      limit: 1000,
+      spent: 0,
+      remaining: 0,
+      byModel: () => [],
+    }
+  );
+
+  const wrapperRef = ref<HTMLElement>();
+  const showPopup = ref(false);
+
+  const percent = computed(() => {
+    if (!props.limit) return 0;
+    return Math.min(100, Math.round((props.spent / props.limit) * 100));
+  });
+
+  const circumference = computed(() => 2 * Math.PI * 13);
+  const offset = computed(() => circumference.value * (1 - percent.value / 100));
+
+  const color = computed(() => {
+    if (percent.value >= 90) return '#ff4d4f';
+    if (percent.value >= 70) return '#faad14';
+    return '#52c41a';
+  });
+
+  const spentTokens = computed(() => formatTokens(props.spent));
+  const limitTokens = computed(() => formatTokens(props.limit));
+  const remainingTokens = computed(() => formatTokens(Math.max(0, props.remaining ?? props.limit - props.spent)));
+
+  const togglePopup = () => {
+    showPopup.value = !showPopup.value;
+  };
+
+  onClickOutside(wrapperRef, () => {
+    showPopup.value = false;
+  });
+</script>
+
+<style scoped lang="less">
+  .btn-usage-wrapper {
+    position: relative;
+    display: flex;
+    align-items: center;
+  }
+  .btn-usage {
+    width: 30px;
+    height: 30px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    position: relative;
+    border-radius: 6px;
+    transition: background 0.2s;
+
+    &:hover {
+      background: rgba(10, 132, 255, 0.1);
+    }
+
+    .usage-ring {
+      width: 24px;
+      height: 24px;
+      transform: rotate(-90deg);
+
+      .usage-ring-bg {
+        fill: none;
+        stroke: rgba(255, 255, 255, 0.1);
+        stroke-width: 3;
+      }
+
+      .usage-ring-fill {
+        fill: none;
+        stroke-width: 3;
+        stroke-linecap: round;
+        transition: stroke-dashoffset 0.3s ease;
+      }
+    }
+
+    .usage-symbol {
+      position: absolute;
+      font-size: 9px;
+      color: #e0e6ed;
+      font-weight: 600;
+      line-height: 1;
+    }
+  }
+  .usage-popup {
+    position: absolute;
+    bottom: 42px;
+    left: 50%;
+    transform: translateX(-50%);
+    background: #0a2a3f;
+    border: 1px solid #1a4a6f;
+    border-radius: 8px;
+    min-width: 200px;
+    padding: 14px 16px;
+
+    &::before {
+      content: '';
+      position: absolute;
+      bottom: -12px;
+      left: 0;
+      right: 0;
+      height: 12px;
+    }
+    z-index: 1000;
+    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+
+    .usage-popup-title {
+      font-size: 13px;
+      color: #8b949e;
+      margin-bottom: 8px;
+    }
+
+    .usage-popup-info {
+      display: flex;
+      align-items: baseline;
+      gap: 2px;
+      margin-bottom: 10px;
+
+      .usage-spent {
+        font-size: 18px;
+        font-weight: 600;
+        color: #e0e6ed;
+      }
+
+      .usage-sep {
+        font-size: 14px;
+        color: #8b949e;
+      }
+
+      .usage-limit {
+        font-size: 14px;
+        color: #8b949e;
+      }
+
+      .usage-pct {
+        font-size: 13px;
+        color: #8b949e;
+      }
+    }
+
+    .usage-popup-bar-track {
+      width: 100%;
+      height: 6px;
+      background: rgba(255, 255, 255, 0.08);
+      border-radius: 3px;
+      overflow: hidden;
+
+      .usage-popup-bar-fill {
+        height: 100%;
+        border-radius: 3px;
+        transition: width 0.3s ease;
+      }
+    }
+
+    .usage-remaining {
+      margin-top: 6px;
+      font-size: 14px;
+      color: #8b949e;
+    }
+
+    .usage-breakdown {
+      margin-top: 10px;
+      padding-top: 10px;
+      border-top: 1px solid rgba(255, 255, 255, 0.08);
+
+      .usage-model-item {
+        padding: 6px 0;
+        border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
+
+        &:last-child {
+          border-bottom: none;
+          padding-bottom: 0;
+        }
+
+        .usage-model-row {
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+          padding: 2px 0;
+
+          .usage-model-label {
+            font-size: 12px;
+            color: #8b949e;
+          }
+
+          .usage-model-value {
+            font-size: 12px;
+            color: #e0e6ed;
+          }
+        }
+      }
+    }
+  }
+</style>

+ 127 - 0
src/views/ventAI/manageAssistent/components/chat/MessageActions.vue

@@ -0,0 +1,127 @@
+<template>
+  <!-- 消息操作栏:AI 与用户消息共用,按传入能力位决定显示哪些按钮 -->
+  <div class="message-actions">
+    <div class="action-btn" :title="copied ? '复制成功' : '复制'" @click="emit('copy')">
+      <img :src="copied ? successIcon : copyIcon" width="14" height="14" />
+    </div>
+    <div v-if="canRegenerate" class="action-btn" @click="emit('regenerate')" title="重新生成">
+      <img :src="getCssUrl('--img-chat-regenerate-icon')" width="14" height="14" />
+    </div>
+    <div v-if="canEdit" class="action-btn" @click="emit('edit')" title="编辑">
+      <img :src="getCssUrl('--img-chat-edit-icon')" width="14" height="14" />
+    </div>
+    <div v-if="message.id" class="action-btn" @click="emit('delete')" title="删除消息">
+      <img :src="getCssUrl('--img-chat-delete-icon')" width="14" height="14" />
+    </div>
+    <div v-if="showAnswerSwitcher" class="answer-switcher">
+      <div class="action-btn answer-nav-btn" :class="{ 'is-disabled': !canPrev }" @click="emit('prevAnswer')" title="上一个回答">
+        <img :src="getCssUrl('--img-chat-prev-icon')" width="14" height="14" />
+      </div>
+      <span class="answer-switcher-text">{{ answerPosition }}</span>
+      <div class="action-btn answer-nav-btn" :class="{ 'is-disabled': !canNext }" @click="emit('nextAnswer')" title="下一个回答">
+        <img :src="getCssUrl('--img-chat-next-icon')" width="14" height="14" />
+      </div>
+    </div>
+    <span v-if="message.createdAt" class="action-time">{{ formatFullTime(message.createdAt) }}</span>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import dayjs from 'dayjs';
+  import type { Message } from '/@/views/ventAI/manageAssistent/types/index';
+  import { getCssUrl } from '/@/views/ventAI/manageAssistent/lib/index';
+
+  /**
+   * 消息操作栏。显示与否(能否重新生成/编辑、多回答切换器、是否已复制)都是父组件的状态,
+   * 这里只做呈现,所有点击以事件形式交回父组件。
+   */
+  defineProps<{
+    message: Message;
+    copied: boolean;
+    canEdit?: boolean;
+    canRegenerate?: boolean;
+    showAnswerSwitcher?: boolean;
+    canPrev?: boolean;
+    canNext?: boolean;
+    answerPosition?: string;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'copy'): void;
+    (e: 'delete'): void;
+    (e: 'regenerate'): void;
+    (e: 'edit'): void;
+    (e: 'prevAnswer'): void;
+    (e: 'nextAnswer'): void;
+  }>();
+
+  const copyIcon = getCssUrl('--img-chat-copy-icon');
+  const successIcon = getCssUrl('--img-chat-copy-success-icon');
+
+  const formatFullTime = (createdAt: string) => dayjs(createdAt).format('YYYY-MM-DD HH:mm:ss');
+</script>
+
+<style scoped lang="less">
+  .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;
+    }
+
+    .answer-switcher {
+      display: flex;
+      align-items: center;
+      gap: 2px;
+      padding: 0 4px;
+      border-radius: 4px;
+      background: rgba(255, 255, 255, 0.04);
+
+      .answer-nav-btn {
+        width: 22px;
+        height: 22px;
+
+        &.is-disabled {
+          opacity: 0.35;
+          cursor: not-allowed;
+
+          &:hover {
+            background: transparent;
+            color: #8b949e;
+          }
+        }
+      }
+
+      .answer-switcher-text {
+        font-size: 12px;
+        color: #8b949e;
+        min-width: 32px;
+        text-align: center;
+        user-select: none;
+      }
+    }
+  }
+</style>

+ 325 - 0
src/views/ventAI/manageAssistent/components/chat/ThinkingCard.vue

@@ -0,0 +1,325 @@
+<template>
+  <!-- 思考过程折叠卡片:耗时条 + 思考内容 + 按智能体聚合的步骤列表 -->
+  <div class="thinking-card">
+    <div class="thinking-duration-bar" @click="toggleCollapsed">
+      <span v-if="durationText" class="thinking-duration">{{ durationText }}</span>
+      <div :class="['thinking-arrow', { expanded: !collapsed }]">
+        <img :src="getCssUrl('--img-chat-arrow-icon')" width="12" height="12" />
+      </div>
+    </div>
+    <Transition name="collapse">
+      <div v-show="!collapsed" class="thinking-collapse-wrap">
+        <div class="thinking-header" @click="toggleContentCollapsed">
+          <div class="thinking-header-left">
+            <div class="thinking-icon"></div>
+            <span class="thinking-title">思考过程</span>
+          </div>
+          <div v-if="message.thinkingContent" :class="['thinking-arrow', { expanded: !contentCollapsed }]">
+            <img :src="getCssUrl('--img-chat-arrow-icon')" width="10" height="10" />
+          </div>
+        </div>
+
+        <!-- thinking_token 思考内容 -->
+        <Transition name="collapse">
+          <div v-if="message.thinkingContent && !contentCollapsed" class="thinking-content-body">
+            {{ message.thinkingContent }}
+          </div>
+        </Transition>
+
+        <div class="thinking-body">
+          <div v-for="(group, groupIdx) in agentGroups" :key="groupIdx" class="agent-group" @click="emit('agentClick', group)">
+            <div class="agent-group-header">
+              <div class="agent-group-header-left">
+                <div class="agent-group-icon"></div>
+                <span class="agent-group-name">{{ group.agent }}</span>
+              </div>
+              <span v-if="getLatestStepMessage(group)" class="agent-group-latest">
+                {{ getLatestStepMessage(group) }}
+              </span>
+              <div class="thinking-arrow agent-arrow">
+                <img :src="getCssUrl('--img-chat-next-icon')" width="10" height="10" />
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </Transition>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
+  import type { Message } from '/@/views/ventAI/manageAssistent/types/index';
+  import { getCssUrl, groupStepsByAgent } from '/@/views/ventAI/manageAssistent/lib/index';
+  import type { AgentGroup } from '/@/views/ventAI/manageAssistent/lib/index';
+
+  /**
+   * 思考过程卡片。折叠态由父组件持有(按消息下标存),这里通过 props/emit 受控;
+   * 耗时文案依赖的 1 秒计时器与步骤聚合都在本组件内部,不再让整个消息列表跟着每秒重渲染。
+   */
+  const props = defineProps<{
+    message: Message;
+    collapsed: boolean;
+    contentCollapsed: boolean;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'update:collapsed', value: boolean): void;
+    (e: 'update:contentCollapsed', value: boolean): void;
+    (e: 'agentClick', group: AgentGroup): void;
+  }>();
+
+  const toggleCollapsed = () => emit('update:collapsed', !props.collapsed);
+  const toggleContentCollapsed = () => emit('update:contentCollapsed', !props.contentCollapsed);
+
+  // 每秒刷新一次,用于实时显示思考耗时
+  const nowTs = ref(Date.now());
+  let timer: ReturnType<typeof setInterval> | null = null;
+
+  onMounted(() => {
+    timer = setInterval(() => {
+      nowTs.value = Date.now();
+    }, 1000);
+  });
+
+  onBeforeUnmount(() => {
+    if (timer) {
+      clearInterval(timer);
+      timer = null;
+    }
+  });
+
+  const durationText = computed<string>(() => {
+    const message = props.message;
+    // 优先使用后端返回的 duration_ms(已包含 baseDurationMs 累加)
+    if (message.durationMs) {
+      return formatDuration(message.durationMs);
+    }
+    // 审批等待中 - 暂停显示中断前的累计时长
+    if (message.isPendingApproval && message.pendingApprovalData?.elapsedMs) {
+      return formatDuration(message.pendingApprovalData.elapsedMs);
+    }
+    // 前端实时计时(含中断前累计时长)
+    if (message.generateStartTime && message.isLoading) {
+      const baseMs = message.baseDurationMs || 0;
+      return formatDuration(nowTs.value - message.generateStartTime + baseMs);
+    }
+    return '';
+  });
+
+  const formatDuration = (ms: number): string => {
+    const totalSec = Math.round(ms / 1000);
+    const min = Math.floor(totalSec / 60);
+    const sec = totalSec % 60;
+    return `已工作 ${min}分${sec.toString().padStart(2, '0')}秒`;
+  };
+
+  const agentGroups = computed<AgentGroup[]>(() => groupStepsByAgent(props.message.thinkingSteps || []));
+
+  const getLatestStepMessage = (group: AgentGroup): string => {
+    if (group.steps.length === 0) return '';
+    const last = group.steps[group.steps.length - 1];
+    if (last.type === 'executing') return last.message || '正在执行...';
+    if (last.type === 'tool_call') return last.message || last.tool || '';
+    if (last.type === 'tool_result') return last.message || '';
+    if (last.type === 'agent_start') return last.message || '';
+    if (last.type === 'agent_done') return last.message || '';
+    if (last.type === 'updated_todo_list') return '任务列表已更新';
+    return '';
+  };
+</script>
+
+<style scoped lang="less">
+  .thinking-card {
+    margin-bottom: 12px;
+    overflow: hidden;
+
+    .thinking-duration-bar {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 8px 14px;
+      border-bottom: 1px solid rgba(63, 80, 106, 0.3);
+      cursor: pointer;
+      user-select: none;
+      transition: background 0.2s;
+
+      &:hover {
+        background: rgba(10, 132, 255, 0.12);
+      }
+
+      .thinking-duration {
+        font-size: 16px;
+        color: #8b949e;
+      }
+
+      .thinking-arrow {
+        color: #8b949e;
+        display: flex;
+        align-items: center;
+        transition: transform 0.2s;
+
+        &.expanded {
+          transform: rotate(180deg);
+        }
+      }
+    }
+
+    .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.05);
+      }
+
+      .thinking-header-left {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+      }
+
+      .thinking-arrow {
+        display: flex;
+        align-items: center;
+        transition: transform 0.2s;
+
+        &.expanded {
+          transform: rotate(180deg);
+        }
+      }
+
+      .thinking-icon {
+        width: 16px;
+        height: 16px;
+        background-image: var(--img-chat-thinking-icon);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+      }
+
+      .thinking-title {
+        font-size: 13px;
+        font-weight: 500;
+        color: #8b949e;
+      }
+
+      .thinking-count {
+        font-size: 12px;
+        color: #8b949e;
+        background: rgba(10, 132, 255, 0.1);
+        padding: 1px 6px;
+        border-radius: 10px;
+      }
+    }
+
+    .thinking-content-body {
+      margin: 0 14px 8px;
+      padding: 8px 12px;
+      max-height: 200px;
+      overflow-y: auto;
+      font-size: 13px;
+      line-height: 1.6;
+      color: #8b949e;
+      white-space: pre-wrap;
+      word-break: break-word;
+      background: rgba(10, 13, 23, 0.3);
+      border-radius: 6px;
+    }
+
+    .thinking-body {
+      padding: 8px 14px;
+
+      .agent-group {
+        margin-bottom: 8px;
+        border: 1px solid transparent;
+        border-radius: 6px;
+        overflow: hidden;
+        transition: border-color 0.2s;
+        cursor: pointer;
+
+        &:hover {
+          border-color: rgba(63, 80, 106, 0.25);
+          background: rgba(10, 132, 255, 0.05);
+        }
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+
+        .agent-group-header {
+          display: flex;
+          align-items: center;
+          justify-content: space-between;
+          padding: 8px 12px;
+          user-select: none;
+
+          .agent-group-header-left {
+            display: flex;
+            align-items: center;
+            gap: 8px;
+          }
+
+          .agent-group-icon {
+            width: 14px;
+            height: 14px;
+            background-image: var(--img-chat-agent-icon);
+            background-repeat: no-repeat;
+            background-size: 100% 100%;
+            flex-shrink: 0;
+          }
+
+          .agent-group-name {
+            font-size: 13px;
+            font-weight: 500;
+            color: #fff;
+          }
+
+          .agent-group-latest {
+            font-size: 12px;
+            color: #8b949e;
+            margin-left: 8px;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+            flex: 1;
+            min-width: 0;
+          }
+
+          .agent-arrow {
+            margin-left: 8px;
+            flex-shrink: 0;
+            display: flex;
+            align-items: center;
+          }
+        }
+      }
+    }
+  }
+
+  .collapse-enter-active,
+  .collapse-leave-active {
+    transition: all 0.25s ease;
+    overflow: hidden;
+  }
+
+  .collapse-enter-from,
+  .collapse-leave-to {
+    opacity: 0;
+    max-height: 0;
+    padding-top: 0;
+    padding-bottom: 0;
+    margin-top: 0;
+    margin-bottom: 0;
+  }
+
+  .collapse-enter-to,
+  .collapse-leave-from {
+    opacity: 1;
+    max-height: 500px;
+  }
+</style>

+ 1 - 5
src/views/ventAI/manageAssistent/components/chatModal/TodoListBar.vue → src/views/ventAI/manageAssistent/components/chat/TodoListBar.vue

@@ -20,6 +20,7 @@
 
 
 <script setup lang="ts">
 <script setup lang="ts">
   import { ref, computed, onMounted } from 'vue';
   import { ref, computed, onMounted } from 'vue';
+  import { getCssUrl } from '/@/views/ventAI/manageAssistent/lib/index';
 
 
   const props = defineProps<{
   const props = defineProps<{
     todos: Array<{ content: string; status: string }>;
     todos: Array<{ content: string; status: string }>;
@@ -29,11 +30,6 @@
 
 
   const completedCount = computed(() => props.todos.filter((t) => t.status === 'completed').length);
   const completedCount = computed(() => props.todos.filter((t) => t.status === 'completed').length);
 
 
-  const getCssUrl = (varName: string) => {
-    const val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
-    return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
-  };
-
   const arrowIcon = ref('');
   const arrowIcon = ref('');
   onMounted(() => {
   onMounted(() => {
     arrowIcon.value = getCssUrl('--img-chat-arrow-icon');
     arrowIcon.value = getCssUrl('--img-chat-arrow-icon');

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

@@ -1,2145 +0,0 @@
-<template>
-  <div class="chat-input-area">
-    <div v-if="pendingFile" class="pending-file-display">
-      <div class="pending-file-card" @click="emit('pending-file-preview', pendingFile)">
-        <div class="file-icon-pending" :style="{ backgroundImage: getFileIconVar(pendingFile.name) }"></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.stop="emit('remove-pending-file')">
-          <CloseOutlined />
-        </div>
-      </div>
-    </div>
-
-    <!-- 已选会话引用标签 -->
-    <div v-if="selectedSessions.length > 0" class="session-chips">
-      <div v-for="session in selectedSessions" :key="session.id" class="session-chip">
-        <div class="session-chip-icon"></div>
-        <span class="session-chip-name">{{ session.name }}</span>
-        <CloseOutlined class="session-chip-remove" @click.stop="removeSession(session.id)" />
-      </div>
-    </div>
-
-    <!-- 已选技能引用标签 -->
-    <div v-if="selectedSkill" class="skill-chips">
-      <div class="skill-chip">
-        <div class="skill-chip-icon"></div>
-        <span class="skill-chip-name">{{ selectedSkill.display_name }}</span>
-        <CloseOutlined class="skill-chip-remove" @click.stop="removeSkill()" />
-      </div>
-    </div>
-
-    <a-textarea
-      ref="textareaRef"
-      :value="modelValue"
-      @update:value="handleTextareaInput"
-      :rows="3"
-      class="chat-input"
-      placeholder="请输入你的问题"
-      :auto-size="{ minRows: 3, maxRows: 6 }"
-      @keydown="handleSessionKeydown"
-      @keydown.enter.prevent="handleEnterKey"
-      @keydown.backspace="handleBackspace"
-    />
-
-    <div class="input-actions">
-      <div class="action-left">
-        <div class="btn-upload-wrapper">
-          <a-tooltip title="添加上下文">
-            <div class="btn-upload" @click="togglePopup">
-              <div class="plus-icon"></div>
-            </div>
-          </a-tooltip>
-          <div v-if="showPopup" ref="popupRef" class="upload-popup" @click.stop>
-            <div class="popup-item" @click="handleAddAttachment">
-              <div class="popup-item-icon attach-icon"></div>
-              <span class="popup-item-text">添加附件</span>
-            </div>
-            <!-- <div class="popup-item" @click="handleInsertMention">
-              <div class="popup-item-icon mention-icon"></div>
-              <span class="popup-item-text">插入 @ 提及</span>
-            </div> -->
-            <div class="popup-item" @click="handleInsertSession">
-              <div class="popup-item-icon session-icon"></div>
-              <span class="popup-item-text">插入 # 会话</span>
-            </div>
-            <div class="popup-item" @click="handleInsertSkill">
-              <div class="popup-item-icon skill-icon"></div>
-              <span class="popup-item-text">插入 $ 技能</span>
-            </div>
-            <!-- <div class="popup-item" @click="handleInsertCommand">
-              <div class="popup-item-icon command-icon"></div>
-              <span class="popup-item-text">插入 / 命令</span>
-            </div> -->
-          </div>
-        </div>
-        <div class="action-divider"></div>
-        <div class="btn-edit-mode-wrapper">
-          <a-tooltip title="切换模式">
-            <div class="btn-edit-mode" @click="toggleEditModePopup">
-              <div class="btn-icon" :class="`mode-icon-${selectedEditMode.key}`"></div>
-              <span class="btn-text">{{ selectedEditMode.label }}</span>
-              <div class="btn-arrow"></div>
-            </div>
-          </a-tooltip>
-          <div v-if="showEditModePopup" ref="editModePopupRef" class="edit-mode-popup">
-            <div
-              v-for="item in editModeOptions"
-              :key="item.key"
-              class="edit-mode-item"
-              :class="{ active: selectedEditMode.key === item.key }"
-              @click="handleSelectEditMode(item)"
-            >
-              <div class="edit-mode-item-icon" :class="`mode-icon-${item.key}`"></div>
-              <div class="edit-mode-item-main">
-                <span class="edit-mode-item-label">{{ item.label }}</span>
-                <span class="edit-mode-item-desc">{{ item.desc }}</span>
-              </div>
-              <div class="edit-mode-check" :class="{ visible: selectedEditMode.key === item.key }"></div>
-            </div>
-          </div>
-        </div>
-        <input ref="fileInputRef" type="file" style="display: none" @change="emit('file-change', $event)" />
-      </div>
-      <div class="action-right">
-        <div class="btn-context-wrapper" ref="contextPopupRef" @mouseenter="showContextPopup = true" @mouseleave="showContextPopup = false">
-          <div class="btn-context" @click="toggleContextPopup">
-            <svg class="context-ring" viewBox="0 0 32 32">
-              <circle class="context-ring-bg" cx="16" cy="16" r="13" />
-              <circle
-                class="context-ring-fill"
-                cx="16"
-                cy="16"
-                r="13"
-                :stroke-dasharray="contextCircumference"
-                :stroke-dashoffset="contextOffset"
-                :style="{ stroke: contextColor }"
-              />
-            </svg>
-            <span class="context-percent">{{ contextPercent }}%</span>
-          </div>
-          <div v-if="showContextPopup" class="context-popup" @click.stop>
-            <div class="context-popup-title">上下文容量</div>
-            <div class="context-popup-info">
-              <span class="context-used">{{ contextDisplayUsed }}</span>
-              <span class="context-sep">/</span>
-              <span class="context-max">{{ contextDisplayMax }}</span>
-              <span class="context-pct">({{ contextPercent }}%)</span>
-            </div>
-            <div class="context-popup-bar-track">
-              <div class="context-popup-bar-fill" :style="{ width: contextPercent + '%', background: contextColor }"></div>
-            </div>
-            <div class="context-breakdown">
-              <div class="breakdown-item">
-                <span class="breakdown-label">历史消息</span>
-                <span class="breakdown-value">{{ formatTokens(props.contextBreakdown.messages) }}</span>
-              </div>
-              <div class="breakdown-item">
-                <span class="breakdown-label">MCP工具</span>
-                <span class="breakdown-value">{{ formatTokens(props.contextBreakdown.mcp) }}</span>
-              </div>
-              <div class="breakdown-item">
-                <span class="breakdown-label">技能描述</span>
-                <span class="breakdown-value">{{ formatTokens(props.contextBreakdown.skills) }}</span>
-              </div>
-              <div class="breakdown-item">
-                <span class="breakdown-label">系统提示词</span>
-                <span class="breakdown-value">{{ formatTokens(props.contextBreakdown.system_prompt) }}</span>
-              </div>
-              <div class="breakdown-item">
-                <span class="breakdown-label">其他</span>
-                <span class="breakdown-value">{{ formatTokens(props.contextBreakdown.other) }}</span>
-              </div>
-            </div>
-          </div>
-        </div>
-        <div class="action-divider"></div>
-        <div class="btn-usage-wrapper" ref="usagePopupRef" @mouseenter="showUsagePopup = true" @mouseleave="showUsagePopup = false">
-          <a-tooltip>
-            <div class="btn-usage" @click="toggleUsagePopup">
-              <svg class="usage-ring" viewBox="0 0 32 32">
-                <circle class="usage-ring-bg" cx="16" cy="16" r="13" />
-                <circle
-                  class="usage-ring-fill"
-                  cx="16"
-                  cy="16"
-                  r="13"
-                  :stroke-dasharray="usageCircumference"
-                  :stroke-dashoffset="usageOffset"
-                  :style="{ stroke: usageColor }"
-                />
-              </svg>
-              <span class="usage-symbol">T</span>
-            </div>
-          </a-tooltip>
-          <div v-if="showUsagePopup" class="usage-popup" @click.stop>
-            <div class="usage-popup-title">今日用量</div>
-            <div class="usage-popup-info">
-              <span class="usage-spent">{{ usageSpentTokens }} </span>
-              <span class="usage-sep">/</span>
-              <span class="usage-limit">{{ usageLimitTokens }} </span>
-              <span class="usage-pct">({{ usagePercent }}%)</span>
-            </div>
-            <div class="usage-popup-bar-track">
-              <div class="usage-popup-bar-fill" :style="{ width: usagePercent + '%', background: usageColor }"></div>
-            </div>
-            <div class="usage-remaining">剩余额度 {{ usageRemainingTokens }}</div>
-            <div v-if="usageByModel.length > 0" class="usage-breakdown">
-              <div v-for="item in usageByModel" :key="item.model" class="usage-model-item">
-                <!-- <div class="usage-model-header">
-                  <span class="usage-model-name">{{ item.model }}</span>
-                  <span class="usage-model-cost">{{ formatTokens(item.tokens) }}</span>
-                </div> -->
-                <div class="usage-model-row">
-                  <span class="usage-model-label">缓存命中</span>
-                  <span class="usage-model-value">{{ formatTokens(item.cache_hit_tokens) }}</span>
-                </div>
-                <div class="usage-model-row">
-                  <span class="usage-model-label">缓存未命中</span>
-                  <span class="usage-model-value">{{ formatTokens(item.cache_miss_tokens) }}</span>
-                </div>
-                <div class="usage-model-row">
-                  <span class="usage-model-label">输出</span>
-                  <span class="usage-model-value">{{ formatTokens(item.output_tokens) }}</span>
-                </div>
-                <div class="usage-model-row">
-                  <span class="usage-model-label">请求次数</span>
-                  <span class="usage-model-value">{{ item.request_count }} 次</span>
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
-        <!-- <div class="action-divider"></div>
-        <div class="btn-model-wrapper" ref="modelPopupRef">
-          <a-tooltip title="选择模型">
-            <div class="btn-model" @click="toggleModelPopup">
-              <span class="btn-text">{{ selectedModel.label }}</span>
-              <div class="btn-arrow"></div>
-            </div>
-          </a-tooltip>
-          <div v-if="showModelPopup" class="model-popup" @click.stop>
-            <div
-              v-for="item in modelOptions"
-              :key="item.key"
-              class="model-popup-item"
-              :class="{ active: selectedModel.key === item.key }"
-              @click="handleSelectModel(item)"
-            >
-              <div class="model-item-main">
-                <span class="model-item-label">{{ item.label }}</span>
-                <span class="model-item-desc">{{ item.desc }}</span>
-              </div>
-              <div class="model-item-check" :class="{ visible: selectedModel.key === item.key }"></div>
-            </div>
-          </div>
-        </div> -->
-        <div class="action-divider"></div>
-        <!-- 【改动 M3】思考级别切换控件对非管理员隐藏:TfAgents 已把「思考级别切换」列为全局变更端点
-             (见改动清单 M3)。这里只包住 .btn-think-wrapper 本身,左右的分隔线与发送按钮保持原样,
-             以免改动按钮区布局。思考级别的"读取"(getModelMsg)不受影响,对所有人依旧可用。 -->
-        <div v-if="isAdmin" class="btn-think-wrapper" ref="thinkPopupRef">
-          <a-tooltip title="思考级别">
-            <div class="btn-think" @click="toggleThinkPopup">
-              <div class="btn-icon think-icon"></div>
-              <span class="btn-text">{{ selectedThinkLevel.label }}</span>
-              <div class="btn-arrow"></div>
-            </div>
-          </a-tooltip>
-          <div v-if="showThinkPopup" class="think-popup" @click.stop>
-            <div
-              v-for="item in thinkLevelOptions"
-              :key="item.key"
-              class="think-popup-item"
-              :class="{ active: selectedThinkLevel.key === item.key }"
-              @click="handleSelectThinkLevel(item)"
-            >
-              <div class="think-item-main">
-                <span class="think-item-label">{{ item.label }}</span>
-                <span class="think-item-desc">{{ item.desc }}</span>
-              </div>
-              <div class="think-item-check" :class="{ visible: selectedThinkLevel.key === item.key }"></div>
-            </div>
-          </div>
-        </div>
-        <div class="action-divider"></div>
-        <div class="btn-send" :class="{ disabled: !streaming && (!modelValue.trim() || loading) }" @click="handleSendClick">
-          <div class="send-icon" :class="{ 'stop-icon': streaming }"></div>
-        </div>
-      </div>
-      <!-- 会话列表弹框 -->
-      <div v-if="showSessionPopup" ref="sessionPopupRef" class="session-popup" @click.stop @pointerdown.stop>
-        <div class="session-popup-header">
-          <span>选择会话</span>
-          <span class="session-count">{{ selectedSessions.length }}/5</span>
-        </div>
-        <div class="session-list">
-          <div
-            v-for="(task, index) in filteredSessionList"
-            :key="task.id"
-            class="session-item"
-            :class="{
-              active: index === activeSessionIndex,
-              selected: isSessionSelected(task.id),
-              disabled: (selectedSessions.length >= 5 && !isSessionSelected(task.id)) || task.id === props.currentTaskId,
-            }"
-            @click="handleSelectSession(task)"
-          >
-            <div class="session-checkbox" :class="{ checked: isSessionSelected(task.id) }">
-              <span v-if="isSessionSelected(task.id)" class="check-mark">✓</span>
-            </div>
-            <div class="session-item-icon"></div>
-            <span class="session-item-name">{{ task.name }}</span>
-          </div>
-          <div v-if="filteredSessionList.length === 0" class="session-empty">暂无匹配会话</div>
-        </div>
-        <div class="session-popup-footer">
-          <span v-if="selectedSessions.length >= 5" class="max-hint">最多选择5个会话</span>
-          <span v-else>点击选择会话(最多5个)</span>
-        </div>
-      </div>
-      <!-- 技能列表弹框 -->
-      <div v-if="showSkillPopup" ref="skillPopupRef" class="skill-popup" @click.stop @pointerdown.stop>
-        <div class="skill-popup-header">
-          <span>选择技能</span>
-          <span class="skill-count">0/1</span>
-        </div>
-        <div class="skill-list">
-          <div
-            v-for="(skill, index) in filteredSkillList"
-            :key="skill.name"
-            class="skill-item"
-            :class="{
-              active: index === activeSkillIndex,
-              selected: isSkillSelected(skill.name),
-            }"
-            @click="handleSelectSkill(skill)"
-          >
-            <div class="skill-item-checkbox" :class="{ checked: isSkillSelected(skill.name) }">
-              <span v-if="isSkillSelected(skill.name)" class="check-mark">✓</span>
-            </div>
-            <div class="skill-item-icon"></div>
-            <span class="skill-item-name">{{ skill.display_name }}</span>
-          </div>
-          <div v-if="filteredSkillList.length === 0" class="skill-empty">暂无匹配技能</div>
-        </div>
-        <div class="skill-popup-footer">
-          <span>点击选择技能(仅可选1个)</span>
-        </div>
-      </div>
-    </div>
-  </div>
-</template>
-
-<script setup lang="ts">
-  import { ref, computed, watch, nextTick, onMounted } from 'vue';
-  import { onClickOutside } from '@vueuse/core';
-  import { CloseOutlined } from '@ant-design/icons-vue';
-  import { formatFileSize, getFileIconVar } from './utils';
-  import { getSkills } from '../../api';
-  import type { UsageByModel } from '../../api';
-  import type { AttachedFile, Task } from './types';
-
-  const props = withDefaults(
-    defineProps<{
-      modelValue: string;
-      pendingFile: AttachedFile | null;
-      loading: boolean;
-      streaming?: boolean;
-      contextUsed?: number;
-      contextMax?: number;
-      contextPercent?: number;
-      contextBreakdown?: { messages: number; mcp: number; skills: number; system_prompt: number; other: number };
-      usageLimit?: number;
-      usageSpent?: number;
-      usageRemaining?: number;
-      usageByModel?: UsageByModel[];
-      initialThinkLevel?: string;
-      initialEditMode?: string;
-      taskList?: Task[];
-      currentTaskId?: string;
-      // 【改动 M3】是否管理员(由 AiAssistantModal 透传);可选 + 默认 false = 值缺失按非管理员处理
-      isAdmin?: boolean;
-    }>(),
-    {
-      contextUsed: 0,
-      contextMax: 1000000,
-      contextPercent: 0,
-      streaming: false,
-      contextBreakdown: () => ({ messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 }),
-      usageLimit: 1000,
-      usageSpent: 0,
-      usageRemaining: 0,
-      usageByModel: () => [],
-      initialThinkLevel: 'off',
-      initialEditMode: 'full',
-      taskList: () => [],
-      currentTaskId: '',
-      isAdmin: false,
-    }
-  );
-
-  const emit = defineEmits<{
-    (e: 'update:modelValue', value: string): void;
-    (e: 'send'): void;
-    (e: 'stop'): void;
-    (e: 'file-upload'): void;
-    (e: 'file-change', event: Event): void;
-    (e: 'remove-pending-file'): void;
-    (e: 'pending-file-preview', file: AttachedFile): void;
-    (e: 'model-change', modelKey: string): void;
-    (e: 'think-level-change', levelKey: string): void;
-    (e: 'edit-mode-change', modeKey: string): void;
-    (e: 'update:selectedSessions', sessions: Array<{ id: string; name: string }>): void;
-    (e: 'update:selectedSkill', skill: { name: string; display_name: string } | null): void;
-  }>();
-
-  const handleSelectModel = (item: ModelOption) => {
-    selectedModel.value = item;
-    showModelPopup.value = false;
-    emit('model-change', item.key);
-  };
-
-  const fileInputRef = ref<HTMLInputElement>();
-  const popupRef = ref<HTMLElement>();
-  const showPopup = ref(false);
-
-  // 上下文容量
-  const contextPopupRef = ref<HTMLElement>();
-  const showContextPopup = ref(false);
-
-  const contextPercent = computed(() => {
-    return props.contextPercent;
-  });
-
-  const contextCircumference = computed(() => 2 * Math.PI * 13);
-  const contextOffset = computed(() => contextCircumference.value * (1 - contextPercent.value / 100));
-
-  const contextColor = computed(() => {
-    if (contextPercent.value >= 90) return '#ff4d4f';
-    if (contextPercent.value >= 70) return '#faad14';
-    return '#52c41a';
-  });
-
-  const contextDisplayUsed = computed(() => {
-    if (props.contextUsed >= 10000) return (props.contextUsed / 10000).toFixed(1) + '万';
-    return props.contextUsed.toLocaleString();
-  });
-
-  const contextDisplayMax = computed(() => {
-    if (props.contextMax >= 10000) return (props.contextMax / 10000).toFixed(0) + '万';
-    return props.contextMax.toLocaleString();
-  });
-
-  const formatTokens = (tokens: number) => {
-    if (tokens >= 10000) return (tokens / 10000).toFixed(1) + '万';
-    return tokens.toLocaleString();
-  };
-
-  const toggleContextPopup = () => {
-    showContextPopup.value = !showContextPopup.value;
-  };
-
-  onClickOutside(contextPopupRef, () => {
-    showContextPopup.value = false;
-  });
-
-  // 今日用量
-  const usagePopupRef = ref<HTMLElement>();
-  const showUsagePopup = ref(false);
-
-  const usagePercent = computed(() => {
-    if (!props.usageLimit) return 0;
-    return Math.min(100, Math.round((props.usageSpent / props.usageLimit) * 100));
-  });
-
-  const usageCircumference = computed(() => 2 * Math.PI * 13);
-  const usageOffset = computed(() => usageCircumference.value * (1 - usagePercent.value / 100));
-
-  const usageColor = computed(() => {
-    if (usagePercent.value >= 90) return '#ff4d4f';
-    if (usagePercent.value >= 70) return '#faad14';
-    return '#52c41a';
-  });
-
-  const usageSpentTokens = computed(() => formatTokens(props.usageSpent));
-  const usageLimitTokens = computed(() => formatTokens(props.usageLimit));
-  const usageRemainingTokens = computed(() => formatTokens(Math.max(0, props.usageRemaining ?? props.usageLimit - props.usageSpent)));
-
-  const toggleUsagePopup = () => {
-    showUsagePopup.value = !showUsagePopup.value;
-  };
-
-  onClickOutside(usagePopupRef, () => {
-    showUsagePopup.value = false;
-  });
-
-  // 模型选择
-  const modelPopupRef = ref<HTMLElement>();
-  const showModelPopup = ref(false);
-
-  interface ModelOption {
-    key: string;
-    label: string;
-    desc: string;
-  }
-
-  const modelOptions: ModelOption[] = [
-    { key: 'deepseek-v4-pro', label: 'DeepSeek-V4-Pro', desc: '默认模型,综合能力强' },
-    { key: 'deepseek-v4', label: 'DeepSeek-V4', desc: '轻量快速,适合简单任务' },
-    { key: 'gpt-4o', label: 'GPT-4o', desc: 'OpenAI 多模态模型' },
-    { key: 'gpt-4o-mini', label: 'GPT-4o Mini', desc: 'OpenAI 轻量模型' },
-    { key: 'claude-sonnet-4', label: 'Claude Sonnet 4', desc: 'Anthropic 高性能模型' },
-    { key: 'qwen-max', label: '通义千问 Max', desc: '阿里云旗舰模型' },
-  ];
-
-  const selectedModel = ref<ModelOption>(modelOptions[0]);
-
-  const toggleModelPopup = () => {
-    showModelPopup.value = !showModelPopup.value;
-  };
-
-  onClickOutside(modelPopupRef, () => {
-    showModelPopup.value = false;
-  });
-
-  // 思考级别
-  const thinkPopupRef = ref<HTMLElement>();
-  const showThinkPopup = ref(false);
-
-  interface ThinkLevelOption {
-    key: string;
-    label: string;
-    desc: string;
-  }
-
-  const thinkLevelOptions: ThinkLevelOption[] = [
-    { key: 'off', label: '关闭', desc: '不使用深度思考,响应最快' },
-    { key: 'high', label: '高', desc: '深度思考,平衡速度与质量' },
-    { key: 'highest', label: '最高', desc: '极致思考,质量最高但较慢' },
-  ];
-
-  const selectedThinkLevel = ref<ThinkLevelOption>(thinkLevelOptions[1]);
-
-  watch(
-    () => props.initialThinkLevel,
-    (level) => {
-      const found = thinkLevelOptions.find((opt) => opt.key === level);
-      if (found) selectedThinkLevel.value = found;
-    },
-    { immediate: true }
-  );
-
-  const toggleThinkPopup = () => {
-    showThinkPopup.value = !showThinkPopup.value;
-  };
-
-  const handleSelectThinkLevel = (item: ThinkLevelOption) => {
-    selectedThinkLevel.value = item;
-    showThinkPopup.value = false;
-    emit('think-level-change', item.key);
-  };
-
-  onClickOutside(thinkPopupRef, () => {
-    showThinkPopup.value = false;
-  });
-
-  const editModePopupRef = ref<HTMLElement>();
-  const showEditModePopup = ref(false);
-
-  interface EditModeOption {
-    key: string;
-    label: string;
-    desc: string;
-  }
-
-  const editModeOptions: EditModeOption[] = [
-    // { key: 'confirm', label: '变更前确认', desc: '改文件前先问我' },
-    // { key: 'auto', label: '自动编辑', desc: '自动编辑文件' },
-    { key: 'plan', label: '计划模式', desc: '编辑前先出计划' },
-    { key: 'full', label: '完全访问', desc: '减少确认次数' },
-  ];
-
-  const selectedEditMode = ref<EditModeOption>(editModeOptions[1]);
-
-  watch(
-    () => props.initialEditMode,
-    (mode) => {
-      const found = editModeOptions.find((opt) => opt.key === mode);
-      if (found) selectedEditMode.value = found;
-    },
-    { immediate: true }
-  );
-
-  onClickOutside(editModePopupRef, () => {
-    showEditModePopup.value = false;
-  });
-
-  const toggleEditModePopup = () => {
-    showEditModePopup.value = !showEditModePopup.value;
-  };
-
-  const handleSelectEditMode = (item: EditModeOption) => {
-    selectedEditMode.value = item;
-    showEditModePopup.value = false;
-    emit('edit-mode-change', item.key);
-  };
-
-  onClickOutside(popupRef, () => {
-    showPopup.value = false;
-  });
-
-  const togglePopup = () => {
-    showPopup.value = !showPopup.value;
-  };
-
-  const handleAddAttachment = () => {
-    showPopup.value = false;
-    emit('file-upload');
-  };
-
-  const handleInsertMention = () => {
-    showPopup.value = false;
-  };
-
-  // 会话列表弹框
-  const textareaRef = ref<InstanceType<any>>();
-  const sessionPopupRef = ref<HTMLElement>();
-  const showSessionPopup = ref(false);
-  const activeSessionIndex = ref(0);
-  const selectedSessions = ref<Array<{ id: string; name: string }>>([]);
-  const sessionPopupManual = ref(false);
-  let isRemovingTag = false;
-  let sessionJustSelected = false;
-
-  // 技能列表弹框
-  interface SkillItem {
-    name: string;
-    display_name: string;
-    path: string;
-    description: string;
-    enabled: boolean;
-    agents: string[];
-    has_skill_md: boolean;
-  }
-
-  // Module-level cache for skill list to avoid redundant API calls across component mounts
-  let skillListCache: SkillItem[] | null = null;
-  let skillListFetchPromise: Promise<void> | null = null;
-
-  const skillPopupRef = ref<HTMLElement>();
-  const showSkillPopup = ref(false);
-  const activeSkillIndex = ref(0);
-  const selectedSkill = ref<{ name: string; display_name: string } | null>(null);
-  const skillPopupManual = ref(false);
-  const skillList = ref<SkillItem[]>([]);
-  let skillJustSelected = false;
-
-  const fetchSkillList = async () => {
-    // Use cached data if available
-    if (skillListCache) {
-      skillList.value = skillListCache;
-      return;
-    }
-    // Deduplicate concurrent requests
-    if (skillListFetchPromise) {
-      await skillListFetchPromise;
-      skillList.value = skillListCache || [];
-      return;
-    }
-    skillListFetchPromise = (async () => {
-      try {
-        const res = await getSkills();
-        skillList.value = res?.skills || [];
-        skillListCache = skillList.value;
-      } catch {
-        skillList.value = [];
-      } finally {
-        skillListFetchPromise = null;
-      }
-    })();
-    await skillListFetchPromise;
-  };
-
-  onMounted(() => {
-    fetchSkillList();
-  });
-
-  const isSkillSelected = (name: string) => {
-    return selectedSkill.value?.name === name;
-  };
-
-  const isSessionSelected = (sessionId: string) => {
-    return selectedSessions.value.some((s) => s.id === sessionId);
-  };
-
-  const sessionSearchText = computed(() => {
-    const val = props.modelValue || '';
-    const hashIndex = val.lastIndexOf('#');
-    if (hashIndex === -1) return '';
-    const afterHash = val.substring(hashIndex + 1);
-    if (afterHash.includes(' ')) return '';
-    return afterHash.toLowerCase();
-  });
-
-  const filteredSessionList = computed(() => {
-    const list = props.taskList.filter((t) => t.sessionId);
-    if (!sessionSearchText.value) return list;
-    return list.filter((t) => t.name.toLowerCase().includes(sessionSearchText.value));
-  });
-
-  watch(filteredSessionList, () => {
-    activeSessionIndex.value = 0;
-  });
-
-  watch(showSessionPopup, (val) => {
-    if (val) activeSessionIndex.value = 0;
-  });
-
-  const skillSearchText = computed(() => {
-    const val = props.modelValue || '';
-    const dollarIndex = val.lastIndexOf('$');
-    if (dollarIndex === -1) return '';
-    const afterDollar = val.substring(dollarIndex + 1);
-    if (afterDollar.includes(' ')) return '';
-    return afterDollar.toLowerCase();
-  });
-
-  const filteredSkillList = computed(() => {
-    if (!skillSearchText.value) return skillList.value;
-    return skillList.value.filter(
-      (s) => s.display_name.toLowerCase().includes(skillSearchText.value) || s.name.toLowerCase().includes(skillSearchText.value)
-    );
-  });
-
-  watch(filteredSkillList, () => {
-    activeSkillIndex.value = 0;
-  });
-
-  watch(showSkillPopup, (val) => {
-    if (val) activeSkillIndex.value = 0;
-  });
-
-  const handleSessionKeydown = (e: KeyboardEvent) => {
-    if (showSkillPopup.value && filteredSkillList.value.length > 0) {
-      if (e.key === 'ArrowDown') {
-        e.preventDefault();
-        activeSkillIndex.value = (activeSkillIndex.value + 1) % filteredSkillList.value.length;
-      } else if (e.key === 'ArrowUp') {
-        e.preventDefault();
-        activeSkillIndex.value = (activeSkillIndex.value - 1 + filteredSkillList.value.length) % filteredSkillList.value.length;
-      } else if (e.key === 'Enter') {
-        e.preventDefault();
-        const skill = filteredSkillList.value[activeSkillIndex.value];
-        if (skill) {
-          skillJustSelected = true;
-          handleSelectSkill(skill);
-        }
-      }
-      return;
-    }
-    if (showSessionPopup.value && filteredSessionList.value.length > 0) {
-      if (e.key === 'ArrowDown') {
-        e.preventDefault();
-        activeSessionIndex.value = (activeSessionIndex.value + 1) % filteredSessionList.value.length;
-      } else if (e.key === 'ArrowUp') {
-        e.preventDefault();
-        activeSessionIndex.value = (activeSessionIndex.value - 1 + filteredSessionList.value.length) % filteredSessionList.value.length;
-      } else if (e.key === 'Enter') {
-        e.preventDefault();
-        const task = filteredSessionList.value[activeSessionIndex.value];
-        if (task) {
-          sessionJustSelected = true;
-          handleSelectSession(task);
-        }
-      }
-    }
-  };
-
-  const handleInsertSession = () => {
-    showPopup.value = false;
-    sessionPopupManual.value = true;
-    showSessionPopup.value = true;
-    const cur = props.modelValue || '';
-    const needsSpace = cur && !cur.endsWith(' ') ? ' ' : '';
-    emit('update:modelValue', `${cur}${needsSpace}#`);
-    nextTick(() => {
-      const el = textareaRef.value?.$el?.querySelector('textarea') || textareaRef.value?.$el;
-      if (el) el.focus();
-    });
-  };
-
-  const handleSelectSession = (task: Task) => {
-    if (task.id === props.currentTaskId) return;
-    const idx = selectedSessions.value.findIndex((s) => s.id === task.id);
-    if (idx >= 0) {
-      selectedSessions.value.splice(idx, 1);
-    } else {
-      if (selectedSessions.value.length >= 5) return;
-      selectedSessions.value.push({ id: task.id, name: task.name });
-    }
-    removeSearchTag();
-    emit('update:selectedSessions', [...selectedSessions.value]);
-  };
-
-  const handleInsertSkill = () => {
-    showPopup.value = false;
-    showSessionPopup.value = false;
-    skillPopupManual.value = true;
-    showSkillPopup.value = true;
-    const cur = props.modelValue || '';
-    const needsSpace = cur && !cur.endsWith(' ') ? ' ' : '';
-    emit('update:modelValue', `${cur}${needsSpace}$`);
-    nextTick(() => {
-      const el = textareaRef.value?.$el?.querySelector('textarea') || textareaRef.value?.$el;
-      if (el) el.focus();
-    });
-  };
-
-  const handleSelectSkill = (skill: SkillItem) => {
-    selectedSkill.value = { name: skill.name, display_name: skill.display_name };
-    removeSkillSearchTag();
-    showSkillPopup.value = false;
-    skillPopupManual.value = false;
-    emit('update:selectedSkill', { name: skill.name, display_name: skill.display_name });
-  };
-
-  const removeSkillSearchTag = () => {
-    isRemovingTag = true;
-    const val = props.modelValue || '';
-    const dollarIndex = val.lastIndexOf('$');
-    if (dollarIndex === -1) {
-      isRemovingTag = false;
-      return;
-    }
-    emit('update:modelValue', val.substring(0, dollarIndex).trimEnd());
-    nextTick(() => {
-      isRemovingTag = false;
-    });
-  };
-
-  const removeSkill = () => {
-    selectedSkill.value = null;
-    emit('update:selectedSkill', null);
-  };
-
-  const removeSearchTag = () => {
-    isRemovingTag = true;
-    const val = props.modelValue || '';
-    const hashIndex = val.lastIndexOf('#');
-    if (hashIndex === -1) {
-      isRemovingTag = false;
-      return;
-    }
-    emit('update:modelValue', val.substring(0, hashIndex).trimEnd());
-    nextTick(() => {
-      isRemovingTag = false;
-    });
-  };
-
-  watch(
-    () => props.modelValue,
-    (newVal, oldVal) => {
-      if (isRemovingTag) return;
-      if (oldVal && !newVal) {
-        selectedSessions.value = [];
-        selectedSkill.value = null;
-      }
-    }
-  );
-
-  const removeSession = (sessionId: string) => {
-    selectedSessions.value = selectedSessions.value.filter((s) => s.id !== sessionId);
-    emit('update:selectedSessions', [...selectedSessions.value]);
-  };
-
-  const handleTextareaInput = (val: string) => {
-    emit('update:modelValue', val);
-
-    // 技能弹框 $ 检测
-    if (!skillPopupManual.value) {
-      const dollarIndex = val.lastIndexOf('$');
-      if (dollarIndex === -1) {
-        showSkillPopup.value = false;
-      } else {
-        const afterDollar = val.substring(dollarIndex + 1);
-        if (afterDollar.includes(' ')) {
-          showSkillPopup.value = false;
-        } else {
-          showSkillPopup.value = true;
-          showSessionPopup.value = false;
-        }
-      }
-    }
-
-    // 会话弹框 # 检测
-    if (!sessionPopupManual.value) {
-      const hashIndex = val.lastIndexOf('#');
-      if (hashIndex === -1) {
-        showSessionPopup.value = false;
-      } else {
-        const afterHash = val.substring(hashIndex + 1);
-        if (afterHash.includes(' ')) {
-          showSessionPopup.value = false;
-        } else {
-          showSessionPopup.value = true;
-          showSkillPopup.value = false;
-        }
-      }
-    }
-  };
-
-  const handleBackspace = (e: KeyboardEvent) => {
-    const val = props.modelValue || '';
-    if (val) return;
-    if (selectedSkill.value) {
-      e.preventDefault();
-      selectedSkill.value = null;
-      emit('update:selectedSkill', null);
-    } else if (selectedSessions.value.length > 0) {
-      e.preventDefault();
-      selectedSessions.value.pop();
-      emit('update:selectedSessions', [...selectedSessions.value]);
-    }
-  };
-
-  onClickOutside(sessionPopupRef, () => {
-    showSessionPopup.value = false;
-    sessionPopupManual.value = false;
-  });
-
-  onClickOutside(skillPopupRef, () => {
-    showSkillPopup.value = false;
-    skillPopupManual.value = false;
-  });
-
-  const handleInsertCommand = () => {
-    showPopup.value = false;
-  };
-
-  const handleEnterKey = (e: KeyboardEvent) => {
-    if (showSessionPopup.value || showSkillPopup.value) return;
-    if (sessionJustSelected) {
-      sessionJustSelected = false;
-      return;
-    }
-    if (skillJustSelected) {
-      skillJustSelected = false;
-      return;
-    }
-    if (!e.shiftKey && props.modelValue.trim() && !props.loading) {
-      emit('send');
-    }
-  };
-
-  const handleSend = () => {
-    if (props.modelValue.trim() && !props.loading) {
-      emit('send');
-    }
-  };
-
-  const handleSendClick = () => {
-    if (props.streaming) {
-      emit('stop');
-      return;
-    }
-    handleSend();
-  };
-
-  defineExpose({ fileInputRef });
-</script>
-
-<style scoped lang="less">
-  .chat-input-area {
-    position: relative;
-    padding: 5px 10px;
-    flex-shrink: 0;
-    background-color: #002f4a;
-    border: 1px solid #005482;
-    border-radius: 4px;
-    margin: 10px 16px 16px 16px;
-
-    .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;
-        cursor: pointer;
-
-        &: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: space-between;
-      align-items: center;
-      margin-top: 12px;
-
-      .mode-icon-confirm {
-        background-image: var(--img-chat-mode-confirm-icon);
-      }
-      .mode-icon-auto {
-        background-image: var(--img-chat-mode-auto-icon);
-      }
-      .mode-icon-plan {
-        background-image: var(--img-chat-mode-plan-icon);
-      }
-      .mode-icon-full {
-        background-image: var(--img-chat-mode-full-icon);
-      }
-
-      .action-left,
-      .action-right {
-        display: flex;
-        gap: 8px;
-        .action-divider {
-          width: 1px;
-          height: 18px;
-          background: rgba(255, 255, 255, 0.15);
-          align-self: center;
-          flex-shrink: 0;
-        }
-        .btn-icon {
-          width: 14px;
-          height: 14px;
-          background-repeat: no-repeat;
-          background-size: 100% 100%;
-          flex-shrink: 0;
-        }
-        .think-icon {
-          background-image: var(--img-chat-thinking-icon);
-        }
-        .btn-text {
-          font-size: 12px;
-          color: #e0e6ed;
-          white-space: nowrap;
-        }
-        .btn-arrow {
-          width: 0;
-          height: 0;
-          border-left: 4px solid transparent;
-          border-right: 4px solid transparent;
-          border-top: 4px solid #8b949e;
-          flex-shrink: 0;
-        }
-        .btn-upload-wrapper {
-          position: relative;
-        }
-        .btn-upload {
-          width: 30px;
-          height: 30px;
-          border-radius: 6px;
-          display: flex;
-          align-items: center;
-          justify-content: center;
-          cursor: pointer;
-        }
-        .plus-icon {
-          width: 20px;
-          height: 20px;
-          background-image: var(--img-chat-input-bg);
-          background-repeat: no-repeat;
-          background-size: 100% 100%;
-          cursor: pointer;
-        }
-        .upload-popup {
-          position: absolute;
-          bottom: 38px;
-          left: 0;
-          background: #0a2a3f;
-          border: 1px solid #1a4a6f;
-          border-radius: 8px;
-          min-width: 160px;
-          z-index: 1000;
-          box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-
-          .popup-item {
-            display: flex;
-            align-items: center;
-            gap: 10px;
-            padding: 9px 16px;
-            cursor: pointer;
-            transition: background 0.2s;
-            border-radius: 8px;
-
-            &:hover {
-              background: rgba(10, 132, 255, 0.15);
-            }
-
-            .popup-item-icon {
-              width: 18px;
-              height: 18px;
-              background-repeat: no-repeat;
-              background-size: 100% 100%;
-              flex-shrink: 0;
-            }
-
-            .attach-icon {
-              background-image: var(--img-chat-popup-attach-icon);
-            }
-
-            .mention-icon {
-              background-image: var(--img-chat-popup-mention-icon);
-            }
-
-            .session-icon {
-              background-image: var(--img-chat-popup-session-icon);
-            }
-
-            .skill-icon {
-              background-image: var(--img-chat-options-skill-icon);
-            }
-
-            .command-icon {
-              background-image: var(--img-chat-popup-command-icon);
-            }
-
-            .popup-item-text {
-              font-size: 13px;
-              color: #e0e6ed;
-              white-space: nowrap;
-            }
-          }
-        }
-        .btn-edit-mode-wrapper {
-          position: relative;
-        }
-        .btn-edit-mode {
-          height: 28px;
-          padding: 0 10px;
-          border-radius: 6px;
-          display: flex;
-          align-items: center;
-          gap: 4px;
-          cursor: pointer;
-          transition: all 0.2s;
-
-          &:hover {
-            border-color: #3a8fd4;
-            background: rgba(10, 132, 255, 0.1);
-          }
-        }
-        .edit-mode-popup {
-          position: absolute;
-          bottom: 38px;
-          left: 50%;
-          transform: translateX(-50%);
-          background: #0a2a3f;
-          border: 1px solid #1a4a6f;
-          border-radius: 8px;
-          min-width: 200px;
-          z-index: 1000;
-          box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-
-          .edit-mode-item {
-            display: flex;
-            align-items: center;
-            justify-content: space-between;
-            padding: 10px 12px;
-            cursor: pointer;
-            transition: background 0.2s;
-            border-radius: 6px;
-
-            &:hover {
-              background: rgba(10, 132, 255, 0.15);
-            }
-
-            &.active {
-              background: rgba(10, 132, 255, 0.1);
-            }
-
-            .edit-mode-item-icon {
-              width: 18px;
-              height: 18px;
-              background-repeat: no-repeat;
-              background-size: 100% 100%;
-              flex-shrink: 0;
-              margin-right: 10px;
-            }
-
-            .edit-mode-item-main {
-              display: flex;
-              flex-direction: column;
-              gap: 2px;
-
-              .edit-mode-item-label {
-                font-size: 13px;
-                color: #e0e6ed;
-                white-space: nowrap;
-              }
-
-              .edit-mode-item-desc {
-                font-size: 11px;
-                color: #8b949e;
-                white-space: nowrap;
-              }
-            }
-
-            .edit-mode-check {
-              width: 16px;
-              height: 16px;
-              flex-shrink: 0;
-              display: flex;
-              align-items: center;
-              justify-content: center;
-              visibility: hidden;
-
-              &.visible {
-                visibility: visible;
-              }
-
-              &::after {
-                content: '✓';
-                font-size: 12px;
-                color: #3a8fd4;
-              }
-            }
-          }
-        }
-        .btn-context-wrapper {
-          position: relative;
-          display: flex;
-          align-items: center;
-        }
-        .btn-context {
-          width: 30px;
-          height: 30px;
-          display: flex;
-          align-items: center;
-          justify-content: center;
-          cursor: pointer;
-          position: relative;
-          border-radius: 6px;
-          transition: background 0.2s;
-
-          &:hover {
-            background: rgba(10, 132, 255, 0.1);
-          }
-
-          .context-ring {
-            width: 24px;
-            height: 24px;
-            transform: rotate(-90deg);
-
-            .context-ring-bg {
-              fill: none;
-              stroke: rgba(255, 255, 255, 0.1);
-              stroke-width: 3;
-            }
-
-            .context-ring-fill {
-              fill: none;
-              stroke-width: 3;
-              stroke-linecap: round;
-              transition: stroke-dashoffset 0.3s ease;
-            }
-          }
-
-          .context-percent {
-            position: absolute;
-            font-size: 8px;
-            color: #e0e6ed;
-            font-weight: 500;
-            line-height: 1;
-          }
-        }
-        .context-popup {
-          position: absolute;
-          bottom: 42px;
-          left: 50%;
-          transform: translateX(-50%);
-          background: #0a2a3f;
-          border: 1px solid #1a4a6f;
-          border-radius: 8px;
-          min-width: 200px;
-          padding: 14px 16px;
-
-          &::before {
-            content: '';
-            position: absolute;
-            bottom: -12px;
-            left: 0;
-            right: 0;
-            height: 12px;
-          }
-          z-index: 1000;
-          box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-
-          .context-popup-title {
-            font-size: 13px;
-            color: #8b949e;
-            margin-bottom: 8px;
-          }
-
-          .context-popup-info {
-            display: flex;
-            align-items: baseline;
-            gap: 2px;
-            margin-bottom: 10px;
-
-            .context-used {
-              font-size: 18px;
-              font-weight: 600;
-              color: #e0e6ed;
-            }
-
-            .context-sep {
-              font-size: 14px;
-              color: #8b949e;
-            }
-
-            .context-max {
-              font-size: 14px;
-              color: #8b949e;
-            }
-
-            .context-pct {
-              font-size: 13px;
-              color: #8b949e;
-            }
-          }
-
-          .context-popup-bar-track {
-            width: 100%;
-            height: 6px;
-            background: rgba(255, 255, 255, 0.08);
-            border-radius: 3px;
-            overflow: hidden;
-
-            .context-popup-bar-fill {
-              height: 100%;
-              border-radius: 3px;
-              transition: width 0.3s ease;
-            }
-          }
-
-          .context-breakdown {
-            margin-top: 10px;
-            padding-top: 10px;
-            border-top: 1px solid rgba(255, 255, 255, 0.08);
-
-            .breakdown-item {
-              display: flex;
-              justify-content: space-between;
-              align-items: center;
-              padding: 3px 0;
-
-              .breakdown-label {
-                font-size: 12px;
-                color: #8b949e;
-              }
-
-              .breakdown-value {
-                font-size: 12px;
-                color: #e0e6ed;
-              }
-            }
-          }
-        }
-        .btn-usage-wrapper {
-          position: relative;
-          display: flex;
-          align-items: center;
-        }
-        .btn-usage {
-          width: 30px;
-          height: 30px;
-          display: flex;
-          align-items: center;
-          justify-content: center;
-          cursor: pointer;
-          position: relative;
-          border-radius: 6px;
-          transition: background 0.2s;
-
-          &:hover {
-            background: rgba(10, 132, 255, 0.1);
-          }
-
-          .usage-ring {
-            width: 24px;
-            height: 24px;
-            transform: rotate(-90deg);
-
-            .usage-ring-bg {
-              fill: none;
-              stroke: rgba(255, 255, 255, 0.1);
-              stroke-width: 3;
-            }
-
-            .usage-ring-fill {
-              fill: none;
-              stroke-width: 3;
-              stroke-linecap: round;
-              transition: stroke-dashoffset 0.3s ease;
-            }
-          }
-
-          .usage-symbol {
-            position: absolute;
-            font-size: 9px;
-            color: #e0e6ed;
-            font-weight: 600;
-            line-height: 1;
-          }
-        }
-        .usage-popup {
-          position: absolute;
-          bottom: 42px;
-          left: 50%;
-          transform: translateX(-50%);
-          background: #0a2a3f;
-          border: 1px solid #1a4a6f;
-          border-radius: 8px;
-          min-width: 200px;
-          padding: 14px 16px;
-
-          &::before {
-            content: '';
-            position: absolute;
-            bottom: -12px;
-            left: 0;
-            right: 0;
-            height: 12px;
-          }
-          z-index: 1000;
-          box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-
-          .usage-popup-title {
-            font-size: 13px;
-            color: #8b949e;
-            margin-bottom: 8px;
-          }
-
-          .usage-popup-info {
-            display: flex;
-            align-items: baseline;
-            gap: 2px;
-            margin-bottom: 10px;
-
-            .usage-spent {
-              font-size: 18px;
-              font-weight: 600;
-              color: #e0e6ed;
-            }
-
-            .usage-sep {
-              font-size: 14px;
-              color: #8b949e;
-            }
-
-            .usage-limit {
-              font-size: 14px;
-              color: #8b949e;
-            }
-
-            .usage-pct {
-              font-size: 13px;
-              color: #8b949e;
-            }
-          }
-
-          .usage-popup-bar-track {
-            width: 100%;
-            height: 6px;
-            background: rgba(255, 255, 255, 0.08);
-            border-radius: 3px;
-            overflow: hidden;
-
-            .usage-popup-bar-fill {
-              height: 100%;
-              border-radius: 3px;
-              transition: width 0.3s ease;
-            }
-          }
-
-          .usage-remaining {
-            margin-top: 6px;
-            font-size: 14px;
-            color: #8b949e;
-          }
-
-          .usage-breakdown {
-            margin-top: 10px;
-            padding-top: 10px;
-            border-top: 1px solid rgba(255, 255, 255, 0.08);
-
-            .usage-model-item {
-              padding: 6px 0;
-              border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
-
-              &:last-child {
-                border-bottom: none;
-                padding-bottom: 0;
-              }
-
-              .usage-model-header {
-                display: flex;
-                justify-content: space-between;
-                align-items: center;
-                gap: 12px;
-                margin-bottom: 4px;
-
-                .usage-model-name {
-                  font-size: 14px;
-                  font-weight: 600;
-                  color: #e0e6ed;
-                  overflow: hidden;
-                  text-overflow: ellipsis;
-                  white-space: nowrap;
-                }
-
-                .usage-model-cost {
-                  font-size: 14px;
-                  font-weight: 600;
-                  color: #3a8fd4;
-                  white-space: nowrap;
-                  flex-shrink: 0;
-                }
-              }
-
-              .usage-model-row {
-                display: flex;
-                justify-content: space-between;
-                align-items: center;
-                padding: 2px 0;
-
-                .usage-model-label {
-                  font-size: 12px;
-                  color: #8b949e;
-                }
-
-                .usage-model-value {
-                  font-size: 12px;
-                  color: #e0e6ed;
-                }
-              }
-            }
-          }
-        }
-        .btn-model-wrapper {
-          position: relative;
-          display: flex;
-          align-items: center;
-        }
-        .btn-model {
-          height: 28px;
-          padding: 0 10px;
-          border-radius: 6px;
-          display: flex;
-          align-items: center;
-          gap: 4px;
-          cursor: pointer;
-          transition: all 0.2s;
-          border: 1px solid rgba(255, 255, 255, 0.12);
-
-          &:hover {
-            border-color: #3a8fd4;
-            background: rgba(10, 132, 255, 0.1);
-          }
-        }
-        .model-popup {
-          position: absolute;
-          bottom: 38px;
-          left: 50%;
-          transform: translateX(-50%);
-          background: #0a2a3f;
-          border: 1px solid #1a4a6f;
-          border-radius: 8px;
-          min-width: 220px;
-          z-index: 1000;
-          box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-
-          .model-popup-item {
-            display: flex;
-            align-items: center;
-            justify-content: space-between;
-            padding: 10px 12px;
-            cursor: pointer;
-            transition: background 0.2s;
-            border-radius: 6px;
-
-            &:hover {
-              background: rgba(10, 132, 255, 0.15);
-            }
-
-            &.active {
-              background: rgba(10, 132, 255, 0.1);
-            }
-
-            .model-item-main {
-              display: flex;
-              flex-direction: column;
-              gap: 2px;
-
-              .model-item-label {
-                font-size: 13px;
-                color: #e0e6ed;
-                white-space: nowrap;
-              }
-
-              .model-item-desc {
-                font-size: 11px;
-                color: #8b949e;
-                white-space: nowrap;
-              }
-            }
-
-            .model-item-check {
-              width: 16px;
-              height: 16px;
-              flex-shrink: 0;
-              display: flex;
-              align-items: center;
-              justify-content: center;
-              visibility: hidden;
-
-              &.visible {
-                visibility: visible;
-              }
-
-              &::after {
-                content: '✓';
-                font-size: 12px;
-                color: #3a8fd4;
-              }
-            }
-          }
-        }
-        .btn-think-wrapper {
-          position: relative;
-          display: flex;
-          align-items: center;
-        }
-        .btn-think {
-          height: 28px;
-          padding: 0 10px;
-          border-radius: 6px;
-          display: flex;
-          align-items: center;
-          gap: 4px;
-          cursor: pointer;
-          transition: all 0.2s;
-
-          &:hover {
-            border-color: #3a8fd4;
-            background: rgba(10, 132, 255, 0.1);
-          }
-        }
-        .think-popup {
-          position: absolute;
-          bottom: 38px;
-          left: 50%;
-          transform: translateX(-50%);
-          background: #0a2a3f;
-          border: 1px solid #1a4a6f;
-          border-radius: 8px;
-          min-width: 200px;
-          z-index: 1000;
-          box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-
-          .think-popup-item {
-            display: flex;
-            align-items: center;
-            justify-content: space-between;
-            padding: 10px 12px;
-            cursor: pointer;
-            transition: background 0.2s;
-            border-radius: 6px;
-
-            &:hover {
-              background: rgba(10, 132, 255, 0.15);
-            }
-
-            &.active {
-              background: rgba(10, 132, 255, 0.1);
-            }
-
-            .think-item-main {
-              display: flex;
-              flex-direction: column;
-              gap: 2px;
-
-              .think-item-label {
-                font-size: 13px;
-                color: #e0e6ed;
-                white-space: nowrap;
-              }
-
-              .think-item-desc {
-                font-size: 11px;
-                color: #8b949e;
-                white-space: nowrap;
-              }
-            }
-
-            .think-item-check {
-              width: 16px;
-              height: 16px;
-              flex-shrink: 0;
-              display: flex;
-              align-items: center;
-              justify-content: center;
-              visibility: hidden;
-
-              &.visible {
-                visibility: visible;
-              }
-
-              &::after {
-                content: '✓';
-                font-size: 12px;
-                color: #3a8fd4;
-              }
-            }
-          }
-        }
-        .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%;
-
-            &.stop-icon {
-              background-image: var(--img-chat-stop-icon);
-            }
-          }
-        }
-      }
-    }
-
-    .session-chips {
-      display: flex;
-      flex-wrap: wrap;
-      gap: 6px;
-      margin-bottom: 5px;
-
-      .session-chip {
-        display: inline-flex;
-        align-items: center;
-        gap: 4px;
-        padding: 3px 6px 3px 4px;
-        background: rgba(10, 132, 255, 0.15);
-        border: 1px solid rgba(10, 132, 255, 0.3);
-        border-radius: 4px;
-        font-size: 12px;
-        color: #e0e6ed;
-        cursor: default;
-
-        .session-chip-icon {
-          width: 16px;
-          height: 16px;
-          background-image: var(--img-chat-popup-session-icon);
-          background-repeat: no-repeat;
-          background-size: 100% 100%;
-          flex-shrink: 0;
-        }
-
-        .session-chip-name {
-          max-width: 150px;
-          overflow: hidden;
-          text-overflow: ellipsis;
-          white-space: nowrap;
-        }
-
-        .session-chip-remove {
-          font-size: 10px;
-          color: #8b949e;
-          cursor: pointer;
-          padding: 0 2px;
-          transition: color 0.2s;
-
-          &:hover {
-            color: #ff4d4f;
-          }
-        }
-      }
-    }
-
-    .skill-chips {
-      display: flex;
-      flex-wrap: wrap;
-      gap: 6px;
-      margin-bottom: 5px;
-
-      .skill-chip {
-        display: inline-flex;
-        align-items: center;
-        gap: 4px;
-        padding: 3px 6px 3px 4px;
-        background: rgba(10, 132, 255, 0.15);
-        border: 1px solid rgba(10, 132, 255, 0.3);
-        border-radius: 4px;
-        font-size: 12px;
-        color: #e0e6ed;
-        cursor: default;
-
-        .skill-chip-icon {
-          width: 16px;
-          height: 16px;
-          background-image: var(--img-chat-options-skill-icon);
-          background-repeat: no-repeat;
-          background-size: 100% 100%;
-          flex-shrink: 0;
-        }
-
-        .skill-chip-name {
-          max-width: 150px;
-          overflow: hidden;
-          text-overflow: ellipsis;
-          white-space: nowrap;
-        }
-
-        .skill-chip-remove {
-          font-size: 10px;
-          color: #8b949e;
-          cursor: pointer;
-          padding: 0 2px;
-          transition: color 0.2s;
-
-          &:hover {
-            color: #ff4d4f;
-          }
-        }
-      }
-    }
-
-    .session-popup {
-      position: absolute;
-      bottom: 100%;
-      left: 0;
-      right: 0;
-      margin-bottom: 8px;
-      background: #0a2a3f;
-      border: 1px solid #1a4a6f;
-      border-radius: 8px;
-      max-height: 300px;
-      display: flex;
-      flex-direction: column;
-      z-index: 1000;
-      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-
-      .session-popup-header {
-        display: flex;
-        justify-content: space-between;
-        align-items: center;
-        padding: 10px 14px 8px;
-        font-size: 13px;
-        color: #e0e6ed;
-        border-bottom: 1px solid rgba(63, 80, 106, 0.3);
-        flex-shrink: 0;
-
-        .session-count {
-          font-size: 12px;
-          color: #3a8fd4;
-          font-weight: 500;
-        }
-      }
-
-      .session-list {
-        flex: 1;
-        overflow-y: auto;
-        padding: 6px;
-
-        .session-item {
-          display: flex;
-          align-items: center;
-          gap: 10px;
-          padding: 8px 10px;
-          cursor: pointer;
-          border-radius: 6px;
-          transition: background 0.2s;
-
-          &:hover {
-            background: rgba(10, 132, 255, 0.15);
-          }
-
-          &.active {
-            background: rgba(10, 132, 255, 0.2);
-          }
-
-          &.selected {
-            background: rgba(10, 132, 255, 0.12);
-          }
-
-          &.disabled {
-            opacity: 0.4;
-            cursor: not-allowed;
-
-            &:hover {
-              background: transparent;
-            }
-          }
-
-          .session-checkbox {
-            width: 16px;
-            height: 16px;
-            border: 1.5px solid rgba(255, 255, 255, 0.25);
-            border-radius: 3px;
-            flex-shrink: 0;
-            display: flex;
-            align-items: center;
-            justify-content: center;
-            transition: all 0.2s;
-
-            &.checked {
-              background: #3a8fd4;
-              border-color: #3a8fd4;
-            }
-
-            .check-mark {
-              font-size: 11px;
-              color: #fff;
-              line-height: 1;
-            }
-          }
-
-          .session-item-icon {
-            width: 18px;
-            height: 18px;
-            background-image: var(--img-chat-popup-session-icon);
-            background-repeat: no-repeat;
-            background-size: 100% 100%;
-            flex-shrink: 0;
-          }
-
-          .session-item-name {
-            font-size: 13px;
-            color: #e0e6ed;
-            overflow: hidden;
-            text-overflow: ellipsis;
-            white-space: nowrap;
-          }
-        }
-
-        .session-empty {
-          text-align: center;
-          color: #8b949e;
-          font-size: 13px;
-          padding: 20px 0;
-        }
-      }
-
-      .session-popup-footer {
-        padding: 8px 14px;
-        font-size: 12px;
-        color: #8b949e;
-        text-align: center;
-        border-top: 1px solid rgba(63, 80, 106, 0.3);
-        flex-shrink: 0;
-
-        .max-hint {
-          color: #faad14;
-        }
-      }
-    }
-
-    .skill-popup {
-      position: absolute;
-      bottom: 100%;
-      left: 0;
-      right: 0;
-      margin-bottom: 8px;
-      background: #0a2a3f;
-      border: 1px solid #1a4a6f;
-      border-radius: 8px;
-      max-height: 300px;
-      display: flex;
-      flex-direction: column;
-      z-index: 1000;
-      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-
-      .skill-popup-header {
-        display: flex;
-        justify-content: space-between;
-        align-items: center;
-        padding: 10px 14px 8px;
-        font-size: 13px;
-        color: #e0e6ed;
-        border-bottom: 1px solid rgba(63, 80, 106, 0.3);
-        flex-shrink: 0;
-
-        .skill-count {
-          font-size: 12px;
-          color: #3a8fd4;
-          font-weight: 500;
-        }
-      }
-
-      .skill-list {
-        flex: 1;
-        overflow-y: auto;
-        padding: 6px;
-
-        .skill-item {
-          display: flex;
-          align-items: center;
-          gap: 10px;
-          padding: 8px 10px;
-          cursor: pointer;
-          border-radius: 6px;
-          transition: background 0.2s;
-
-          &:hover {
-            background: rgba(10, 132, 255, 0.15);
-          }
-
-          &.active {
-            background: rgba(10, 132, 255, 0.2);
-          }
-
-          &.selected {
-            background: rgba(10, 132, 255, 0.12);
-          }
-
-          .skill-item-checkbox {
-            width: 16px;
-            height: 16px;
-            border: 1.5px solid rgba(255, 255, 255, 0.25);
-            border-radius: 3px;
-            flex-shrink: 0;
-            display: flex;
-            align-items: center;
-            justify-content: center;
-            transition: all 0.2s;
-
-            &.checked {
-              background: #3a8fd4;
-              border-color: #3a8fd4;
-            }
-
-            .check-mark {
-              font-size: 11px;
-              color: #fff;
-              line-height: 1;
-            }
-          }
-
-          .skill-item-icon {
-            width: 18px;
-            height: 18px;
-            background-image: var(--img-chat-options-skill-icon);
-            background-repeat: no-repeat;
-            background-size: 100% 100%;
-            flex-shrink: 0;
-          }
-
-          .skill-item-name {
-            font-size: 13px;
-            color: #e0e6ed;
-            overflow: hidden;
-            text-overflow: ellipsis;
-            white-space: nowrap;
-          }
-        }
-
-        .skill-empty {
-          text-align: center;
-          color: #8b949e;
-          font-size: 13px;
-          padding: 20px 0;
-        }
-      }
-
-      .skill-popup-footer {
-        padding: 8px 14px;
-        font-size: 12px;
-        color: #8b949e;
-        text-align: center;
-        border-top: 1px solid rgba(63, 80, 106, 0.3);
-        flex-shrink: 0;
-      }
-    }
-  }
-</style>

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

@@ -1,2446 +0,0 @@
-<template>
-  <div class="chat-messages-container">
-    <!-- 左侧对话缩略导航 -->
-    <div
-      v-if="qaPairs.length > 0"
-      class="conversation-nav"
-      ref="navRef"
-      @mouseenter="onNavMove"
-      @mousemove="onNavMove"
-      @mouseleave="onNavLeave"
-      @scroll="onNavScroll"
-    >
-      <div
-        v-for="(pair, pIdx) in qaPairs"
-        :key="pIdx"
-        :class="['nav-line', { 'nav-line-hot': nearestLineIndex === pIdx, active: activeMessage && activeMessage === messages[pair.startIndex] }]"
-        :style="{ width: `${navLineWidth(pIdx)}px` }"
-        @click="scrollToPair(pair)"
-      ></div>
-    </div>
-    <Transition name="tooltip-fade">
-      <div v-if="hoveredPair" ref="tooltipRef" class="conversation-tooltip" :style="{ top: `${tooltipTop}px` }">
-        <span class="tooltip-arrow" :style="{ top: `${tooltipArrowTop}px` }"></span>
-        <div v-if="hoveredPair.question" class="tooltip-row">
-          <span class="tooltip-label tooltip-label-q">问</span>
-          <span class="tooltip-text">{{ hoveredPair.question }}</span>
-        </div>
-        <div v-if="hoveredPair.answer" class="tooltip-row">
-          <span class="tooltip-label tooltip-label-a">答</span>
-          <span class="tooltip-text">{{ hoveredPair.answer }}</span>
-        </div>
-      </div>
-    </Transition>
-    <div class="chat-messages" ref="messagesRef">
-      <!-- 新对话缺省图 -->
-      <div v-if="messages.length === 0" class="welcome-empty">
-        <img :src="getCssUrl('--img-chat-welcome-img')" class="welcome-img" />
-      </div>
-      <div
-        v-for="(message, index) in messages"
-        :key="index"
-        :class="['message-wrapper', message.type, { 'answer-hidden': isHiddenAnswer(index) }]"
-        @mouseleave="copiedMessageIndex = -1"
-      >
-        <!-- AI消息 -->
-        <div v-if="message.type === 'ai'" class="ai-message">
-          <div class="message-body">
-            <!-- 思考过程折叠卡片 -->
-            <div v-if="message.thinkingSteps && message.thinkingSteps.length > 0" class="thinking-card">
-              <div class="thinking-duration-bar" @click="toggleThinkingCard(index)">
-                <span v-if="getDurationText(message)" class="thinking-duration">{{ getDurationText(message) }}</span>
-                <div :class="['thinking-arrow', { expanded: !collapsedThinking[index] }]">
-                  <img :src="getCssUrl('--img-chat-arrow-icon')" width="12" height="12" />
-                </div>
-              </div>
-              <Transition name="collapse">
-                <div v-show="!collapsedThinking[index]" class="thinking-collapse-wrap">
-                  <div class="thinking-header" @click="toggleThinkingContent(index)">
-                    <div class="thinking-header-left">
-                      <div class="thinking-icon"></div>
-                      <span class="thinking-title">思考过程</span>
-                    </div>
-                    <div v-if="message.thinkingContent" :class="['thinking-arrow', { expanded: !collapsedThinkingContent[index] }]">
-                      <img :src="getCssUrl('--img-chat-arrow-icon')" width="10" height="10" />
-                    </div>
-                  </div>
-
-                  <!-- thinking_token 思考内容 -->
-                  <Transition name="collapse">
-                    <div v-if="message.thinkingContent && !collapsedThinkingContent[index]" class="thinking-content-body">
-                      {{ message.thinkingContent }}
-                    </div>
-                  </Transition>
-
-                  <div class="thinking-body">
-                    <div
-                      v-for="(group, groupIdx) in groupStepsByAgent(message.thinkingSteps)"
-                      :key="groupIdx"
-                      class="agent-group"
-                      @click="handleAgentGroupClick(index, group)"
-                    >
-                      <div class="agent-group-header">
-                        <div class="agent-group-header-left">
-                          <div class="agent-group-icon"></div>
-                          <span class="agent-group-name">{{ group.agent }}</span>
-                        </div>
-                        <span v-if="getLatestStepMessage(group)" class="agent-group-latest">
-                          {{ getLatestStepMessage(group) }}
-                        </span>
-                        <div class="thinking-arrow agent-arrow">
-                          <img :src="getCssUrl('--img-chat-next-icon')" width="10" height="10" />
-                        </div>
-                      </div>
-                    </div>
-                  </div>
-                </div>
-              </Transition>
-            </div>
-
-            <div
-              v-if="message.content"
-              class="message-text"
-              v-html="
-                renderMarkdown(
-                  message.content,
-                  tableIcons,
-                  message.citations?.items?.length ? Math.max(...message.citations.items.map((i) => i.id)) : undefined
-                )
-              "
-              @click="handleFootnoteClick($event, index)"
-            ></div>
-
-            <!-- 数据来源卡片 citations -->
-            <div v-if="message.citations" class="citations-card">
-              <div v-if="message.citations.model_only" class="citations-header citations-header-simple">
-                <span class="citations-title">本回答依据模型知识生成,仅供参考</span>
-              </div>
-              <template v-else-if="message.citations.items?.length">
-                <div class="citations-header" @click="toggleCitations(index)">
-                  <div class="citations-header-left">
-                    <span class="citations-title">数据来源</span>
-                    <span class="citations-count">{{ message.citations.items.length }}</span>
-                  </div>
-                  <div :class="['thinking-arrow', { expanded: !collapsedCitations[index] }]">
-                    <img :src="getCssUrl('--img-chat-arrow-icon')" width="10" height="10" />
-                  </div>
-                </div>
-                <div v-show="!collapsedCitations[index]" class="citations-body">
-                  <div v-for="item in message.citations.items" :key="item.id" :id="`citation-${index}-${item.id}`" class="citation-item">
-                    <div class="citation-item-row">
-                      <span :class="['citation-badge', `citation-badge-${item.type}`]">{{ item.type_cn }}</span>
-                      <span class="citation-title-text">{{ item.title }}</span>
-                      <span v-if="item.search_url" class="citation-link">知识库检索</span>
-                      <span v-if="item.url" class="citation-link">链接</span>
-                    </div>
-                    <div class="citation-item-attrs">
-                      <span v-if="item.source_system" class="citation-attr">{{ item.source_system }}</span>
-                      <span v-if="item.fetched_at" class="citation-attr">{{ item.fetched_at }}</span>
-                    </div>
-                    <div v-if="item.snippet" class="citation-snippet" :title="item.snippet">{{ item.snippet }}</div>
-                  </div>
-                </div>
-              </template>
-            </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.downloadFiles?.length" class="download-file-cards">
-              <div v-for="(file, fIdx) in message.downloadFiles" :key="fIdx" class="download-file-card" @click="handlePreviewFile(file)">
-                <div class="file-card-info">
-                  <div class="file-card-icon" :style="{ backgroundImage: getFileIconVar(file.filename) }"></div>
-                  <div class="file-card-text">
-                    <span class="file-card-name">{{ file.filename }}</span>
-                    <span class="file-card-hint">点击预览文件</span>
-                  </div>
-                </div>
-                <div class="file-card-actions">
-                  <button class="file-action-btn download-btn" @click.stop="handleDownloadFile(file.url, file.filename)">
-                    <img :src="getCssUrl('--img-chat-download-icon-btn')" width="14" height="14" />
-                    <span>下载</span>
-                  </button>
-                  <button v-if="isEditableFile(file.filename)" class="file-action-btn edit-btn" @click.stop="handleEditFile(file.url, file.filename)">
-                    <img :src="getCssUrl('--img-chat-edit-icon')" width="14" height="14" />
-                    <span>编辑</span>
-                  </button>
-                </div>
-              </div>
-            </div>
-            <div v-show="message.isLoading" class="loading-dots">
-              <span class="dot"></span>
-              <span class="dot"></span>
-              <span class="dot"></span>
-            </div>
-
-            <!-- ask_user 交互提问 UI(分步向导):ask_user 存在但 questions 缺失时不渲染,避免模板访问异常 -->
-            <div v-if="message.isPendingApproval && message.pendingApprovalData?.ask_user?.questions?.length" class="approval-card">
-              <div class="approval-icon"></div>
-              <div class="approval-content">
-                <div class="approval-title">{{ message.pendingApprovalData?.message }}</div>
-
-                <!-- 计划内容 -->
-                <div v-if="message.pendingApprovalData?.plan" class="approval-plan">
-                  <div class="approval-plan-label">执行计划</div>
-                  <div class="approval-plan-content">{{ message.pendingApprovalData.plan }}</div>
-                </div>
-
-                <!-- 步骤指示器 -->
-                <div v-if="message.pendingApprovalData.ask_user.questions.length > 1" class="ask-user-stepper">
-                  <div
-                    v-for="(_, sIdx) in message.pendingApprovalData.ask_user.questions"
-                    :key="sIdx"
-                    :class="[
-                      'ask-user-step-dot',
-                      {
-                        active: sIdx === (askUserCurrentStep[index] ?? 0),
-                        completed: sIdx < (askUserCurrentStep[index] ?? 0) || !!(askUserAnswers[index]?.[sIdx] && askUserAnswers[index][sIdx].trim()),
-                      },
-                    ]"
-                    @click="askUserCurrentStep[index] = sIdx"
-                  ></div>
-                  <span class="ask-user-step-text"
-                    >{{ (askUserCurrentStep[index] ?? 0) + 1 }} / {{ message.pendingApprovalData.ask_user.questions.length }}</span
-                  >
-                </div>
-
-                <!-- 当前问题 -->
-                <div v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]" class="ask-user-question">
-                  <div class="ask-user-question-text">
-                    {{ message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].question }}
-                    <span
-                      v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].required !== false"
-                      class="ask-user-required"
-                      >*</span
-                    >
-                  </div>
-
-                  <!-- multiple_choice 类型 -->
-                  <div
-                    v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].type === 'multiple_choice'"
-                    class="ask-user-choices"
-                  >
-                    <div
-                      v-for="(choice, cIdx) in message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].choices"
-                      :key="cIdx"
-                      :class="[
-                        'ask-user-choice',
-                        {
-                          selected:
-                            askUserAnswers[index]?.[askUserCurrentStep[index] ?? 0] === getChoiceValue(choice) &&
-                            !askUserOtherSelected[index]?.[askUserCurrentStep[index] ?? 0],
-                        },
-                      ]"
-                      @click="selectChoice(index, askUserCurrentStep[index] ?? 0, choice, message.pendingApprovalData.ask_user.questions.length)"
-                    >
-                      <span>{{ getChoiceValue(choice) }}</span>
-                    </div>
-                    <!-- Other 选项 -->
-                    <div
-                      :class="['ask-user-choice', { selected: askUserOtherSelected[index]?.[askUserCurrentStep[index] ?? 0] }]"
-                      @click="selectOther(index, askUserCurrentStep[index] ?? 0)"
-                    >
-                      <span>其他</span>
-                    </div>
-                    <input
-                      v-if="askUserOtherSelected[index]?.[askUserCurrentStep[index] ?? 0]"
-                      class="ask-user-other-input"
-                      placeholder="请输入..."
-                      :value="askUserAnswers[index]?.[askUserCurrentStep[index] ?? 0]"
-                      @input="askUserAnswers[index][askUserCurrentStep[index] ?? 0] = ($event.target as HTMLInputElement).value"
-                    />
-                  </div>
-
-                  <!-- text 类型 -->
-                  <div
-                    v-else-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].type === 'text'"
-                    class="ask-user-text"
-                  >
-                    <textarea
-                      class="ask-user-textarea"
-                      placeholder="请输入..."
-                      :value="askUserAnswers[index]?.[askUserCurrentStep[index] ?? 0]"
-                      @input="askUserAnswers[index][askUserCurrentStep[index] ?? 0] = ($event.target as HTMLTextAreaElement).value"
-                    ></textarea>
-                  </div>
-                </div>
-
-                <!-- 操作按钮 -->
-                <div class="approval-actions">
-                  <button v-if="(askUserCurrentStep[index] ?? 0) > 0" class="approval-btn nav-btn" @click="goPrevStep(index)">上一步</button>
-                  <button
-                    v-if="(askUserCurrentStep[index] ?? 0) < message.pendingApprovalData.ask_user.questions.length - 1"
-                    class="approval-btn approve-btn"
-                    :disabled="
-                      !isCurrentStepAnswered(
-                        index,
-                        askUserCurrentStep[index] ?? 0,
-                        message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]
-                      )
-                    "
-                    @click="goNextStep(index, message.pendingApprovalData.ask_user.questions.length)"
-                    >下一步</button
-                  >
-                  <button
-                    v-if="(askUserCurrentStep[index] ?? 0) === message.pendingApprovalData.ask_user.questions.length - 1"
-                    class="approval-btn approve-btn"
-                    :disabled="
-                      !isCurrentStepAnswered(
-                        index,
-                        askUserCurrentStep[index] ?? 0,
-                        message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]
-                      )
-                    "
-                    @click="handleSubmitAskUser(index)"
-                    >提交</button
-                  >
-                  <button class="approval-btn reject-btn" @click="handleCancelAskUser">取消</button>
-                </div>
-              </div>
-            </div>
-          </div>
-          <!-- AI消息操作栏 -->
-          <div class="message-actions">
-            <div class="action-btn" @click="copyMessageContent(message.content, index)" :title="copiedMessageIndex === index ? '复制成功' : '复制'">
-              <img :src="copyIconUrl(index)" width="14" height="14" />
-            </div>
-            <div v-if="canRegenerate(index, message)" class="action-btn" @click="emit('regenerate')" title="重新生成">
-              <img :src="getCssUrl('--img-chat-regenerate-icon')" width="14" height="14" />
-            </div>
-            <div v-if="message.id" class="action-btn" @click="emit('delete-message', message)" title="删除消息">
-              <img :src="getCssUrl('--img-chat-delete-icon')" width="14" height="14" />
-            </div>
-            <div v-if="showAnswerSwitcher(index)" class="answer-switcher">
-              <div class="action-btn answer-nav-btn" :class="{ 'is-disabled': !canPrevAnswer(index) }" @click="prevAnswer(index)" title="上一个回答">
-                <img :src="getCssUrl('--img-chat-prev-icon')" width="14" height="14" />
-              </div>
-              <span class="answer-switcher-text">{{ answerPositionText(index) }}</span>
-              <div class="action-btn answer-nav-btn" :class="{ 'is-disabled': !canNextAnswer(index) }" @click="nextAnswer(index)" title="下一个回答">
-                <img :src="getCssUrl('--img-chat-next-icon')" width="14" height="14" />
-              </div>
-            </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">
-            <template v-if="editingIndex === index">
-              <textarea
-                v-model="editDraft"
-                class="message-edit-textarea"
-                spellcheck="false"
-                @keydown.enter.exact.prevent="submitEdit(index)"
-                @keydown.escape.prevent="cancelEdit"
-              ></textarea>
-              <div class="message-edit-actions">
-                <button class="message-edit-btn confirm" @click="submitEdit(index)">重发</button>
-                <button class="message-edit-btn cancel" @click="cancelEdit">取消</button>
-              </div>
-            </template>
-            <template v-else>
-              <div class="message-text" v-html="renderMarkdown(message.content, tableIcons)"></div>
-              <div v-if="message.attachedFile" class="attached-file-card" @click="emit('file-preview', message.attachedFile)">
-                <div class="file-icon-card" :style="{ backgroundImage: getFileIconVar(message.attachedFile.name) }"></div>
-                <div 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>
-            </template>
-          </div>
-          <!-- 用户消息操作栏 -->
-          <div class="message-actions">
-            <div class="action-btn" @click="copyMessageContent(message.content, index)" :title="copiedMessageIndex === index ? '复制成功' : '复制'">
-              <img :src="copyIconUrl(index)" width="14" height="14" />
-            </div>
-            <div v-if="canEdit(index, message)" class="action-btn" @click="startEdit(index)" title="编辑">
-              <img :src="getCssUrl('--img-chat-edit-icon')" width="14" height="14" />
-            </div>
-            <div v-if="message.id" class="action-btn" @click="emit('delete-message', message)" title="删除消息">
-              <img :src="getCssUrl('--img-chat-delete-icon')" 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>
-</template>
-
-<script setup lang="ts">
-  import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
-  import dayjs from 'dayjs';
-  import {
-    renderMarkdown,
-    formatFileSize,
-    tableHtmlToMarkdown,
-    tableHtmlToCsv,
-    isMdFile,
-    isTextFile,
-    getFileIconVar,
-    fetchPreviewFile,
-    groupStepsByAgent,
-  } from './utils';
-  import type { AgentGroup } from './utils';
-  import { getToken } from '/@/utils/auth';
-  import { message } from 'ant-design-vue';
-  import type { Message, AttachedFile } from './types';
-  // import katex from 'katex';
-  // import 'katex/dist/katex.min.css';
-
-  // Cache CSS variable lookups to avoid repeated getComputedStyle calls on every render
-  const cssUrlCache = new Map<string, string>();
-  const getCssUrl = (varName: string) => {
-    if (!cssUrlCache.has(varName)) {
-      const val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
-      cssUrlCache.set(varName, val.replace(/^url\(["']?/, '').replace(/["']?\)$/, ''));
-    }
-    return cssUrlCache.get(varName)!;
-  };
-
-  // Clear cache when theme changes (observed via MutationObserver on html class)
-  const themeObserver = new MutationObserver(() => cssUrlCache.clear());
-  themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
-  onBeforeUnmount(() => themeObserver.disconnect());
-
-  const tableIcons = {
-    copy: getCssUrl('--img-chat-copy-icon'),
-    download: getCssUrl('--img-chat-download-icon-btn'),
-    preview: getCssUrl('--img-chat-preview-icon'),
-    copySuccess: getCssUrl('--img-chat-copy-success-icon'),
-  };
-
-  const copyIconUrl = computed(() => {
-    const icon = getCssUrl('--img-chat-copy-icon');
-    const successIcon = getCssUrl('--img-chat-copy-success-icon');
-    return (index: number) => (copiedMessageIndex.value === index ? successIcon : icon);
-  });
-
-  // 全局计时器 - 每秒刷新一次,用于实时显示思考耗时
-  const nowTs = ref(Date.now());
-  let timerInterval: ReturnType<typeof setInterval> | null = null;
-
-  const getDurationText = (message: Message): string => {
-    // 优先使用后端返回的 duration_ms(已包含 baseDurationMs 累加)
-    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.isPendingApproval && message.pendingApprovalData?.elapsedMs) {
-      const totalSec = Math.round(message.pendingApprovalData.elapsedMs / 1000);
-      const min = Math.floor(totalSec / 60);
-      const sec = totalSec % 60;
-      return `已工作 ${min}分${sec.toString().padStart(2, '0')}秒`;
-    }
-    // 前端实时计时(含中断前累计时长)
-    if (message.generateStartTime && message.isLoading) {
-      const baseMs = message.baseDurationMs || 0;
-      const elapsed = Math.floor((nowTs.value - message.generateStartTime + baseMs) / 1000);
-      const min = Math.floor(elapsed / 60);
-      const sec = elapsed % 60;
-      return `已工作 ${min}分${sec.toString().padStart(2, '0')}秒`;
-    }
-    return '';
-  };
-
-  const props = defineProps<{
-    messages: Message[];
-  }>();
-
-  export interface AgentDetailData {
-    messageIndex: number;
-    agent: string;
-    agentId?: string;
-  }
-
-  const emit = defineEmits<{
-    (e: 'file-preview', file: AttachedFile): void;
-    (e: 'download-word', url: string): void;
-    (e: 'open-file-preview', file: AttachedFile): void;
-    (e: 'ask-user-submit', answers: string[]): void;
-    (e: 'ask-user-cancel'): void;
-    (e: 'agent-detail-click', data: AgentDetailData): void;
-    (e: 'open-markdown-tab', payload: { title: string; content: string }): void;
-    (e: 'delete-message', message: Message): void;
-    (e: 'regenerate'): void;
-    (e: 'edit-resend', index: number, newContent: string): void;
-  }>();
-
-  // 用户消息内联编辑状态
-  const editingIndex = ref(-1);
-  const editDraft = ref('');
-
-  // 剥离注入的技能/会话引用 <img> HTML,用于编辑时回填原始文本
-  const stripInjectedHtml = (content: string): string =>
-    (content || '')
-      .replace(/\[<img[^>]*>[^\]]*\]/g, '')
-      .replace(/<img[^>]*>/g, '')
-      .trim();
-
-  // ===== 多回答折叠:重新生成产生的连续多条 AI 消息在视觉上合并为一条,可通过 prev/next 切换查看 =====
-  interface AnswerGroup {
-    indices: number[];
-  }
-
-  // 将连续的 AI 消息划分为若干“回答组”
-  const answerGroups = computed<AnswerGroup[]>(() => {
-    const groups: AnswerGroup[] = [];
-    let current: AnswerGroup | null = null;
-    props.messages.forEach((msg, index) => {
-      if (msg.type === 'ai') {
-        if (!current) {
-          current = { indices: [] };
-          groups.push(current);
-        }
-        current.indices.push(index);
-      } else {
-        current = null;
-      }
-    });
-    return groups;
-  });
-
-  // 每条 AI 消息 → 所属组 key(组首条 index)、组内偏移、组大小
-  const aiGroupMeta = computed(() => {
-    const meta = new Map<number, { groupKey: number; offset: number; groupLen: number }>();
-    for (const group of answerGroups.value) {
-      const groupKey = group.indices[0];
-      const groupLen = group.indices.length;
-      group.indices.forEach((idx, offset) => {
-        meta.set(idx, { groupKey, offset, groupLen });
-      });
-    }
-    return meta;
-  });
-
-  // 每个回答组当前选中的回答偏移(默认组内最后一条,即最新生成)
-  const activeAnswerOffset = reactive<Record<number, number>>({});
-
-  const currentAnswerOffset = (groupKey: number, groupLen: number): number => {
-    const val = activeAnswerOffset[groupKey];
-    if (val == null) return groupLen - 1;
-    return Math.min(Math.max(val, 0), groupLen - 1);
-  };
-
-  // 末尾回答组内是否存在正在生成(isLoading)的消息
-  const lastAnswerGroupHasLoading = computed(() => {
-    const msgs = props.messages;
-    if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
-    let start = msgs.length - 1;
-    while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
-    for (let i = start; i < msgs.length; i++) {
-      if (msgs[i].isLoading) return true;
-    }
-    return false;
-  });
-
-  // 新回答开始生成(false → true)时,自动切换到最新一条正在生成的消息
-  watch(lastAnswerGroupHasLoading, (loading, wasLoading) => {
-    if (loading && !wasLoading) {
-      const msgs = props.messages;
-      let start = msgs.length - 1;
-      while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
-      delete activeAnswerOffset[start];
-    }
-  });
-
-  // 该 AI 消息是否应被折叠隐藏(属于多回答组且非当前选中项)
-  const isHiddenAnswer = (index: number): boolean => {
-    const meta = aiGroupMeta.value.get(index);
-    if (!meta || meta.groupLen <= 1) return false;
-    return currentAnswerOffset(meta.groupKey, meta.groupLen) !== meta.offset;
-  };
-
-  // 该消息是否为当前选中显示的回答(供父组件标记折叠回答中的条目,如胶囊面板的智能体记录)
-  const isAnswerVisible = (index: number): boolean => !isHiddenAnswer(index);
-
-  // 该 AI 消息是否显示回答切换器(多回答组且为当前选中项)
-  const showAnswerSwitcher = (index: number): boolean => {
-    const meta = aiGroupMeta.value.get(index);
-    if (!meta || meta.groupLen <= 1) return false;
-    return currentAnswerOffset(meta.groupKey, meta.groupLen) === meta.offset;
-  };
-
-  const answerPositionText = (index: number): string => {
-    const meta = aiGroupMeta.value.get(index);
-    if (!meta) return '';
-    return `${currentAnswerOffset(meta.groupKey, meta.groupLen) + 1}/${meta.groupLen}`;
-  };
-
-  const canPrevAnswer = (index: number): boolean => {
-    const meta = aiGroupMeta.value.get(index);
-    if (!meta) return false;
-    return currentAnswerOffset(meta.groupKey, meta.groupLen) > 0;
-  };
-
-  const canNextAnswer = (index: number): boolean => {
-    const meta = aiGroupMeta.value.get(index);
-    if (!meta) return false;
-    return currentAnswerOffset(meta.groupKey, meta.groupLen) < meta.groupLen - 1;
-  };
-
-  const prevAnswer = (index: number) => {
-    const meta = aiGroupMeta.value.get(index);
-    if (!meta) return;
-    const cur = currentAnswerOffset(meta.groupKey, meta.groupLen);
-    if (cur > 0) activeAnswerOffset[meta.groupKey] = cur - 1;
-  };
-
-  const nextAnswer = (index: number) => {
-    const meta = aiGroupMeta.value.get(index);
-    if (!meta) return;
-    const cur = currentAnswerOffset(meta.groupKey, meta.groupLen);
-    if (cur < meta.groupLen - 1) activeAnswerOffset[meta.groupKey] = cur + 1;
-  };
-
-  // 该消息是否属于“末尾连续 AI 组”:重新生成按钮只对最新一轮回答(含折叠组内任意一条)显示
-  const isInLastAnswerGroup = (index: number): boolean => {
-    const msgs = props.messages;
-    if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
-    if (msgs[index]?.type !== 'ai') return false;
-    let start = msgs.length - 1;
-    while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
-    return index >= start;
-  };
-
-  const canRegenerate = (index: number, message: Message): boolean => isInLastAnswerGroup(index) && !message.isLoading && !message.isPendingApproval;
-
-  // 最后一条用户消息的索引(编辑重发只作用于最后一条用户消息,而非整段对话的最后一条)
-  const lastUserMessageIndex = computed(() => {
-    for (let i = props.messages.length - 1; i >= 0; i--) {
-      if (props.messages[i].type === 'user') return i;
-    }
-    return -1;
-  });
-
-  const canEdit = (index: number, message: Message): boolean =>
-    message.type === 'user' && index === lastUserMessageIndex.value && editingIndex.value !== index;
-
-  const startEdit = (index: number) => {
-    const msg = props.messages[index];
-    editingIndex.value = index;
-    editDraft.value = msg.rawInput || stripInjectedHtml(msg.content);
-  };
-
-  const cancelEdit = () => {
-    editingIndex.value = -1;
-    editDraft.value = '';
-  };
-
-  const submitEdit = (index: number) => {
-    const content = editDraft.value;
-    editingIndex.value = -1;
-    editDraft.value = '';
-    emit('edit-resend', index, content);
-  };
-
-  const isEditableFile = (filename: string) => isMdFile(filename) || isTextFile(filename);
-
-  const handleDownloadFile = (url: string, filename: string) => {
-    if (!url) return;
-    const a = document.createElement('a');
-    a.href = url;
-    a.download = filename;
-    document.body.appendChild(a);
-    a.click();
-    document.body.removeChild(a);
-  };
-
-  const handleEditFile = async (url: string, filename: string) => {
-    try {
-      const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
-      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
-      const content = await resp.text();
-      const ext = filename.split('.').pop()?.toLowerCase() || '';
-      const mimeMap: Record<string, string> = {
-        md: 'text/markdown',
-        txt: 'text/plain',
-        json: 'application/json',
-        xml: 'text/xml',
-        yaml: 'text/yaml',
-        yml: 'text/yaml',
-        csv: 'text/csv',
-      };
-      const file: AttachedFile = {
-        id: `report-${url}`,
-        name: filename,
-        size: content.length,
-        type: mimeMap[ext] || 'text/plain',
-        content,
-        uploadTime: new Date().toISOString(),
-      };
-      emit('open-file-preview', file);
-    } catch (err) {
-      console.error('获取文件内容失败:', err);
-      message.error('获取文件内容失败');
-    }
-  };
-
-  // 点击文件卡片:按类型拉取内容并在右侧辅助面板打开预览标签
-  const handlePreviewFile = async (file: { url: string; filename: string }) => {
-    if (!file.url) return;
-    try {
-      const previewFile = await fetchPreviewFile(file.url, file.filename);
-      if (!previewFile) {
-        // 不支持的格式回退为直接下载
-        handleDownloadFile(file.url, file.filename);
-        return;
-      }
-      emit('file-preview', previewFile);
-    } catch (err) {
-      console.error('获取文件内容失败:', err);
-      message.error('文件预览失败,请尝试下载查看');
-    }
-  };
-
-  // ask_user 答案状态(按消息索引存储)
-  const askUserAnswers = reactive<Record<number, string[]>>({});
-  const askUserOtherSelected = reactive<Record<number, boolean[]>>({});
-  const askUserCurrentStep = reactive<Record<number, number>>({});
-
-  // choice 可能是字符串或 { value: "..." } 对象,统一提取字符串
-  const getChoiceValue = (choice: any): string => {
-    if (typeof choice === 'string') return choice;
-    if (choice && typeof choice === 'object' && 'value' in choice) return choice.value;
-    return String(choice);
-  };
-
-  const initAskUserAnswers = (index: number, questions: { question: string; type: string; choices?: any[] }[]) => {
-    // Always reinitialize when called - handles repeated interrupts from backend
-    askUserAnswers[index] = questions.map(() => '');
-    askUserOtherSelected[index] = questions.map(() => false);
-    askUserCurrentStep[index] = 0;
-  };
-
-  // Track the last pendingApprovalData reference to detect new interrupts
-  const lastApprovalDataRef = new Map<number, any>();
-
-  // 自动初始化 ask_user 答案状态。
-  // 必须注册在 initAskUserAnswers 等 const 声明之后:immediate watcher 在 setup 阶段同步执行,
-  // 若带审批消息重新挂载(关闭弹框后再打开),回调会访问尚未初始化的变量(TDZ 报错导致整个弹框挂载失败)
-  watch(
-    () => props.messages,
-    (msgs) => {
-      msgs.forEach((msg, idx) => {
-        if (msg.isPendingApproval && msg.pendingApprovalData?.ask_user?.questions) {
-          // Only reinitialize if pendingApprovalData object reference changed (new interrupt)
-          const lastRef = lastApprovalDataRef.get(idx);
-          if (lastRef !== msg.pendingApprovalData) {
-            lastApprovalDataRef.set(idx, msg.pendingApprovalData);
-            initAskUserAnswers(idx, msg.pendingApprovalData.ask_user.questions);
-          }
-        } else {
-          lastApprovalDataRef.delete(idx);
-        }
-      });
-    },
-    { immediate: true, deep: true }
-  );
-
-  const isCurrentStepAnswered = (msgIndex: number, qIndex: number, question: { required?: boolean; type: string }) => {
-    const answers = askUserAnswers[msgIndex];
-    if (!answers) return false;
-    if (question.required === false && !answers[qIndex]) return true;
-    return !!(answers[qIndex] && answers[qIndex].trim() !== '');
-  };
-
-  const goNextStep = (msgIndex: number, totalQuestions: number) => {
-    if (askUserCurrentStep[msgIndex] < totalQuestions - 1) {
-      askUserCurrentStep[msgIndex]++;
-    }
-  };
-
-  const goPrevStep = (msgIndex: number) => {
-    if (askUserCurrentStep[msgIndex] > 0) {
-      askUserCurrentStep[msgIndex]--;
-    }
-  };
-
-  const selectChoice = (msgIndex: number, qIndex: number, choice: any, totalQuestions: number) => {
-    if (!askUserAnswers[msgIndex]) return;
-    askUserAnswers[msgIndex][qIndex] = getChoiceValue(choice);
-    askUserOtherSelected[msgIndex][qIndex] = false;
-    // 单选自动跳到下一题(非最后一题时)
-    if (qIndex < totalQuestions - 1) {
-      goNextStep(msgIndex, totalQuestions);
-    }
-  };
-
-  const selectOther = (msgIndex: number, qIndex: number) => {
-    if (!askUserOtherSelected[msgIndex]) return;
-    askUserOtherSelected[msgIndex][qIndex] = true;
-    askUserAnswers[msgIndex][qIndex] = '';
-  };
-
-  const handleSubmitAskUser = (index: number) => {
-    // 防御性初始化:如果答案数组未初始化,从 pendingApprovalData 回退
-    if (!askUserAnswers[index]) {
-      const msg = props.messages[index];
-      if (msg?.pendingApprovalData?.ask_user?.questions) {
-        initAskUserAnswers(index, msg.pendingApprovalData.ask_user.questions);
-      } else {
-        return;
-      }
-    }
-    const answers = askUserAnswers[index];
-    if (!answers) return;
-    emit('ask-user-submit', [...answers]);
-  };
-
-  const handleCancelAskUser = () => {
-    emit('ask-user-cancel');
-  };
-
-  const messagesRef = ref<HTMLElement>();
-  const collapsedThinking = reactive<Record<number, boolean>>({});
-  const collapsedThinkingContent = reactive<Record<number, boolean>>({});
-  const collapsedCitations = reactive<Record<number, boolean>>({});
-  const isAtBottom = ref(true);
-  const copiedMessageIndex = ref(-1);
-
-  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);
-        const doCopy = async () => {
-          try {
-            await 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);
-          }
-        };
-        doCopy().then(() => {
-          const btn = target;
-          const img = btn.querySelector('img');
-          const successIcon = btn.dataset.iconSuccess;
-          const originalIcon = btn.dataset.icon;
-          if (img && successIcon) {
-            img.src = successIcon;
-            btn.title = '复制成功';
-            const reset = () => {
-              img.src = originalIcon!;
-              btn.title = '复制Markdown';
-              wrapper.removeEventListener('mouseleave', reset);
-            };
-            const wrapper = btn.closest('.markdown-table-wrapper')!;
-            wrapper.addEventListener('mouseleave', reset);
-          }
-        });
-        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': {
-        // 在右侧面板新建标签页展示表格渲染效果
-        emit('open-markdown-tab', { title: '表格预览', content: tableHtmlToMarkdown(tableHtml) });
-        break;
-      }
-    }
-  };
-
-  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 - el.clientHeight;
-    }
-  };
-
-  const forceScrollToBottom = () => {
-    const el = messagesRef.value;
-    if (el) {
-      el.scrollTop = el.scrollHeight - el.clientHeight;
-      // 使用 requestAnimationFrame 确保 DOM 渲染完成后再滚动一次
-      requestAnimationFrame(() => {
-        el.scrollTop = el.scrollHeight - el.clientHeight;
-      });
-    }
-    isAtBottom.value = true;
-  };
-
-  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 toggleThinkingCard = (index: number) => {
-    collapsedThinking[index] = !collapsedThinking[index];
-  };
-
-  const toggleThinkingContent = (index: number) => {
-    collapsedThinkingContent[index] = !collapsedThinkingContent[index];
-  };
-
-  const handleAgentGroupClick = (messageIndex: number, group: AgentGroup) => {
-    emit('agent-detail-click', {
-      messageIndex,
-      agent: group.agent,
-      agentId: group.agentId,
-    });
-  };
-
-  const toggleCitations = (index: number) => {
-    collapsedCitations[index] = !collapsedCitations[index];
-  };
-
-  const handleFootnoteClick = (e: Event, msgIndex: number) => {
-    const target = (e.target as HTMLElement).closest('.footnote-ref') as HTMLElement;
-    if (!target) return;
-    const n = target.dataset.n;
-    if (!n) return;
-    const el = document.getElementById(`citation-${msgIndex}-${n}`);
-    if (el) {
-      scrollMessagesTo(el, 'center');
-      el.classList.add('citation-highlight');
-      setTimeout(() => el.classList.remove('citation-highlight'), 1500);
-    }
-  };
-
-  const getLatestStepMessage = (group: AgentGroup): string => {
-    if (group.steps.length === 0) return '';
-    const last = group.steps[group.steps.length - 1];
-    if (last.type === 'executing') return last.message || '正在执行...';
-    if (last.type === 'tool_call') return last.message || last.tool || '';
-    if (last.type === 'tool_result') return last.message || '';
-    if (last.type === 'agent_start') return last.message || '';
-    if (last.type === 'agent_done') return last.message || '';
-    if (last.type === 'updated_todo_list') return '任务列表已更新';
-    return '';
-  };
-
-  const formatFullTime = (createdAt: string) => {
-    return dayjs(createdAt).format('YYYY-MM-DD HH:mm:ss');
-  };
-
-  const copyMessageContent = async (content: string, index: number) => {
-    if (!content) return;
-    try {
-      await navigator.clipboard.writeText(content);
-      copiedMessageIndex.value = index;
-    } 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);
-      copiedMessageIndex.value = index;
-    }
-  };
-
-  // ===== 左侧对话缩略导航 =====
-  interface QaPair {
-    startIndex: number;
-    question: string;
-    answer: string;
-  }
-
-  const toPlainText = (md: string): string =>
-    md
-      .replace(/```[\s\S]*?```/g, ' [代码] ')
-      .replace(/`[^`]*`/g, ' ')
-      .replace(/<[^>]+>/g, '')
-      .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
-      .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
-      .replace(/^#{1,6}\s*/gm, '')
-      .replace(/[*_~>|]/g, '')
-      .replace(/\s+/g, ' ')
-      .trim();
-
-  const truncateText = (text: string, max = 50): string => (text.length > max ? `${text.slice(0, max)}…` : text);
-
-  // 一轮问答 = 一条用户消息 + 其后的连续 AI 消息
-  const qaPairs = computed<QaPair[]>(() => {
-    const pairs: QaPair[] = [];
-    let current: QaPair | null = null;
-    props.messages.forEach((msg, index) => {
-      if (msg.type === 'user') {
-        if (current) pairs.push(current);
-        current = { startIndex: index, question: truncateText(toPlainText(msg.content)), answer: '' };
-      } else {
-        if (!current) current = { startIndex: index, question: '', answer: '' };
-        const part = msg.isLoading && !msg.content ? '正在生成...' : toPlainText(msg.content);
-        if (part) current.answer = truncateText(current.answer ? `${current.answer} ${part}` : part);
-      }
-    });
-    if (current) pairs.push(current);
-    return pairs;
-  });
-
-  const navRef = ref<HTMLElement>();
-  const tooltipRef = ref<HTMLElement>();
-  const hoveredPair = ref<QaPair | null>(null);
-  const tooltipTop = ref(0);
-  const tooltipArrowTop = ref(0);
-  const activeMessage = ref<Message | null>(null);
-
-  // 鼠标在导航列内的 Y 坐标(导航列内容坐标系,随滚动补偿);null 表示未悬浮
-  const navMouseY = ref<number | null>(null);
-  // 各横线中心 Y(导航列内容坐标系),消息变化/列滚动后重建
-  const lineCenters = ref<number[]>([]);
-
-  const updateLineCenters = () => {
-    const nav = navRef.value;
-    if (!nav) {
-      lineCenters.value = [];
-      return;
-    }
-    const centers: number[] = [];
-    nav.querySelectorAll<HTMLElement>('.nav-line').forEach((line) => centers.push(line.offsetTop + line.offsetHeight / 2));
-    lineCenters.value = centers;
-  };
-
-  // 宽度随与鼠标的距离连续衰减(高斯),形成平滑的"长-中-短"波形
-  const navLineWidth = (pIdx: number): number => {
-    const mouseY = navMouseY.value;
-    if (mouseY === null) return 10;
-    const dist = Math.abs((lineCenters.value[pIdx] ?? 0) - mouseY);
-    return 10 + 8 * Math.exp(-(dist * dist) / (2 * 14 * 14));
-  };
-
-  const nearestLineIndex = computed(() => {
-    const mouseY = navMouseY.value;
-    if (mouseY === null) return -1;
-    let best = -1;
-    let bestDist = Infinity;
-    lineCenters.value.forEach((center, i) => {
-      const dist = Math.abs(center - mouseY);
-      if (dist < bestDist) {
-        bestDist = dist;
-        best = i;
-      }
-    });
-    return best;
-  });
-
-  let lastTooltipIndex = -1;
-
-  const applyTooltip = async (pIdx: number) => {
-    const pair = qaPairs.value[pIdx];
-    if (!pair) {
-      hoveredPair.value = null;
-      return;
-    }
-    hoveredPair.value = pair;
-    await nextTick();
-    const tooltip = tooltipRef.value;
-    const nav = navRef.value;
-    const container = nav?.parentElement as HTMLElement | null;
-    if (!tooltip || !nav || !container) return;
-    const lineCenterY = nav.offsetTop + (lineCenters.value[pIdx] ?? 0) - nav.scrollTop;
-    const tooltipH = tooltip.offsetHeight;
-    const maxTop = Math.max(8, container.clientHeight - tooltipH - 8);
-    tooltipTop.value = Math.max(8, Math.min(maxTop, lineCenterY - tooltipH / 2));
-    tooltipArrowTop.value = Math.max(6, Math.min(tooltipH - 16, lineCenterY - tooltipTop.value - 5));
-  };
-
-  const onNavMove = (e: MouseEvent) => {
-    const nav = navRef.value;
-    if (!nav) return;
-    // 横线数量变化(新增问答/首次悬浮)时重建坐标
-    if (lineCenters.value.length !== qaPairs.value.length) updateLineCenters();
-    // e.clientY / getBoundingClientRect 是容器 scale 缩放后的视口坐标,需换算回布局坐标
-    // (÷ 容器竖向缩放)后再与 lineCenters / scrollTop 比较,否则不同分辨率下定位错位
-    const scale = nav.offsetHeight ? nav.getBoundingClientRect().height / nav.offsetHeight : 1;
-    navMouseY.value = (e.clientY - nav.getBoundingClientRect().top) / scale + nav.scrollTop;
-    const nearest = nearestLineIndex.value;
-    if (nearest >= 0 && nearest !== lastTooltipIndex) {
-      lastTooltipIndex = nearest;
-      applyTooltip(nearest);
-    }
-  };
-
-  const onNavLeave = () => {
-    navMouseY.value = null;
-    hoveredPair.value = null;
-    lastTooltipIndex = -1;
-  };
-
-  const onNavScroll = () => {
-    updateLineCenters();
-    if (lastTooltipIndex >= 0) applyTooltip(lastTooltipIndex);
-  };
-
-  // 仅滚动消息列表容器:scrollIntoView 会联动滚动 #adaptive-container 等祖先,导致整页上移、底部露出空白
-  const scrollMessagesTo = (el: HTMLElement, block: 'start' | 'center' = 'start') => {
-    const container = messagesRef.value;
-    if (!container) return;
-    const top = el.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop;
-    const offset = block === 'center' ? (container.clientHeight - el.offsetHeight) / 2 : 0;
-    container.scrollTo({ top: Math.max(0, top - offset), behavior: 'smooth' });
-  };
-
-  const scrollToMessage = (index: number, block: 'start' | 'center' = 'start') => {
-    const wrappers = messagesRef.value?.querySelectorAll('.message-wrapper');
-    let el = wrappers?.[index] as HTMLElement | undefined;
-    // 多回答折叠组中被隐藏的消息没有几何信息,回退到其前面最近一条可见消息(通常是该轮的用户提问)
-    if (el && el.offsetParent === null) {
-      el = undefined;
-      for (let i = index - 1; i >= 0; i--) {
-        const w = wrappers?.[i] as HTMLElement | undefined;
-        if (w && w.offsetParent !== null) {
-          el = w;
-          break;
-        }
-      }
-    }
-    if (el) scrollMessagesTo(el, block);
-  };
-
-  const scrollToPair = (pair: QaPair) => {
-    activeMessage.value = props.messages[pair.startIndex];
-    scrollToMessage(pair.startIndex, 'start');
-    const wrappers = messagesRef.value?.querySelectorAll('.message-wrapper');
-    const el = wrappers?.[pair.startIndex] as HTMLElement | undefined;
-    el?.classList.add('nav-located-highlight');
-    setTimeout(() => el?.classList.remove('nav-located-highlight'), 1500);
-  };
-
-  defineExpose({ messagesRef, isAtBottom, forceScrollToBottom, scrollToMessage, initAskUserAnswers, isAnswerVisible });
-</script>
-
-<style scoped lang="less">
-  .chat-messages-container {
-    flex: 1;
-    position: relative;
-    display: flex;
-    flex-direction: row;
-    overflow: hidden;
-  }
-
-  .conversation-nav {
-    position: relative;
-    width: 28px;
-    flex-shrink: 0;
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    overflow-y: auto;
-    padding: 6px 0;
-    scrollbar-width: none;
-
-    &::-webkit-scrollbar {
-      display: none;
-    }
-
-    .nav-line {
-      width: 10px;
-      height: 3px;
-      border-radius: 2px;
-      background: rgba(139, 148, 158, 0.4);
-      margin: 5px 0;
-      flex-shrink: 0;
-      cursor: pointer;
-      transition:
-        width 0.15s ease-out,
-        background 0.2s;
-
-      &:first-child {
-        margin-top: auto;
-      }
-
-      &:last-child {
-        margin-bottom: auto;
-      }
-
-      &.nav-line-hot {
-        background: #0a84ff;
-      }
-
-      &.active {
-        background: #0a84ff;
-      }
-    }
-  }
-
-  .conversation-tooltip {
-    position: absolute;
-    left: 36px;
-    width: 260px;
-    background: linear-gradient(135deg, #0a1628 0%, #0d2137 50%, #0a1e35 100%);
-    border: 1px solid #1a4a6f;
-    border-radius: 8px;
-    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-    padding: 10px 12px;
-    z-index: 20;
-    pointer-events: none;
-
-    .tooltip-arrow {
-      position: absolute;
-      left: -6px;
-      width: 10px;
-      height: 10px;
-      transform: rotate(45deg);
-      background: #0d2137;
-      border-left: 1px solid #1a4a6f;
-      border-bottom: 1px solid #1a4a6f;
-    }
-
-    .tooltip-row {
-      display: flex;
-      align-items: flex-start;
-      gap: 8px;
-      font-size: 12px;
-      line-height: 1.5;
-
-      & + .tooltip-row {
-        margin-top: 8px;
-        padding-top: 8px;
-        border-top: 1px solid rgba(63, 80, 106, 0.3);
-      }
-
-      .tooltip-label {
-        flex-shrink: 0;
-        width: 16px;
-        height: 16px;
-        line-height: 16px;
-        margin-top: 1px;
-        text-align: center;
-        border-radius: 3px;
-        font-size: 11px;
-
-        &-q {
-          color: #79c0ff;
-          background: rgba(10, 132, 255, 0.15);
-        }
-
-        &-a {
-          color: #8b949e;
-          background: rgba(139, 148, 158, 0.15);
-        }
-      }
-
-      .tooltip-text {
-        color: #c9d1d9;
-        word-break: break-word;
-      }
-    }
-  }
-
-  .tooltip-fade-enter-active,
-  .tooltip-fade-leave-active {
-    transition: opacity 0.15s ease;
-  }
-
-  .tooltip-fade-enter-from,
-  .tooltip-fade-leave-to {
-    opacity: 0;
-  }
-
-  .chat-messages {
-    flex: 1;
-    overflow-y: auto;
-    padding: 20px;
-    position: relative;
-
-    .welcome-empty {
-      position: absolute;
-      top: 50%;
-      left: 50%;
-      transform: translate(-50%, -50%);
-      display: flex;
-      align-items: center;
-      justify-content: center;
-
-      .welcome-img {
-        max-width: 300px;
-        max-height: 300px;
-        object-fit: contain;
-      }
-    }
-
-    .message-wrapper {
-      // margin-bottom: 20px;
-
-      &.answer-hidden {
-        display: none;
-      }
-
-      &.nav-located-highlight {
-        background: rgba(10, 132, 255, 0.08);
-        border-radius: 6px;
-      }
-
-      .ai-message {
-        display: flex;
-        flex-wrap: wrap;
-        .message-agent {
-          display: flex;
-          margin-bottom: 8px;
-          opacity: 0;
-        }
-
-        .message-body {
-          padding: 12px 12px 4px 12px;
-          border-radius: 6px;
-          width: 100%;
-          min-width: 0;
-          overflow: hidden;
-
-          /* 思考过程卡片 */
-          .thinking-card {
-            margin-bottom: 12px;
-            overflow: hidden;
-
-            .thinking-duration-bar {
-              display: flex;
-              align-items: center;
-              justify-content: space-between;
-              padding: 8px 14px;
-              border-bottom: 1px solid rgba(63, 80, 106, 0.3);
-              cursor: pointer;
-              user-select: none;
-              transition: background 0.2s;
-
-              &:hover {
-                background: rgba(10, 132, 255, 0.12);
-              }
-
-              .thinking-duration {
-                font-size: 16px;
-                color: #8b949e;
-              }
-
-              .thinking-arrow {
-                color: #8b949e;
-                display: flex;
-                align-items: center;
-                transition: transform 0.2s;
-
-                &.expanded {
-                  transform: rotate(180deg);
-                }
-              }
-            }
-
-            .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.05);
-              }
-
-              .thinking-header-left {
-                display: flex;
-                align-items: center;
-                gap: 8px;
-              }
-
-              .thinking-arrow {
-                display: flex;
-                align-items: center;
-                transition: transform 0.2s;
-
-                &.expanded {
-                  transform: rotate(180deg);
-                }
-              }
-
-              .thinking-icon {
-                width: 16px;
-                height: 16px;
-                background-image: var(--img-chat-thinking-icon);
-                background-repeat: no-repeat;
-                background-size: 100% 100%;
-              }
-
-              .thinking-title {
-                font-size: 13px;
-                font-weight: 500;
-                color: #8b949e;
-              }
-
-              .thinking-count {
-                font-size: 12px;
-                color: #8b949e;
-                background: rgba(10, 132, 255, 0.1);
-                padding: 1px 6px;
-                border-radius: 10px;
-              }
-            }
-
-            .thinking-content-body {
-              margin: 0 14px 8px;
-              padding: 8px 12px;
-              max-height: 200px;
-              overflow-y: auto;
-              font-size: 13px;
-              line-height: 1.6;
-              color: #8b949e;
-              white-space: pre-wrap;
-              word-break: break-word;
-              background: rgba(10, 13, 23, 0.3);
-              border-radius: 6px;
-            }
-
-            .thinking-body {
-              padding: 8px 14px;
-
-              .agent-group {
-                margin-bottom: 8px;
-                border: 1px solid transparent;
-                border-radius: 6px;
-                overflow: hidden;
-                transition: border-color 0.2s;
-                cursor: pointer;
-
-                &:hover {
-                  border-color: rgba(63, 80, 106, 0.25);
-                  background: rgba(10, 132, 255, 0.05);
-                }
-
-                &:last-child {
-                  margin-bottom: 0;
-                }
-
-                .agent-group-header {
-                  display: flex;
-                  align-items: center;
-                  justify-content: space-between;
-                  padding: 8px 12px;
-                  user-select: none;
-
-                  .agent-group-header-left {
-                    display: flex;
-                    align-items: center;
-                    gap: 8px;
-                  }
-
-                  .agent-group-icon {
-                    width: 14px;
-                    height: 14px;
-                    background-image: var(--img-chat-agent-icon);
-                    background-repeat: no-repeat;
-                    background-size: 100% 100%;
-                    flex-shrink: 0;
-                  }
-
-                  .agent-group-name {
-                    font-size: 13px;
-                    font-weight: 500;
-                    color: #fff;
-                  }
-
-                  .agent-group-latest {
-                    font-size: 12px;
-                    color: #8b949e;
-                    margin-left: 8px;
-                    overflow: hidden;
-                    text-overflow: ellipsis;
-                    white-space: nowrap;
-                    flex: 1;
-                    min-width: 0;
-                  }
-
-                  .agent-arrow {
-                    margin-left: 8px;
-                    flex-shrink: 0;
-                    display: flex;
-                    align-items: center;
-                  }
-                }
-              }
-            }
-          }
-
-          .collapse-enter-active,
-          .collapse-leave-active {
-            transition: all 0.25s ease;
-            overflow: hidden;
-          }
-
-          .collapse-enter-from,
-          .collapse-leave-to {
-            opacity: 0;
-            max-height: 0;
-            padding-top: 0;
-            padding-bottom: 0;
-            margin-top: 0;
-            margin-bottom: 0;
-          }
-
-          .collapse-enter-to,
-          .collapse-leave-from {
-            opacity: 1;
-            max-height: 500px;
-          }
-
-          .message-text {
-            font-size: 14px;
-            font-weight: 400;
-            line-height: 1.6;
-            color: #e0e6ed;
-            word-break: break-word;
-            overflow-wrap: break-word;
-
-            :deep(.session-ref-icon) {
-              width: 14px;
-              height: 14px;
-              vertical-align: -2px;
-            }
-
-            :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;
-              max-width: 100%;
-              overflow-x: auto;
-
-              .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;
-          min-height: 16px;
-
-          .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;
-          }
-        }
-
-        .approval-card {
-          display: flex;
-          align-items: flex-start;
-          gap: 12px;
-          padding: 14px 16px;
-          margin-top: 12px;
-          background: #002f4a;
-          border: 1px solid #005482;
-          border-radius: 8px;
-
-          .approval-icon {
-            width: 36px;
-            height: 36px;
-            background-image: var(--img-chat-session-approve-icon);
-            background-repeat: no-repeat;
-            background-size: 100% 100%;
-            flex-shrink: 0;
-          }
-
-          .approval-content {
-            flex: 1;
-
-            .approval-title {
-              font-size: 14px;
-              font-weight: 600;
-              color: #e0e6ed;
-              margin-bottom: 6px;
-            }
-
-            .approval-plan {
-              margin-bottom: 14px;
-              padding: 10px 12px;
-              background: rgba(10, 132, 255, 0.08);
-              border: 1px solid rgba(10, 132, 255, 0.2);
-              border-radius: 6px;
-
-              .approval-plan-label {
-                font-size: 12px;
-                color: #0a84ff;
-                font-weight: 600;
-                margin-bottom: 6px;
-              }
-
-              .approval-plan-content {
-                font-size: 13px;
-                color: #c9d1d9;
-                line-height: 1.6;
-                white-space: pre-wrap;
-              }
-            }
-
-            .ask-user-question {
-              margin-bottom: 14px;
-
-              .ask-user-question-text {
-                font-size: 13px;
-                color: #c9d1d9;
-                margin-bottom: 8px;
-                line-height: 1.5;
-
-                .ask-user-required {
-                  color: #f85149;
-                  margin-left: 2px;
-                }
-              }
-
-              .ask-user-choices {
-                display: flex;
-                flex-direction: column;
-                gap: 6px;
-
-                .ask-user-choice {
-                  display: flex;
-                  align-items: center;
-                  padding: 8px 12px;
-                  background: rgba(255, 255, 255, 0.04);
-                  border: 1px solid rgba(63, 80, 106, 0.4);
-                  border-radius: 6px;
-                  cursor: pointer;
-                  font-size: 13px;
-                  color: #c9d1d9;
-                  transition: all 0.2s;
-
-                  &:hover {
-                    background: rgba(10, 132, 255, 0.1);
-                    border-color: rgba(10, 132, 255, 0.4);
-                  }
-
-                  &.selected {
-                    background: rgba(10, 132, 255, 0.15);
-                    border-color: #0a84ff;
-                    color: #fff;
-                  }
-                }
-
-                .ask-user-other-input {
-                  margin-top: 4px;
-                  padding: 8px 12px;
-                  font-size: 13px;
-                  color: #e0e6ed;
-                  background: rgba(255, 255, 255, 0.06);
-                  border: 1px solid rgba(63, 80, 106, 0.5);
-                  border-radius: 6px;
-                  outline: none;
-                  width: 100%;
-
-                  &:focus {
-                    border-color: #0a84ff;
-                    background: rgba(10, 132, 255, 0.08);
-                  }
-
-                  &::placeholder {
-                    color: #6e7681;
-                  }
-                }
-              }
-
-              .ask-user-text {
-                .ask-user-textarea {
-                  width: 100%;
-                  min-height: 60px;
-                  padding: 8px 12px;
-                  font-size: 13px;
-                  color: #e0e6ed;
-                  background: rgba(255, 255, 255, 0.06);
-                  border: 1px solid rgba(63, 80, 106, 0.5);
-                  border-radius: 6px;
-                  outline: none;
-                  resize: vertical;
-                  font-family: inherit;
-
-                  &:focus {
-                    border-color: #0a84ff;
-                    background: rgba(10, 132, 255, 0.08);
-                  }
-
-                  &::placeholder {
-                    color: #6e7681;
-                  }
-                }
-              }
-            }
-
-            .ask-user-stepper {
-              display: flex;
-              align-items: center;
-              gap: 6px;
-              margin-bottom: 14px;
-
-              .ask-user-step-dot {
-                width: 8px;
-                height: 8px;
-                border-radius: 50%;
-                background: rgba(63, 80, 106, 0.5);
-                cursor: pointer;
-                transition: all 0.2s;
-
-                &.active {
-                  background: #0a84ff;
-                  width: 20px;
-                  border-radius: 4px;
-                }
-
-                &.completed {
-                  background: rgba(10, 132, 255, 0.5);
-                }
-              }
-
-              .ask-user-step-text {
-                margin-left: 6px;
-                font-size: 12px;
-                color: #6e7681;
-              }
-            }
-
-            .approval-actions {
-              display: flex;
-              gap: 10px;
-
-              .approval-btn {
-                padding: 6px 20px;
-                border-radius: 6px;
-                font-size: 13px;
-                font-weight: 500;
-                cursor: pointer;
-                border: none;
-                transition: all 0.2s;
-
-                &.approve-btn {
-                  background: #0a84ff;
-                  color: #fff;
-
-                  &:hover:not(:disabled) {
-                    background: #3a9bfd;
-                  }
-
-                  &:disabled {
-                    opacity: 0.4;
-                    cursor: not-allowed;
-                  }
-                }
-
-                &.nav-btn {
-                  background: rgba(255, 255, 255, 0.08);
-                  color: #c9d1d9;
-                  border: 1px solid rgba(63, 80, 106, 0.5);
-
-                  &:hover {
-                    background: rgba(255, 255, 255, 0.15);
-                  }
-                }
-
-                &.reject-btn {
-                  background: rgba(255, 255, 255, 0.08);
-                  color: #e0e6ed;
-                  border: 1px solid rgba(63, 80, 106, 0.5);
-
-                  &:hover {
-                    background: rgba(255, 255, 255, 0.15);
-                  }
-                }
-              }
-            }
-          }
-        }
-
-        .citations-card {
-          overflow: hidden;
-
-          .citations-header {
-            display: flex;
-            align-items: center;
-            justify-content: space-between;
-            padding: 8px 14px;
-            border-bottom: 1px solid rgba(63, 80, 106, 0.3);
-            cursor: pointer;
-            user-select: none;
-            transition: background 0.2s;
-
-            &:hover {
-              background: rgba(10, 132, 255, 0.08);
-            }
-
-            &.citations-header-simple {
-              cursor: default;
-
-              &:hover {
-                background: transparent;
-              }
-            }
-
-            .citations-header-left {
-              display: flex;
-              align-items: center;
-              gap: 8px;
-            }
-
-            .citations-title {
-              font-size: 13px;
-              font-weight: 500;
-              color: #8b949e;
-            }
-
-            .citations-count {
-              font-size: 12px;
-              color: #8b949e;
-              background: rgba(10, 132, 255, 0.1);
-              padding: 1px 6px;
-              border-radius: 10px;
-            }
-
-            .thinking-arrow {
-              display: flex;
-              align-items: center;
-              transition: transform 0.2s;
-
-              &.expanded {
-                transform: rotate(180deg);
-              }
-            }
-          }
-
-          .citations-body {
-            padding: 4px 14px;
-
-            .citation-item {
-              padding: 6px 0;
-              border-bottom: 1px solid rgba(63, 80, 106, 0.15);
-
-              &:last-child {
-                border-bottom: none;
-              }
-
-              &.citation-highlight {
-                background: rgba(10, 132, 255, 0.08);
-                border-radius: 4px;
-                padding: 6px 8px;
-                margin: 0 -8px;
-              }
-
-              .citation-item-row {
-                display: flex;
-                align-items: center;
-                gap: 8px;
-                line-height: 1.5;
-
-                .citation-badge {
-                  font-size: 12px;
-                  color: #79c0ff;
-                  font-family: monospace;
-                  background: rgba(10, 132, 255, 0.1);
-                  padding: 1px 5px;
-                  border-radius: 3px;
-                  white-space: nowrap;
-                  flex-shrink: 0;
-                }
-
-                .citation-badge-knowledge_base {
-                  color: #3fb950;
-                  background: rgba(46, 160, 67, 0.1);
-                }
-
-                .citation-badge-web {
-                  color: #d29922;
-                  background: rgba(210, 153, 34, 0.1);
-                }
-
-                .citation-badge-calc {
-                  color: #a371f7;
-                  background: rgba(163, 113, 247, 0.1);
-                }
-
-                .citation-title-text {
-                  font-size: 13px;
-                  color: #b0b8c4;
-                  overflow: hidden;
-                  text-overflow: ellipsis;
-                  white-space: nowrap;
-                  min-width: 0;
-                }
-
-                .citation-link {
-                  font-size: 12px;
-                  color: #0a84ff;
-                  text-decoration: none;
-                  white-space: nowrap;
-                  flex-shrink: 0;
-                }
-              }
-
-              .citation-item-attrs {
-                display: flex;
-                align-items: center;
-                flex-wrap: wrap;
-                gap: 4px 10px;
-                padding-left: 0;
-                margin-top: 2px;
-
-                .citation-attr {
-                  font-size: 12px;
-                  color: #8b949e;
-                  white-space: nowrap;
-                }
-
-                .citation-low-score {
-                  color: #d29922;
-                }
-              }
-
-              .citation-snippet {
-                margin-top: 4px;
-                font-size: 12px;
-                color: #8b949e;
-                line-height: 1.5;
-                overflow: hidden;
-                text-overflow: ellipsis;
-                white-space: nowrap;
-              }
-            }
-          }
-        }
-
-        .download-file-cards {
-          display: flex;
-          flex-direction: column;
-          gap: 8px;
-          margin-top: 12px;
-
-          .download-file-card {
-            display: flex;
-            align-items: center;
-            justify-content: space-between;
-            padding: 12px 16px;
-            background: linear-gradient(135deg, rgba(0, 47, 74, 0.8) 0%, rgba(0, 60, 90, 0.6) 100%);
-            border: 1px solid rgba(0, 84, 130, 0.6);
-            border-radius: 10px;
-            cursor: pointer;
-            transition: all 0.2s ease;
-
-            &:hover {
-              border-color: rgba(10, 132, 255, 0.4);
-              box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
-            }
-
-            .file-card-info {
-              display: flex;
-              align-items: center;
-              gap: 12px;
-              min-width: 0;
-              flex: 1;
-
-              .file-card-icon {
-                width: 36px;
-                height: 36px;
-                background-image: var(--img-chat-download-icon);
-                background-repeat: no-repeat;
-                background-size: 100% 100%;
-                flex-shrink: 0;
-                opacity: 0.9;
-              }
-
-              .file-card-text {
-                display: flex;
-                flex-direction: column;
-                gap: 2px;
-                min-width: 0;
-
-                .file-card-name {
-                  font-size: 13px;
-                  font-weight: 500;
-                  color: #e0e6ed;
-                  overflow: hidden;
-                  text-overflow: ellipsis;
-                  white-space: nowrap;
-                }
-
-                .file-card-hint {
-                  font-size: 11px;
-                  color: #6e7681;
-                }
-              }
-            }
-
-            .file-card-actions {
-              display: flex;
-              gap: 8px;
-              flex-shrink: 0;
-
-              .file-action-btn {
-                display: inline-flex;
-                align-items: center;
-                gap: 5px;
-                padding: 6px 14px;
-                border-radius: 7px;
-                font-size: 12px;
-                font-weight: 500;
-                cursor: pointer;
-                border: none;
-                transition: all 0.2s ease;
-                white-space: nowrap;
-
-                &.download-btn {
-                  background: rgba(255, 255, 255, 0.08);
-                  color: #c9d1d9;
-                  border: 1px solid rgba(63, 80, 106, 0.5);
-
-                  &:hover {
-                    background: rgba(255, 255, 255, 0.14);
-                    color: #e0e6ed;
-                    border-color: rgba(63, 80, 106, 0.8);
-                  }
-                }
-
-                &.edit-btn {
-                  background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
-                  color: #fff;
-                  border: 1px solid rgba(10, 132, 255, 0.5);
-                  box-shadow: 0 2px 6px rgba(10, 132, 255, 0.2);
-
-                  &:hover {
-                    background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
-                    box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-
-      .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;
-        }
-
-        .message-body {
-          background: rgba(10, 132, 255, 0.15);
-          padding: 12px;
-          border-radius: 6px 0 6px 6px;
-          max-width: 700px;
-          min-width: 0;
-          overflow: hidden;
-
-          .message-text {
-            font-size: 14px;
-            font-weight: 400;
-            line-height: 1.6;
-            color: #e0e6ed;
-            word-break: break-word;
-            overflow-wrap: break-word;
-
-            :deep(.session-ref-icon) {
-              width: 14px;
-              height: 14px;
-              vertical-align: -2px;
-            }
-
-            :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;
-          }
-
-          .answer-switcher {
-            display: flex;
-            align-items: center;
-            gap: 2px;
-            padding: 0 4px;
-            border-radius: 4px;
-            background: rgba(255, 255, 255, 0.04);
-
-            .answer-nav-btn {
-              width: 22px;
-              height: 22px;
-
-              &.is-disabled {
-                opacity: 0.35;
-                cursor: not-allowed;
-
-                &:hover {
-                  background: transparent;
-                  color: #8b949e;
-                }
-              }
-            }
-
-            .answer-switcher-text {
-              font-size: 12px;
-              color: #8b949e;
-              min-width: 32px;
-              text-align: center;
-              user-select: none;
-            }
-          }
-        }
-
-        &:hover .message-actions {
-          opacity: 1;
-        }
-      }
-
-      .ai-message .message-actions {
-        padding: 0 0 0 10px;
-      }
-
-      .user-message .message-actions {
-        padding: 4px 0;
-        justify-content: flex-end;
-      }
-    }
-  }
-
-  .message-edit-textarea {
-    width: 100%;
-    min-width: 500px;
-    min-height: 80px;
-    padding: 8px 12px;
-    font-size: 14px;
-    line-height: 1.6;
-    color: #e0e6ed;
-    background: rgba(0, 0, 0, 0.2);
-    border: 1px solid rgba(10, 132, 255, 0.5);
-    border-radius: 6px;
-    outline: none;
-    resize: vertical;
-    font-family: inherit;
-
-    &:focus {
-      border-color: #0a84ff;
-      background: rgba(0, 0, 0, 0.3);
-    }
-  }
-
-  .message-edit-actions {
-    display: flex;
-    gap: 8px;
-    margin-top: 8px;
-    justify-content: flex-end;
-
-    .message-edit-btn {
-      padding: 3px 14px;
-      font-size: 12px;
-      border-radius: 4px;
-      cursor: pointer;
-      border: 1px solid transparent;
-      transition: all 0.2s;
-
-      &.confirm {
-        color: #fff;
-        background: linear-gradient(135deg, #0a84ff 0%, #06c 100%);
-        border-color: rgba(10, 132, 255, 0.5);
-
-        &:hover {
-          background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
-        }
-      }
-
-      &.cancel {
-        color: #8b949e;
-        background: rgba(255, 255, 255, 0.06);
-        border-color: rgba(63, 80, 106, 0.4);
-
-        &:hover {
-          color: #c9d1d9;
-          background: rgba(255, 255, 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>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików