Преглед изворни кода

[Feat 0000]AI助手增加打开网页链接面板

wangkeyi пре 2 дана
родитељ
комит
e3d4295caf

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

@@ -68,6 +68,7 @@
             @file-preview="openFilePreview"
             @agent-detail-click="handleAgentDetailClick"
             @locate-message="handleLocateMessage"
+            @open-web="openWebTab"
           />
 
           <!-- 附件列表面板 -->
@@ -85,6 +86,7 @@
             @ask-user-cancel="handleAskUserCancel"
             @agent-detail-click="handleAgentDetailClick"
             @open-markdown-tab="openMarkdownPreviewTab"
+            @open-web="openWebTab"
             @delete-message="handleDeleteMessage"
             @regenerate="handleRegenerate"
             @edit-resend="handleEditResend"
@@ -280,6 +282,7 @@
     handleLocateMessage,
     openMarkdownPreviewTab,
     openFilePreview,
+    openWebTab,
     handleOpenFilePreview,
     handleSaveSuccess,
     getCurrentTaskFiles,

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

@@ -42,6 +42,7 @@
       @file-preview="emit('file-preview', $event)"
       @agent-detail-click="emit('agent-detail-click', $event)"
       @locate-message="emit('locate-message', $event)"
+      @open-web="emit('open-web', $event)"
     />
 
     <!-- 会话导出 -->
@@ -102,6 +103,8 @@
     'agent-detail-click': [data: { messageIndex: number; agent: string; agentId?: string }];
     // 胶囊面板中点击"定位到消息",交给父组件滚动消息列表
     'locate-message': [messageIndex: number];
+    // 胶囊面板中点击站内链接,交给父组件在右侧面板用 iframe 打开
+    'open-web': [payload: { url: string; title?: string }];
   }>();
 
   // 导出气泡状态

+ 31 - 2
src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue

@@ -49,7 +49,7 @@
                   !message.isLoading
                 )
               "
-              @click="handleFootnoteClick($event, index)"
+              @click="handleMessageTextClick($event, index)"
             ></div>
 
             <!-- 数据来源卡片 -->
@@ -122,7 +122,11 @@
               </div>
             </template>
             <template v-else>
-              <div class="message-text" v-html="renderMarkdownCached(message.content, tableIcons)"></div>
+              <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">
@@ -172,6 +176,8 @@
     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';
@@ -193,6 +199,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: 'delete-message', message: Message): void;
     (e: 'regenerate'): void;
     (e: 'edit-resend', index: number, newContent: string): void;
@@ -470,6 +477,28 @@
     }
   };
 
