Parcourir la source

[Mod 0000] 修改AI助手模型预览面板销毁逻辑

wangkeyi il y a 2 jours
Parent
commit
f7f6bd23fe

+ 224 - 32
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -69,7 +69,7 @@
             @download-attachment="handleAttachmentDownload"
             @agent-detail-click="handleAgentDetailClick"
             @locate-message="handleLocateMessage"
-            @open-web="openWebTab"
+            @open-web="handleOpenWebLink"
           />
 
           <!-- 附件列表面板 -->
@@ -89,7 +89,7 @@
             @ask-user-cancel="handleAskUserCancel"
             @agent-detail-click="handleAgentDetailClick"
             @open-markdown-tab="openMarkdownPreviewTab"
-            @open-web="openWebTab"
+            @open-web="handleOpenWebLink"
             @delete-message="handleDeleteMessage"
             @regenerate="handleRegenerate"
             @edit-resend="handleEditResend"
@@ -135,36 +135,33 @@
           />
         </div>
 
-        <!-- 右侧辅助面板(标签页容器,与 chat-panel 并列,带滑入滑出动画) -->
-        <Transition name="right-panel-slide" @after-leave="onPanelLeaveEnd">
-          <!-- <RightPanelTabs
-            v-if="currentRightPanels.tabs.length > 0"
-            :tabs="currentRightPanels.tabs"
-            :activeTabId="currentRightPanels.activeTabId"
-            :messages="messages"
-            @switch-tab="activateTab"
-            @close-tab="closeTab"
-            @save-success="handleSaveSuccess"
-            @schedule-deleted="handleScheduleDeleted"
-            @schedule-updated="handleScheduleUpdated"
-            @open-schedule-session="handleOpenScheduleSession"
-            @open-markdown-tab="openMarkdownPreviewTab"
-          /> -->
-          <RightPanelTabs
-            :tabs="currentRightPanels.tabs"
-            :activeTabId="currentRightPanels.activeTabId"
-            :messages="messages"
-            @switch-tab="activateTab"
-            @close-tab="closeTab"
-            @save-success="handleSaveSuccess"
-            @schedule-deleted="handleScheduleDeleted"
-            @schedule-updated="handleScheduleUpdated"
-            @open-schedule-session="handleOpenScheduleSession"
-            @open-markdown-tab="openMarkdownPreviewTab"
-          />
-        </Transition>
       </template>
 
+      <!--
+        右侧辅助面板(标签页容器):
+        - 始终挂载,用 v-show 控制显隐;这样关闭二维标签后 iframe 仍保留,只隐藏不销毁;
+        - 处于技能/子智能体/定时任务面板时同样只隐藏;
+        - 关闭整个助手弹框时,由 Modal 的 destroyOnClose 真正销毁。
+      -->
+      <Transition name="right-panel-slide" @after-leave="onPanelLeaveEnd">
+        <RightPanelTabs
+          v-show="activePanel === 'chat' && currentRightPanels.tabs.length > 0"
+          :tabs="currentRightPanels.tabs"
+          :activeTabId="currentRightPanels.activeTabId"
+          :messages="messages"
+          :model-preview-loaded="modelPreview.loaded"
+          :model-preview-visible="modelPreviewVisible"
+          :model-preview-route="modelPreview.route"
+          @switch-tab="activateTab"
+          @close-tab="closeTab"
+          @save-success="handleSaveSuccess"
+          @schedule-deleted="handleScheduleDeleted"
+          @schedule-updated="handleScheduleUpdated"
+          @open-schedule-session="handleOpenScheduleSession"
+          @open-markdown-tab="openMarkdownPreviewTab"
+        />
+      </Transition>
+
       <!-- 技能面板:非管理员只读(面板内部按 isAdmin 隐藏/禁用写操作) -->
       <div v-if="activePanel === 'skill'" class="right-panel full">
         <SkillPanel :is-admin="isAdmin" @close="activePanel = 'chat'" />
@@ -189,7 +186,7 @@
 </template>
 
 <script setup lang="ts">
