Răsfoiți Sursa

[Mod 0000] 修改AI助手链接点击逻辑

wangkeyi 1 săptămână în urmă
părinte
comite
4f642f4f34

+ 20 - 9
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -69,7 +69,7 @@
             @download-attachment="handleAttachmentDownload"
             @agent-detail-click="handleAgentDetailClick"
             @locate-message="handleLocateMessage"
-            @open-web="handleOpenWebLink"
+            @open-link="handleOpenLink"
           />
 
           <!-- 附件列表面板 -->
@@ -89,7 +89,7 @@
             @ask-user-cancel="handleAskUserCancel"
             @agent-detail-click="handleAgentDetailClick"
             @open-markdown-tab="openMarkdownPreviewTab"
-            @open-web="handleOpenWebLink"
+            @open-link="handleOpenLink"
             @delete-message="handleDeleteMessage"
             @regenerate="handleRegenerate"
             @edit-resend="handleEditResend"
@@ -134,7 +134,6 @@
             @update:selected-skill="handleUpdateSelectedSkill"
           />
         </div>
-
       </template>
 
       <!--
@@ -204,7 +203,7 @@
   import { getScheduleList } from '/@/views/ventAI/manageAssistent/api';
   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 { parseModalPreviewLink, isFileDownloadLink, downloadLinkFile } from '/@/views/ventAI/manageAssistent/lib/index';
   import { locateDevice, locateDeviceReport, locateTunnel } from '/@/views/ventAI/manageAssistent/lib/modalPreviewBridge';
   import { useChatStream } from '../hooks/useChatStream';
   import { useChatTasks } from '../hooks/useChatTasks';
@@ -297,7 +296,6 @@
     handleLocateMessage,
     openMarkdownPreviewTab,
     openFilePreview,
-    openWebTab,
     openModalPreviewTab,
     handleOpenFilePreview,
     handleSaveSuccess,
@@ -486,16 +484,29 @@
   );
 
   /**
-   * 聊天链接分流:链接中包含 modal-preview 时打开二维模型标签,并把链接参数传给子应用;
-   * 普通 http(s) 链接仍走右侧 Web 面板。
+   * 聊天链接分流:
+   *   1) 链接里带 modal-preview → 右侧面板打开二维模型预览,并把链接参数传给子应用;
+   *   2) 文件下载链接 → 直接下载(带令牌取回文件再保存);
+   *   3) 其余链接 → 浏览器新标签页打开。
    */
-  const handleOpenWebLink = (payload: { url: string; title?: string }) => {
+  const handleOpenLink = async (payload: { url: string; title?: string }) => {
+    if (!payload?.url) return;
+
     const route = parseModalPreviewLink(payload.url);
     if (route) {
       openModelPreview(route, payload.title);
       return;
     }
-    openWebTab(payload);
+
+    if (isFileDownloadLink(payload.url)) {
+      const close = message.loading('正在下载文件…', 0);
+      const handled = await downloadLinkFile(payload.url);
+      close();
+      if (!handled) message.error('文件下载失败,请稍后重试');
+      return;
+    }
+
+    window.open(payload.url, '_blank', 'noopener,noreferrer');
   };
 
   // 二维模型标签/模型可由业务入口按需打开(宿主可通过组件 ref 调用)

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

@@ -43,7 +43,7 @@
       @download-attachment="emit('download-attachment', $event)"
       @agent-detail-click="emit('agent-detail-click', $event)"
       @locate-message="emit('locate-message', $event)"
-      @open-web="emit('open-web', $event)"
+      @open-link="emit('open-link', $event)"
     />
 
     <!-- 会话导出 -->
@@ -105,8 +105,8 @@
     'agent-detail-click': [data: { messageIndex: number; agent: string; agentId?: string }];
     // 胶囊面板中点击"定位到消息",交给父组件滚动消息列表
     'locate-message': [messageIndex: number];
-    // 胶囊面板中点击站内链接,交给父组件在右侧面板用 iframe 打开
-    'open-web': [payload: { url: string; title?: string }];
+    // 胶囊面板中点击链接,交给父组件按类型分流(模型预览 / 文件下载 / 新标签页)
+    'open-link': [payload: { url: string; title?: string }];
   }>();
 
   // 导出气泡状态

+ 13 - 24
src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue

@@ -127,12 +127,7 @@
                 v-html="renderMarkdownCached(message.content, tableIcons)"
                 @click="handleMessageTextClick($event, index)"
               ></div>
-              <div
-                v-for="file in getMessageAttachments(message)"
-                :key="file.id"
-                class="attached-file-card"
-                @click="emit('file-preview', file)"
-              >
+              <div v-for="file in getMessageAttachments(message)" :key="file.id" class="attached-file-card" @click="emit('file-preview', file)">
                 <div class="file-icon-card" :style="{ backgroundImage: getFileIconVar(file.name) }"></div>
                 <div class="file-info-card">
                   <div class="file-name-card">{{ file.name }}</div>
@@ -162,13 +157,10 @@
 
       <!-- 无法按 message_id 挂到具体消息的历史附件:兜底展示 -->
       <div v-if="orphanAttachments?.length" class="orphan-attachments">
-        <div class="orphan-attachments-title">其他附件 <span>{{ orphanAttachments.length }}</span></div>
-        <div
-          v-for="file in orphanAttachments"
-          :key="file.id"
-          class="attached-file-card orphan-file-card"
-          @click="emit('file-preview', file)"
+        <div class="orphan-attachments-title"
+          >其他附件 <span>{{ orphanAttachments.length }}</span></div
         >
+        <div v-for="file in orphanAttachments" :key="file.id" class="attached-file-card orphan-file-card" @click="emit('file-preview', file)">
           <div class="file-icon-card" :style="{ backgroundImage: getFileIconVar(file.name) }"></div>
           <div class="file-info-card">
             <div class="file-name-card">{{ file.name }}</div>
@@ -212,8 +204,9 @@
     copyToClipboard,
     flashCopySuccess,
     getUserInputText,
-    panelOpenableUrl,
-    webTabTitle,
+    parseModalPreviewLink,
+    isFileDownloadLink,
+    resolveUrl,
   } 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';
@@ -238,7 +231,7 @@
     (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: 'open-link', payload: { url: string; title?: string }): void;
     (e: 'delete-message', message: Message): void;
     (e: 'regenerate'): void;
     (e: 'edit-resend', index: number, newContent: string): void;