+  /**
+   * 消息正文的点击分发: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);

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

@@ -73,6 +73,7 @@
         @updated="emit('schedule-updated')"
         @open-session="handleOpenSession"
       />
+      <WebPreviewPanel v-else-if="activeTab?.type === 'web'" :url="activeTab.url" />
     </div>
   </div>
 </template>
@@ -82,6 +83,7 @@
   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 AgentDetailPanel from './AgentDetailPanel.vue';
   import ScheduleDetailPanel from './ScheduleDetailPanel.vue';
   import { getFileIconVar } from '/@/views/ventAI/manageAssistent/lib/index';
@@ -327,6 +329,10 @@
                 background-image: var(--img-chat-options-schedule-icon);
               }
 
+              .tab-icon-web {
+                background-image: var(--img-chat-link-icon);
+              }
+
               .tab-list-item-title {
                 flex: 1;
                 color: #c9d1d9;
@@ -337,7 +343,6 @@
                 font-size: 12px;
               }
             }
-
           }
         }
       }
@@ -442,6 +447,10 @@
       .panel-tab-schedule .panel-tab-icon {
         background-image: var(--img-chat-options-schedule-icon);
       }
+
+      .panel-tab-web .panel-tab-icon {
+        background-image: var(--img-chat-link-icon);
+      }
     }
 
     .panel-tab-content {

+ 8 - 2
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 } from '/@/views/ventAI/manageAssistent/lib/index';
+  import { formatFileSize, getFileIconVar, fetchPreviewFile, panelOpenableUrl, webTabTitle } from '/@/views/ventAI/manageAssistent/lib/index';
   import type { AttachedFile, CapsuleFileItem, CapsuleLinkItem, CapsuleAgentItem } from '/@/views/ventAI/manageAssistent/types/index';
 
   const props = defineProps<{
@@ -155,6 +155,7 @@
     (e: 'file-preview', 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;
   }>();
 
   const wrapRef = ref<HTMLElement>();
@@ -286,9 +287,14 @@
     }
   };
 
-  // 与消息正文中 markdown 链接的渲染行为一致:新标签页打开
+  // 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');
   };
 

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

@@ -0,0 +1,220 @@
+<template>
+  <div class="web-preview-panel">
+    <!-- 地址栏:面板里已经在浏览的真实地址(同源,可读 location),随时能复制或跳回新窗口 -->
+    <div class="web-toolbar">
+      <div class="web-url" :title="url">{{ displayUrl }}</div>
+      <a-select v-model:value="sizeKey" class="web-size-select" size="small" :options="sizeOptions" :dropdown-match-select-width="false" />
+      <div class="web-actions">
+        <a-button size="small" type="link" @click="reload">刷新</a-button>
+        <a-button size="small" type="link" @click="openInBrowser">新窗口打开</a-button>
+        <a-button size="small" type="link" @click="copyUrl">复制链接</a-button>
+      </div>
+    </div>
+    <!--
+      舞台:iframe 按选定分辨率(默认 1280×720)作为「真实视口」渲染,再整体等比缩放到面板里显示。
+      内网系统基本是按固定设计稿尺寸做的、不响应式,直接铺满窄面板会被挤变形,所以要这样缩。
+      只缩不放(放大会糊),四周留白居中;选「自适应面板」时退回铺满、不缩放。
+    -->
+    <div ref="stageRef" class="web-stage">
+      <div v-if="loading" class="web-loading">正在加载页面…</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 上
+  const displayUrl = computed(() => {
+    try {
+      const u = new URL(props.url, window.location.href);
+      return u.pathname + u.search || u.href;
+    } 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;
+  }
+
+  .web-toolbar {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex-shrink: 0;
+    padding: 8px 10px;
+    border-bottom: 1px solid rgb(63 80 106 / 25%);
+
+    .web-url {
+      flex: 1;
+      min-width: 0;
+      font-size: 12px;
+      color: #8b949e;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      padding: 2px 8px;
+      border-radius: 4px;
+      background: rgb(255 255 255 / 5%);
+    }
+
+    .web-size-select {
+      width: 116px;
+      flex-shrink: 0;
+    }
+
+    .web-actions {
+      display: flex;
+      align-items: center;
+      gap: 2px;
+      flex-shrink: 0;
+    }
+  }
+
+  .web-stage {
+    position: relative;
+    flex: 1;
+    min-height: 0;
+    overflow: hidden;
+    // 缩放后四周的留白底色,跟面板区分开,看起来像「一块屏幕」
+    background: rgb(0 0 0 / 22%);
+  }
+
+  .web-viewport {
+    position: absolute;
+    transform-origin: center center;
+    overflow: hidden;
+    background: #fff;
+    box-shadow: 0 6px 22px rgb(0 0 0 / 40%);
+
+    &.is-loading {
+      visibility: hidden;
+    }
+  }
+
+  .web-frame {
+    display: block;
+    width: 100%;
+    height: 100%;
+    border: none;
+  }
+
+  .web-loading {
+    position: absolute;
+    inset: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 13px;
+    color: #8b949e;
+  }
+</style>

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

@@ -1,5 +1,6 @@
 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';
 
 /**
@@ -175,6 +176,22 @@ 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 });
+  };
+
   const getCurrentTaskFiles = (): AttachedFile[] => {
     return getCurrentTask()?.attachedFiles || [];
   };
@@ -259,6 +276,7 @@ export const useChatPanels = (deps: ChatPanelsDeps) => {
     handleLocateMessage,
     openMarkdownPreviewTab,
     openFilePreview,
+    openWebTab,
     handleOpenFilePreview,
     handleSaveSuccess,
     getCurrentTaskFiles,

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

@@ -10,3 +10,4 @@ export * from './clipboard';
 export * from './dxf';
 export * from './userInput';
 export * from './turnRows';
+export * from './webLink';

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

@@ -0,0 +1,35 @@
+// 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;
+  }
+};
+
+/** 面板标签标题:优先用链接文字,没有就用路径(去掉域名,整条 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;
+  }
+};

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

@@ -12,7 +12,9 @@ export interface AgentDetailData {
 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 };
+  | { id: string; type: 'schedule'; title: string; task: ScheduleTask }
+  // 站内页面:url 一定是同源绝对地址(判定见 lib/webLink.ts),内容区用 iframe 嵌
+  | { id: string; type: 'web'; title: string; url: string };
 
 export interface RightPanelState {
   tabs: RightPanelTab[];