-  import { ref, nextTick, watch, computed, onBeforeUnmount } from 'vue';
+  import { ref, nextTick, watch, computed, onBeforeUnmount, reactive } from 'vue';
   import dayjs from 'dayjs';
   // [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3):引入 getMe 用于按 is_admin 隐藏管理入口
   import { getDetail, deleteSession, deleteMessage, StreamError, getMe } from '/@/views/ventAI/manageAssistent/api';
@@ -205,8 +202,10 @@
   import SchedulePanel from '/@/views/ventAI/manageAssistent/components/panels/SchedulePanel.vue';
   import RightPanelTabs from '/@/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue';
   import { getScheduleList } from '/@/views/ventAI/manageAssistent/api';
-  import type { AttachedFile, Message, ScheduleTask, Task } from '/@/views/ventAI/manageAssistent/types/index';
+  import type { AttachedFile, Message, ScheduleTask, SubAppRoute, Task } from '/@/views/ventAI/manageAssistent/types/index';
   import { buildCapsuleFiles, buildCapsuleLinks, buildCapsuleAgents } from '/@/views/ventAI/manageAssistent/lib/capsule';
+  import { parseModalPreviewLink } from '/@/views/ventAI/manageAssistent/lib/webLink';
+  import { locateDevice, locateDeviceReport, locateTunnel } from '/@/views/ventAI/manageAssistent/lib/modalPreviewBridge';
   import { useChatStream } from '../hooks/useChatStream';
   import { useChatTasks } from '../hooks/useChatTasks';
   import { useChatPanels } from '../hooks/useChatPanels';
@@ -299,6 +298,7 @@
     openMarkdownPreviewTab,
     openFilePreview,
     openWebTab,
+    openModalPreviewTab,
     handleOpenFilePreview,
     handleSaveSuccess,
     getCurrentTaskFiles,
@@ -315,6 +315,186 @@
   const { loadHistoryAttachments, handleAttachmentPreview: previewAttachment, handleAttachmentDownload } = useChatAttachments();
   const handleAttachmentPreview = (file: AttachedFile) => previewAttachment(file, openFilePreview);
 