@@ -527,21 +520,18 @@
   };
 
   /**
-   * 消息正文的点击分发:http(s) 链接一律改在右侧面板打开(不区分站内站外,判定见 lib/webLink.ts),
-   * 按住 Ctrl/Cmd/Shift 或点非 http 协议链接时保留原行为(新窗口)。
+   * 消息正文的点击分发:只有「二维模型预览」和「文件下载」两类链接需要接管,
+   * 交给父组件按链接类型分流(见 lib/linkAction.ts);其余链接保持浏览器原生行为(新标签页打开)。
    * 没点到链接时按原逻辑处理脚注跳转。
    * 正文是 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) {
+      const href = anchor.getAttribute('href');
+      if (parseModalPreviewLink(href) || isFileDownloadLink(href)) {
         e.preventDefault();
-        emit('open-web', { url, title: webTabTitle(url, anchor.textContent || '') });
+        emit('open-link', { url: resolveUrl(href), title: (anchor.textContent || '').trim() });
       }
       return;
     }
@@ -1140,7 +1130,6 @@
               padding: 0;
             }
           }
-
         }
       }
 

+ 0 - 10
src/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue

@@ -73,7 +73,6 @@
         @updated="emit('schedule-updated')"
         @open-session="handleOpenSession"
       />
-      <WebPreviewPanel v-else-if="activeTab?.type === 'web'" :url="activeTab.url" />
       <!-- 二维模型常驻宿主:标签关闭/切换只隐藏,不卸载;model_id 变化或超时销毁时由 loaded 控制卸载 -->
       <div v-if="modelPreviewLoaded" v-show="modelPreviewVisible" class="model-preview-host">
         <ModalPreview :sub-app-route="modelPreviewRoute" />
@@ -87,7 +86,6 @@
   import { computed, nextTick, ref } from 'vue';
   import { onClickOutside, useResizeObserver } from '@vueuse/core';
   import FilePreviewPanel from '/@/views/ventAI/manageAssistent/components/resource/FilePreviewPanel.vue';
-  import WebPreviewPanel from '/@/views/ventAI/manageAssistent/components/resource/WebPreviewPanel.vue';
   import ModalPreview from '/@/views/ventAI/manageAssistent/components/resource/ModalPreview.vue';
   import AgentDetailPanel from './AgentDetailPanel.vue';
   import ScheduleDetailPanel from './ScheduleDetailPanel.vue';
@@ -340,10 +338,6 @@
                 background-image: var(--img-chat-options-schedule-icon);
               }
 
-              .tab-icon-web {
-                background-image: var(--img-chat-link-icon);
-              }
-
               .tab-icon-model2d {
                 background-image: var(--img-chat-preview-icon);
               }
@@ -463,10 +457,6 @@
         background-image: var(--img-chat-options-schedule-icon);
       }
 
-      .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);
       }

+ 4 - 9
src/views/ventAI/manageAssistent/components/resource/ResourceCapsule.vue

@@ -140,7 +140,7 @@
   import { CloseOutlined } from '@ant-design/icons-vue';
   import { onClickOutside, onKeyStroke } from '@vueuse/core';
   import { message } from 'ant-design-vue';
-  import { formatFileSize, getFileIconVar, fetchPreviewFile, panelOpenableUrl, webTabTitle } from '/@/views/ventAI/manageAssistent/lib/index';
+  import { formatFileSize, getFileIconVar, fetchPreviewFile, resolveUrl } from '/@/views/ventAI/manageAssistent/lib/index';
   import type { AttachedFile, CapsuleFileItem, CapsuleLinkItem, CapsuleAgentItem } from '/@/views/ventAI/manageAssistent/types/index';
 
   const props = defineProps<{
@@ -156,7 +156,7 @@
     (e: 'download-attachment', file: AttachedFile): void;
     (e: 'agent-detail-click', data: { messageIndex: number; agent: string; agentId?: string }): void;
     (e: 'locate-message', messageIndex: number): void;
-    (e: 'open-web', payload: { url: string; title?: string }): void;
+    (e: 'open-link', payload: { url: string; title?: string }): void;
   }>();
 
   const wrapRef = ref<HTMLElement>();
@@ -293,15 +293,10 @@
     }
   };
 
-  // http(s) 链接一律在右侧面板打开(不区分站内站外),非 http 协议或非法地址仍按原行为新标签页打开
+  // 链接统一交给父组件分流:二维模型预览 / 文件下载 / 其余新标签页打开
   const openLink = (link: CapsuleLinkItem) => {
     if (!link.href) return;
-    const url = panelOpenableUrl(link.href);
-    if (url) {
-      emit('open-web', { url, title: webTabTitle(url, link.title) });
-      return;
-    }
-    window.open(link.href, '_blank', 'noopener,noreferrer');
+    emit('open-link', { url: resolveUrl(link.href), title: link.title });
   };
 
   const agentStatusText = (status: CapsuleAgentItem['status']): string => {

+ 0 - 452
src/views/ventAI/manageAssistent/components/resource/WebPreviewPanel.vue

@@ -1,452 +0,0 @@
-<template>
-  <div class="web-preview-panel">
-    <!-- 地址栏:面板里已经在浏览的真实地址(同源,可读 location),随时能复制或跳回新窗口 -->
-    <div class="web-toolbar">
-      <div class="web-address-row">
-        <div class="web-url" :title="url">
-          <span class="web-url-icon"></span>
-          <span class="web-url-text">{{ displayUrl }}</span>
-        </div>
-      </div>
-      <div class="web-controls-row">
-        <a-select
-          v-model:value="sizeKey"
-          class="web-size-select"
-          size="small"
-          :options="sizeOptions"
-          :dropdown-match-select-width="false"
-          dropdown-class-name="web-size-dropdown"
-        />
-        <div class="web-actions">
-          <button type="button" class="web-action-btn" title="刷新" @click="reload">
-            <span class="web-action-icon web-action-icon-refresh"></span>
-            <span>刷新</span>
-          </button>
-          <button type="button" class="web-action-btn" title="在新窗口打开" @click="openInBrowser">
-            <span class="web-action-icon web-action-icon-export"></span>
-            <span>新窗口</span>
-          </button>
-          <button type="button" class="web-action-btn" title="复制链接" @click="copyUrl">
-            <span class="web-action-icon web-action-icon-copy"></span>
-            <span>复制链接</span>
-          </button>
-        </div>
-      </div>
-    </div>
-    <!--
-      舞台:iframe 按选定分辨率(默认 1280×720)作为「真实视口」渲染,再整体等比缩放到面板里显示。
-      内网系统基本是按固定设计稿尺寸做的、不响应式,直接铺满窄面板会被挤变形,所以要这样缩。
-      只缩不放(放大会糊),四周留白居中;选「自适应面板」时退回铺满、不缩放。
-    -->
-    <div ref="stageRef" class="web-stage">
-      <div v-if="loading" class="web-loading">
-        <span class="web-loading-spinner"></span>
-        <span>正在加载页面…</span>
-      </div>
-      <div class="web-viewport" :class="{ 'is-loading': loading }" :style="viewportStyle">
-        <iframe ref="frameRef" class="web-frame" :src="url" frameborder="0" @load="loading = false"></iframe>
-      </div>
-    </div>
-  </div>
-</template>
-
-<script setup lang="ts">
-  import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
-  import { message } from 'ant-design-vue';
-  import { copyToClipboard } from '/@/views/ventAI/manageAssistent/lib/index';
-
-  /**
-   * 右侧面板的网页预览:http(s) 地址直接 iframe 嵌入(判定见 lib/webLink.ts,当前不区分站内站外)。
-   *
-   * 不加 sandbox:地址多为内网系统,加了(尤其不带 allow-same-origin 时)会让它变成不透明来源,
-   * 登录态、下载、弹窗等能力都受影响。要收紧的话给 iframe 加
-   * sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads" 即可。
-   */
-  const props = defineProps<{ url: string }>();
-
-  const frameRef = ref<HTMLIFrameElement>();
-  const stageRef = ref<HTMLElement>();
-  const loading = ref(true);
-
-  // 预设视口尺寸:内网系统按设计稿分辨率渲染才不会变形
-  const SIZE_PRESETS = [
-    { value: '1280x720', label: '1280 × 720', width: 1280, height: 720 },
-    { value: '1440x900', label: '1440 × 900', width: 1440, height: 900 },
-    { value: '1920x1080', label: '1920 × 1080', width: 1920, height: 1080 },
-    { value: 'fit', label: '自适应面板', width: 0, height: 0 },
-  ];
-  const sizeOptions = SIZE_PRESETS.map((p) => ({ value: p.value, label: p.label }));
-  const sizeKey = ref('1280x720');
-  const preset = computed(() => SIZE_PRESETS.find((p) => p.value === sizeKey.value) || SIZE_PRESETS[0]);
-
-  // 舞台可用尺寸:缩放比要跟着面板大小变(全屏/收起面板时都要重算)
-  const stageSize = ref({ width: 0, height: 0 });
-  const scale = computed(() => {
-    const p = preset.value;
-    const { width, height } = stageSize.value;
-    if (!p.width || !p.height || !width || !height) return 1;
-    return Math.min(1, width / p.width, height / p.height);
-  });
-  const viewportStyle = computed(() => {
-    const p = preset.value;
-    if (!p.width || !p.height) return { top: '0', left: '0', width: '100%', height: '100%', transform: 'none' };
-    return {
-      top: '50%',
-      left: '50%',
-      width: `${p.width}px`,
-      height: `${p.height}px`,
-      transform: `translate(-50%, -50%) scale(${scale.value})`,
-    };
-  });
-
-  let resizeObserver: ResizeObserver | null = null;
-  onMounted(() => {
-    const el = stageRef.value;
-    if (!el) return;
-    const measure = () => {
-      stageSize.value = { width: el.clientWidth, height: el.clientHeight };
-    };
-    resizeObserver = new ResizeObserver(measure);
-    resizeObserver.observe(el);
-    measure();
-  });
-
-  onBeforeUnmount(() => {
-    resizeObserver?.disconnect();
-    resizeObserver = null;
-  });
-
-  // 地址栏文案:同源页面显示路径,外部页面显示域名 + 路径;完整地址放在 title 上。
-  // 之前只取 pathname,外部站点根路径会显示成孤零零的 "/"。
-  const displayUrl = computed(() => {
-    try {
-      const u = new URL(props.url, window.location.href);
-      const path = `${u.pathname}${u.search}${u.hash}`;
-      if (u.origin === window.location.origin) return path || '/';
-      return `${u.host}${path === '/' ? '' : path}`;
-    } catch {
-      return props.url;
-    }
-  });
-
-  // 换地址(同一标签页复用实例时)重新进加载态
-  watch(
-    () => props.url,
-    () => {
-      loading.value = true;
-    }
-  );
-
-  /** iframe 里当前真实地址:可读时用它;跳到读不到的页面(跨域)时退回标签原始地址 */
-  const currentHref = (): string => {
-    try {
-      return frameRef.value?.contentWindow?.location.href || props.url;
-    } catch {
-      return props.url;
-    }
-  };
-
-  const reload = () => {
-    const win = frameRef.value?.contentWindow;
-    try {
-      // 刷新 iframe 内的当前页,而不是回到打开时的初始地址
-      loading.value = true;
-      win?.location.reload();
-    } catch {
-      // 跨域读不到时退回重新加载 src
-      if (frameRef.value) frameRef.value.src = props.url;
-    }
-  };
-
-  const openInBrowser = () => {
-    window.open(currentHref(), '_blank', 'noopener,noreferrer');
-  };
-
-  const copyUrl = () => {
-    copyToClipboard(currentHref()).then(() => message.success('链接已复制'));
-  };
-</script>
-
-<style scoped lang="less">
-  .web-preview-panel {
-    display: flex;
-    flex-direction: column;
-    height: 100%;
-    min-height: 0;
-    background: linear-gradient(180deg, rgb(8 37 58 / 98%), rgb(4 22 36 / 98%));
-  }
-
-  .web-toolbar {
-    display: flex;
-    flex-direction: column;
-    gap: 8px;
-    flex-shrink: 0;
-    padding: 10px 12px;
-    border-bottom: 1px solid rgb(63 80 106 / 35%);
-    background: rgb(2 18 30 / 35%);
-  }
-
-  .web-address-row {
-    display: flex;
-    align-items: center;
-    gap: 8px;
-  }
-
-  .web-url {
-    display: flex;
-    flex: 1;
-    align-items: center;
-    gap: 8px;
-    min-width: 0;
-    height: 30px;
-    padding: 0 10px;
-    border: 1px solid rgb(63 80 106 / 50%);
-    border-radius: 6px;
-    background: rgb(1 16 27 / 72%);
-    color: #c9d1d9;
-    font-size: 12px;
-    transition:
-      border-color 0.18s,
-      background 0.18s;
-
-    &:hover {
-      border-color: rgb(10 132 255 / 40%);
-      background: rgb(1 20 34 / 90%);
-    }
-
-    .web-url-icon {
-      width: 14px;
-      height: 14px;
-      flex-shrink: 0;
-      background-image: var(--img-chat-link-icon);
-      background-repeat: no-repeat;
-      background-size: 100% 100%;
-      opacity: 0.8;
-    }
-
-    .web-url-text {
-      min-width: 0;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-      letter-spacing: 0.2px;
-    }
-  }
-
-  .web-controls-row {
-    display: flex;
-    align-items: center;
-    gap: 8px;
-    flex-wrap: wrap;
-    min-width: 0;
-  }
-
-  .web-size-select {
-    width: 118px;
-    height: 28px;
-    flex-shrink: 0;
-
-    :deep(.zxm-select-selector) {
-      height: 28px !important;
-      padding: 0 9px !important;
-      border-color: rgb(63 80 106 / 50%) !important;
-      border-radius: 6px !important;
-      background: rgb(1 16 27 / 72%) !important;
-      box-shadow: none !important;
-      transition:
-        border-color 0.18s,
-        background 0.18s;
-    }
-
-    :deep(.zxm-select-selection-item) {
-      color: #c9d1d9 !important;
-      font-size: 12px;
-      line-height: 26px !important;
-    }
-
-    :deep(.zxm-select-arrow) {
-      color: #8b949e !important;
-    }
-
-    &:hover :deep(.zxm-select-selector) {
-      border-color: rgb(10 132 255 / 40%) !important;
-      background: rgb(1 20 34 / 90%) !important;
-    }
-
-    &.zxm-select-focused :deep(.zxm-select-selector) {
-      border-color: #5cb8ff !important;
-      box-shadow: 0 0 0 2px rgb(10 132 255 / 15%) !important;
-    }
-  }
-
-  .web-actions {
-    display: flex;
-    align-items: center;
-    gap: 4px;
-    margin-left: auto;
-    flex-shrink: 0;
-  }
-
-  .web-action-btn {
-    display: inline-flex;
-    align-items: center;
-    gap: 4px;
-    height: 28px;
-    padding: 0 9px;
-    border: 1px solid transparent;
-    border-radius: 6px;
-    background: transparent;
-    color: #8b949e;
-    font-size: 12px;
-    line-height: 1;
-    white-space: nowrap;
-    cursor: pointer;
-    transition:
-      color 0.18s,
-      background 0.18s,
-      border-color 0.18s;
-
-    .web-action-icon {
-      width: 14px;
-      height: 14px;
-      flex-shrink: 0;
-      background-repeat: no-repeat;
-      background-size: 100% 100%;
-      opacity: 0.7;
-      transition: opacity 0.18s;
-    }
-
-    .web-action-icon-refresh {
-      background-image: var(--img-chat-regenerate-icon);
-    }
-
-    .web-action-icon-export {
-      background-image: var(--img-chat-export-icon);
-    }
-
-    .web-action-icon-copy {
-      background-image: var(--img-chat-copy-icon);
-    }
-
-    &:hover {
-      border-color: rgb(10 132 255 / 22%);
-      background: rgb(10 132 255 / 12%);
-      color: #e0e6ed;
-
-      .web-action-icon {
-        opacity: 1;
-      }
-    }
-
-    &:active {
-      background: rgb(10 132 255 / 20%);
-    }
-
-    &:focus-visible {
-      outline: 1px solid #5cb8ff;
-      outline-offset: 1px;
-    }
-
-    /* 鼠标点击后不保留持续高亮,只有键盘聚焦才显示描边 */
-    &:focus:not(:focus-visible) {
-      outline: none;
-    }
-  }
-
-  .web-stage {
-    position: relative;
-    flex: 1;
-    min-height: 0;
-    margin: 10px 12px 12px;
-    overflow: hidden;
-    border: 1px solid rgb(63 80 106 / 35%);
-    border-radius: 8px;
-    // 缩放后四周的留白底色,跟面板区分开,看起来像「一块屏幕」
-    background: radial-gradient(ellipse at 50% 0%, rgb(10 132 255 / 8%), transparent 60%), #03111d;
-    box-shadow:
-      inset 0 0 0 1px rgb(255 255 255 / 2%),
-      0 8px 24px rgb(0 0 0 / 25%);
-  }
-
-  .web-viewport {
-    position: absolute;
-    transform-origin: center center;
-    overflow: hidden;
-    border-radius: 4px;
-    background: #fff;
-    box-shadow: 0 8px 28px rgb(0 0 0 / 42%);
-
-    &.is-loading {
-      visibility: hidden;
-    }
-  }
-
-  .web-frame {
-    display: block;
-    width: 100%;
-    height: 100%;
-    border: none;
-  }
-
-  .web-loading {
-    position: absolute;
-    inset: 0;
-    z-index: 1;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    gap: 8px;
-    background: rgb(3 17 29 / 40%);
-    color: #8b949e;
-    font-size: 12px;
-
-    .web-loading-spinner {
-      width: 14px;
-      height: 14px;
-      border: 2px solid rgb(92 184 255 / 20%);
-      border-top-color: #5cb8ff;
-      border-radius: 50%;
-      animation: web-loading-spin 0.8s linear infinite;
-    }
-  }
-
-  @keyframes web-loading-spin {
-    to {
-      transform: rotate(360deg);
-    }
-  }
-</style>
-
-<style lang="less">
-  /*
-   * 下拉弹层挂在 body 下,scoped 样式够不到,只能写在这里;与面板深色风格保持一致。
-   */
-  .web-size-dropdown.zxm-select-dropdown {
-    padding: 4px !important;
-    border: 1px solid #1a4a6f !important;
-    border-radius: 6px !important;
-    background: #0a2a3f !important;
-    box-shadow: 0 6px 20px rgb(0 0 0 / 42%) !important;
-  }
-
-  .web-size-dropdown {
-    .zxm-select-item {
-      min-height: 28px;
-      padding: 4px 9px;
-      border-radius: 4px;
-      color: #c9d1d9 !important;
-      background: transparent !important;
-      font-size: 12px;
-    }
-
-    .zxm-select-item-option-active:not(.zxm-select-item-option-disabled) {
-      background: rgb(10 132 255 / 12%) !important;
-    }
-
-    .zxm-select-item-option-selected:not(.zxm-select-item-option-disabled) {
-      background: rgb(10 132 255 / 18%) !important;
-      color: #e0e6ed !important;
-      font-weight: 400;
-    }
-  }
-</style>