+  // ===== 二维模型生命周期:单活模型 + 关闭隐藏 + 空闲超时销毁 =====
+  interface ModelPreviewCache {
+    loaded: boolean;
+    ownerSessionId: string;
+    modelId: string;
+    route: SubAppRoute | null;
+  }
+
+  // 标签关闭/切换到其他面板时模型只隐藏;超过该时长未再次显示才真正销毁
+  const MODEL_PREVIEW_IDLE_MS = 5 * 60 * 1000;
+  const modelPreview = reactive<ModelPreviewCache>({
+    loaded: false,
+    ownerSessionId: '',
+    modelId: '',
+    route: null,
+  });
+  let modelPreviewIdleTimer: ReturnType<typeof setTimeout> | null = null;
+  let modelPreviewTransition: Promise<void> | null = null;
+  let pendingModelPreviewRoute: SubAppRoute | null = null;
+  let modelPreviewEpoch = 0;
+  let lastModelLocateSignature = '';
+
+  // 当前会话归属:优先用后端 session_id,空白任务用前端任务 id 兜底
+  const currentSessionKey = (): string => {
+    const task = taskList.value.find((t) => t.id === currentTaskId.value);
+    return task?.sessionId || task?.id || '';
+  };
+
+  // 只有“二维标签处于激活状态 + 聊天面板可见 + 模型已加载”才算可见
+  const modelPreviewVisible = computed(() => {
+    if (activePanel.value !== 'chat' || !modelPreview.loaded) return false;
+    const state = currentRightPanels.value;
+    const active = state.tabs.find((tab) => tab.id === state.activeTabId);
+    return active?.type === 'model2d';
+  });
+
+  const clearModelPreviewIdleTimer = () => {
+    if (modelPreviewIdleTimer !== null) {
+      clearTimeout(modelPreviewIdleTimer);
+      modelPreviewIdleTimer = null;
+    }
+  };
+
+  const destroyModelPreview = () => {
+    modelPreviewEpoch++;
+    pendingModelPreviewRoute = null;
+    clearModelPreviewIdleTimer();
+    modelPreview.loaded = false;
+    modelPreview.modelId = '';
+    modelPreview.route = null;
+    modelPreview.ownerSessionId = '';
+    lastModelLocateSignature = '';
+  };
+
+  const scheduleModelPreviewIdleDestroy = () => {
+    clearModelPreviewIdleTimer();
+    if (!modelPreview.loaded) return;
+    modelPreviewIdleTimer = setTimeout(() => {
+      modelPreviewIdleTimer = null;
+      destroyModelPreview();
+    }, MODEL_PREVIEW_IDLE_MS);
+  };
+
+  /**
+   * 同一模型下只做定位,不重载 iframe:
+   * tunid → 巷道定位,locationId → 设备定位,locationName → 报表定位。
+   */
+  const applyModelLocate = (route: SubAppRoute | null) => {
+    const query = route?.query || {};
+    const modelId = query.model_id || modelPreview.modelId;
+    if (!modelId) return;
+    const signature = [modelId, query.tunid || '', query.locationId || '', query.locationName || '', query.deviceType || ''].join('|');
+    if (signature === lastModelLocateSignature) return;
+    lastModelLocateSignature = signature;
+
+    const deviceType = query.deviceType || (query.tunid ? 'tun' : 'fanlocal');
+    if (query.tunid) {
+      locateTunnel({ model_id: modelId, tunid: query.tunid, deviceType });
+    } else if (query.locationId) {
+      locateDevice({ model_id: modelId, deviceType, locationId: query.locationId });
+    } else if (query.locationName) {
+      locateDeviceReport({ model_id: modelId, deviceType, locationName: query.locationName });
+    }
+  };
+
+  /**
+   * 确保当前加载的模型与目标路由一致:
+   * - 未加载:加载;
+   * - model_id 相同:只隐藏/显示,定位参数走 bridge,不重载;
+   * - model_id 不同:先卸载旧 iframe,下一帧再挂载新模型。
+   */
+  const ensureModelPreview = async (route: SubAppRoute | null) => {
+    if (!route) return;
+    const modelId = route.query?.model_id || '';
+
+    if (modelPreview.loaded && modelPreview.modelId === modelId) {
+      modelPreview.ownerSessionId = currentSessionKey();
+      applyModelLocate(route);
+      return;
+    }
+
+    pendingModelPreviewRoute = route;
+    if (modelPreviewTransition) return;
+
+    modelPreviewTransition = (async () => {
+      while (pendingModelPreviewRoute) {
+        const target = pendingModelPreviewRoute;
+        pendingModelPreviewRoute = null;
+        const targetModelId = target.query?.model_id || '';
+
+        if (modelPreview.loaded && modelPreview.modelId === targetModelId) {
+          modelPreview.ownerSessionId = currentSessionKey();
+          applyModelLocate(target);
+          continue;
+        }
+
+        const epoch = modelPreviewEpoch;
+        clearModelPreviewIdleTimer();
+        modelPreview.loaded = false;
+        // 先让旧 iframe 真正卸载,再挂载新模型,避免同帧复用组件导致旧模型残留
+        await nextTick();
+        if (epoch !== modelPreviewEpoch) return;
+
+        modelPreview.route = target;
+        modelPreview.modelId = targetModelId;
+        modelPreview.ownerSessionId = currentSessionKey();
+        lastModelLocateSignature = '';
+        modelPreview.loaded = true;
+      }
+    })().finally(() => {
+      modelPreviewTransition = null;
+      if (pendingModelPreviewRoute) {
+        void ensureModelPreview(pendingModelPreviewRoute);
+      }
+    });
+
+    await modelPreviewTransition;
+  };
+
+  // 会话切换、标签激活/关闭、面板切换后,按当前激活标签同步模型状态
+  const syncModelPreviewForActiveTab = () => {
+    if (!props.visible || activePanel.value !== 'chat') return;
+    const state = currentRightPanels.value;
+    const active = state.tabs.find((tab) => tab.id === state.activeTabId);
+    if (active?.type !== 'model2d') return;
+    void ensureModelPreview(active.subAppRoute || { path: '/modal-preview' });
+  };
+
+  const openModelPreview = (route: SubAppRoute, title?: string) => {
+    openModalPreviewTab({ title: title || '二维模型', subAppRoute: route });
+    void ensureModelPreview(route);
+  };
+
+  // 隐藏后开始 5 分钟空闲倒计时;重新显示立即取消
+  watch(modelPreviewVisible, (visible) => {
+    if (visible) clearModelPreviewIdleTimer();
+    else scheduleModelPreviewIdleDestroy();
+  });
+
+  watch(
+    () => [currentTaskId.value, currentRightPanels.value.activeTabId, activePanel.value] as const,
+    () => syncModelPreviewForActiveTab()
+  );
+
+  /**
+   * 聊天链接分流:链接中包含 modal-preview 时打开二维模型标签,并把链接参数传给子应用;
+   * 普通 http(s) 链接仍走右侧 Web 面板。
+   */
+  const handleOpenWebLink = (payload: { url: string; title?: string }) => {
+    const route = parseModalPreviewLink(payload.url);
+    if (route) {
+      openModelPreview(route, payload.title);
+      return;
+    }
+    openWebTab(payload);
+  };
+
+  // 二维模型标签/模型可由业务入口按需打开(宿主可通过组件 ref 调用)
+  defineExpose({ openModalPreviewTab, openModelPreview });
+
   // —— 拖拽/居中职责已下沉到 useChatDrag(与面板全屏共用 lib/modalWrap) ——
   const drag = useChatDrag({
     getIsFullscreen: () => isFullscreen.value,
@@ -708,6 +888,11 @@
           if (task.sessionId) {
             await deleteSession(task.sessionId);
           }
+          // 删除的是当前已加载模型所属的会话:直接销毁模型(而不是等空闲超时)
+          const ownerKey = task.sessionId || task.id;
+          if (modelPreview.ownerSessionId && modelPreview.ownerSessionId === ownerKey) {
+            destroyModelPreview();
+          }
           const realIndex = taskList.value.findIndex((t) => t.id === task.id);
           if (realIndex !== -1) {
             taskList.value.splice(realIndex, 1);
@@ -951,7 +1136,12 @@
 
         await nextTick(() => {
           chatMessagesRef.value?.forceScrollToBottom();
+          // 重新打开助手时,如果当前标签就是二维模型,按需重新加载
+          syncModelPreviewForActiveTab();
         });
+      } else {
+        // 关闭整个助手弹框:模型立即销毁,而不是隐藏
+        destroyModelPreview();
       }
     },
     { immediate: true }
@@ -968,6 +1158,8 @@
       activeFileReader.value.abort();
       activeFileReader.value = null;
     }
+    // 清掉二维模型的空闲销毁定时器,避免卸载后仍回调
+    clearModelPreviewIdleTimer();
   });
 </script>
 

+ 27 - 2
src/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue

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

+ 12 - 2
src/views/ventAI/manageAssistent/components/resource/ModalPreview.vue

@@ -29,13 +29,14 @@
 </template>
 
 <script setup lang="ts">
-  import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
+  import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
   import { apps } from '/@/qiankun/apps';
   import MicroLoadError from '/@/components/vent/micro/MicroLoadError.vue';
   import { useErrorLogStoreWithOut } from '/@/store/modules/errorLog';
   import { useUserStore } from '/@/store/modules/user';
   import { ErrorTypeEnum } from '/@/enums/exceptionEnum';
   import { isDevMode } from '/@/utils/env';
+  import type { SubAppRoute } from '/@/views/ventAI/manageAssistent/types/index';
   import {
     getLastLocateResult,
     isPreviewReady,
@@ -54,7 +55,6 @@
   // iframe 的 load 事件是文档级加载完成,不等待 Three.js 模型资源;60s 足够,避免加载失败时长时间转圈
   const LOAD_TIMEOUT_MS = 60000;
 
-  type SubAppRoute = { path: string; query?: Record<string, any> };
   const props = withDefaults(defineProps<{ subAppRoute?: SubAppRoute | null }>(), {
     subAppRoute: () => ({ path: DEFAULT_ROUTE }),
   });
@@ -121,6 +121,16 @@
     loading.value = false;
   };
 