+ 0 - 18
src/views/ventAI/manageAssistent/hooks/useChatPanels.ts

@@ -1,6 +1,5 @@
 import { computed, nextTick, ref, watch } from 'vue';
 import { applyWrapStyles } from '../lib/modalWrap';
-import { webTabTitle } from '../lib/webLink';
 import type {
   AgentDetailData,
   AttachedFile,
@@ -184,22 +183,6 @@ export const useChatPanels = (deps: ChatPanelsDeps) => {
     }
   };
 
-  /**
-   * 在右侧面板用 iframe 打开链接,同一地址复用同一个标签。
-   * 只接受已经过判定可进面板的地址(见 lib/webLink.ts 的 panelOpenableUrl),非 http(s) 的不要走这里。
-   */
-  const openWebTab = (payload: { url: string; title?: string }) => {
-    const task = getCurrentTask();
-    if (!task || !payload?.url) return;
-    const state = getPanelState(task);
-    const existing = state.tabs.find((t) => t.type === 'web' && t.url === payload.url);
-    if (existing) {
-      state.activeTabId = existing.id;
-      return;
-    }
-    openTab({ id: nextTabId(), type: 'web', title: webTabTitle(payload.url, payload.title), url: payload.url });
-  };
-
   /**
    * 打开二维模型预览标签:与文件/网页预览同级,仅在激活该标签时挂载 iframe。
    * 同一任务只保留一个二维标签;再次调用会激活已有标签,并可按需更新标题/初始子应用路由。
@@ -309,7 +292,6 @@ export const useChatPanels = (deps: ChatPanelsDeps) => {
     handleLocateMessage,
     openMarkdownPreviewTab,
     openFilePreview,
-    openWebTab,
     openModalPreviewTab,
     handleOpenFilePreview,
     handleSaveSuccess,

+ 1 - 1
src/views/ventAI/manageAssistent/lib/index.ts

@@ -9,4 +9,4 @@ export * from './messageKey';
 export * from './clipboard';
 export * from './userInput';
 export * from './turnRows';
-export * from './webLink';
+export * from './linkAction';

+ 140 - 0
src/views/ventAI/manageAssistent/lib/linkAction.ts

@@ -0,0 +1,140 @@
+// lib/linkAction.ts:消息里的链接点击后该怎么处理 —— 二维模型预览 / 文件下载 / 普通链接。
+//
+// 分流规则(见 AiAssistantModal 的 handleOpenLink):
+//   1) 链接里带 modal-preview 标记 → 右侧面板打开二维模型预览;
+//   2) 命中文件下载链接 → 直接下载文件;
+//   3) 其余 → 浏览器新标签页打开(交给浏览器原生行为)。
+import { getToken } from '/@/utils/auth/index';
+
+/** 二维模型预览链接解析结果 */
+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 };
+};
+
+/** 相对链接补成绝对地址;非法地址原样返回 */
+export const resolveUrl = (href?: string | null): string => {
+  const raw = (href || '').trim();
+  if (!raw) return '';
+  try {
+    return new URL(raw, window.location.href).href;
+  } catch {
+    return raw;
+  }
+};
+
+// 后端自己的下载地址(路径里可能没有扩展名,如 /api/download/report/xxx)
+const DOWNLOAD_ENDPOINT_RE = /\/api\/(?:download\/report|files)\//i;
+// 常见文件扩展名:链接路径以此结尾就按“下载文件”处理,而不是当网页打开
+const FILE_EXT_RE = /\.(pdf|docx?|xlsx?|pptx?|csv|txt|md|json|xml|log|zip|rar|7z|tar|gz|dwg|dxf|svg|png|jpe?g|gif|bmp|webp|tiff?)$/i;
+
+/** 是否文件下载链接:命中后端下载地址,或路径以常见文件扩展名结尾 */
+export const isFileDownloadLink = (href?: string | null): boolean => {
+  const raw = (href || '').trim();
+  if (!raw) return false;
+  if (DOWNLOAD_ENDPOINT_RE.test(raw)) return true;
+  try {
+    const path = decodeURIComponent(new URL(raw, window.location.href).pathname);
+    return FILE_EXT_RE.test(path);
+  } catch {
+    return FILE_EXT_RE.test(raw);
+  }
+};
+
+/** 从链接里取下载用的文件名,取不到就给个兜底名 */
+export const getDownloadFilename = (href: string): string => {
+  try {
+    const raw = new URL(href, window.location.href).pathname.split('/').filter(Boolean).pop() || '';
+    return decodeURIComponent(raw) || '下载文件';
+  } catch {
+    return '下载文件';
+  }
+};
+
+/**
+ * 从响应头里取真实文件名。
+ * 附件下载地址形如 /api/files/<file_id>,路径末段是 id 而不是文件名,真实名字只能从
+ * Content-Disposition 拿;兼容 filename=xxx 与 filename*=UTF-8''xxx(中文名)两种写法。
+ */
+const filenameFromResponse = (resp: Response): string => {
+  const disposition = resp.headers.get('content-disposition') || '';
+  const decode = (value: string): string => {
+    try {
+      return decodeURIComponent(value.trim().replace(/^"|"$/g, ''));
+    } catch {
+      return value.trim().replace(/^"|"$/g, '');
+    }
+  };
+  const utf8 = /filename\*=\s*(?:utf-8|UTF-8)''([^;]+)/i.exec(disposition);
+  if (utf8) return decode(utf8[1]);
+  const plain = /filename\s*=\s*([^;]+)/i.exec(disposition);
+  if (plain) return decode(plain[1]);
+  return '';
+};
+
+/**
+ * 下载链接指向的文件。
+ *
+ * 用带令牌的请求取回二进制再交给浏览器保存:AI 后端的下载地址需要令牌,直接开新标签会 401。
+ * 文件名优先取响应头里的(附件地址路径末段是 id),取不到再用路径末段。
+ * 取不到文件(跨域没有 CORS、网络错误等)时退回新标签打开,至少不会点了没反应。
+ * @returns 是否已处理(真正下载成功或已退回新标签打开)
+ */
+export const downloadLinkFile = async (href: string): Promise<boolean> => {
+  const url = resolveUrl(href);
+  if (!url) return false;
+
+  try {
+    const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
+    if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+    const blob = await resp.blob();
+    const objectUrl = URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = objectUrl;
+    a.download = filenameFromResponse(resp) || getDownloadFilename(url);
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    // 让浏览器先拿到下载任务再释放对象地址
+    setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
+    return true;
+  } catch (err) {
+    console.warn('链接下载失败,改为新标签打开:', err);
+    window.open(url, '_blank', 'noopener,noreferrer');
+    return true;
+  }
+};

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

@@ -1,75 +0,0 @@
-// lib/webLink.ts:判断「给个链接能不能丢进右侧面板 iframe 打开」,以及生成标签标题。
-//
-// 当前不区分站内站外:只要是 http(s) 地址就放行(后端返回的内网系统地址多为同域不同端口,
-// 按同源判定会被排除,先用「全放开」)—— 具体见需求:先不做限制,后续再收紧。
-// 非 http(s) 协议(mailto/tel/javascript/data/blob)和页内锚点(#xxx)无法进 iframe,仍返回 null。
-//
-// 注意:外链能否真的被嵌由对方站点决定(X-Frame-Options: DENY/SAMEORIGIN 或 CSP frame-ancestors 会被
-// 浏览器直接拒绝,面板里只会一片空白,且跨域读不到内容、前端无法可靠识别),所以面板里始终保留
-// 「新窗口打开」兜底。以后要收紧成白名单/同源,只改这里的判定即可。
-
-/** 取出可放进面板的绝对地址;http(s) 以外的协议、页内锚点、空链接返回 null */
-export const panelOpenableUrl = (href?: string | null): string | null => {
-  const raw = (href || '').trim();
-  if (!raw || raw.startsWith('#')) return null;
-  try {
-    const url = new URL(raw, window.location.href);
-    if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
-    return url.href;
-  } catch {
-    // 非法 URL
-    return 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();
-  if (label) return label;
-  try {
-    const url = new URL(href, window.location.href);
-    return url.pathname + url.search || url.host;
-  } catch {
-    return href;
-  }
-};

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

@@ -19,9 +19,7 @@ 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 }
-  // 二维通风模型:与文件/网页预览同级,仅激活该标签时挂载 ModalPreview
+  // 二维通风模型:与文件预览同级,仅激活该标签时挂载 ModalPreview
   | { id: string; type: 'model2d'; title: string; subAppRoute?: SubAppRoute | null };
 
 export interface RightPanelState {