+  // 同一标签页内切换子应用路由(iframe src 变化)时重新进入加载态;
+  // 子应用重新挂载后会再次发 ready,桥接层在 ready 后补发排队中的定位消息。
+  watch(iframeSrc, (next, prev) => {
+    if (!next || next === prev) return;
+    markPreviewNotReady();
+    loadFailed.value = false;
+    loading.value = true;
+    startLoadTimeout();
+  });
+
   // iframe 的 error 事件只在网络层失败时触发(HTTP 404 也会触发 load),所以仍保留超时兜底
   const handleError = () => {
     clearLoadTimeout();

+ 12 - 3
src/views/ventAI/manageAssistent/hooks/useChatAttachments.ts

@@ -1,7 +1,7 @@
 import { message } from 'ant-design-vue';
 import { fetchAttachmentBlob, getSessionAttachments } from '/@/views/ventAI/manageAssistent/api';
 import type { SessionAttachment } from '/@/views/ventAI/manageAssistent/api';
-import { isCsvFile, isDwgFile, isDxfFile, isExcelFile, isTextFile, isWordFile } from '/@/views/ventAI/manageAssistent/lib';
+import { isCsvFile, isDwgFile, isDxfFile, isExcelFile, isTextFile, isWordFile, getPreviewMimeType } from '/@/views/ventAI/manageAssistent/lib';
 import type { AttachedFile, Message, Task } from '/@/views/ventAI/manageAssistent/types/index';
 
 /**
@@ -46,6 +46,15 @@ const saveBlob = (blob: Blob, filename: string) => {
 const hasContent = (file: AttachedFile): boolean =>
   file.content !== undefined || file.arrayBuffer !== undefined || file.preview !== undefined || !!file.originalFile;
 
+/**
+ * 预览用的 blob 换成真实类型:附件下载接口统一回 application/octet-stream(还带 Content-Disposition: attachment),
+ * 浏览器凭这个类型认不出内容——PDF 放进 iframe 会被当成下载而不是显示。
+ */
+const withPreviewType = (file: AttachedFile, blob: Blob): Blob => {
+  const type = getPreviewMimeType(file.name, file.type || blob.type);
+  return type && type !== blob.type ? new Blob([blob], { type }) : blob;
+};
+
 /** 历史附件没有原始内容,首次预览时按文件类型取回并缓存到附件对象上 */
 const ensureAttachmentContent = async (file: AttachedFile): Promise<AttachedFile> => {
   if (hasContent(file)) return file;
@@ -58,10 +67,10 @@ const ensureAttachmentContent = async (file: AttachedFile): Promise<AttachedFile
   const blob = await fetchAttachmentBlob(file.fileId, file.downloadUrl);
   if (isTextFile(file.name) || isCsvFile(file.name) || isDxfFile(file.name)) {
     file.content = await blob.text();
-  } else if (isWordFile(file.name) || isExcelFile(file.name) || isDwgFile(file.name)) {
+  } else if (isWordFile(file.name) || isExcelFile(file.name)) {
     file.arrayBuffer = await blob.arrayBuffer();
   } else {
-    file.preview = URL.createObjectURL(blob);
+    file.preview = URL.createObjectURL(withPreviewType(file, blob));
   }
   file.originalFile = new File([blob], file.name, {
     type: file.type || blob.type || 'application/octet-stream',

+ 35 - 1
src/views/ventAI/manageAssistent/hooks/useChatPanels.ts

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

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

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

+ 7 - 3
src/views/ventAI/manageAssistent/lib/filePreview.ts

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

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

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

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

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