Просмотр исходного кода

[Mod 0000]修改AI助手多标签弹框的逻辑样式

wangkeyi 3 дней назад
Родитель
Сommit
b108f55f87

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

@@ -67,6 +67,7 @@
             @ask-user-submit="handleAskUserSubmit"
             @ask-user-cancel="handleAskUserCancel"
             @agent-detail-click="handleAgentDetailClick"
+            @open-markdown-tab="openMarkdownPreviewTab"
           />
 
           <!-- 任务进度条 -->
@@ -117,6 +118,7 @@
             @schedule-deleted="handleScheduleDeleted"
             @schedule-updated="handleScheduleUpdated"
             @open-schedule-session="handleOpenScheduleSession"
+            @open-markdown-tab="openMarkdownPreviewTab"
           />
         </Transition>
       </template>
@@ -1070,6 +1072,25 @@
     });
   };
 
+  // 在右侧面板新建标签页展示 Markdown 代码块渲染效果(相同内容复用同一标签)
+  const openMarkdownPreviewTab = (payload: { title: string; content: string }) => {
+    let hash = 0;
+    for (let i = 0; i < payload.content.length; i++) {
+      hash = (hash * 31 + payload.content.charCodeAt(i)) | 0;
+    }
+    openFileTab(
+      {
+        id: `md-preview-${hash}`,
+        name: `${payload.title}.md`,
+        size: payload.content.length,
+        type: 'text/markdown',
+        content: payload.content,
+        uploadTime: '',
+      },
+      false
+    );
+  };
+
   const openFilePreview = (file: AttachedFile) => {
     openFileTab(file, false);
 

+ 3 - 117
src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue

@@ -301,19 +301,6 @@
     <div v-if="!isAtBottom" class="scroll-to-bottom" @click="scrollToBottom">
       <div class="scroll-to-bottom-icon"></div>
     </div>
-
-    <!-- 表格预览弹窗 -->
-    <div v-if="showPreviewModal" class="table-preview-overlay" @click.self="closePreview">
-      <div class="table-preview-modal">
-        <div class="table-preview-header">
-          <span class="table-preview-title">表格预览</span>
-          <div class="table-preview-close" @click="closePreview">
-            <img :src="getCssUrl('--img-chat-close-icon-btn')" width="16" height="16" />
-          </div>
-        </div>
-        <div class="table-preview-body" v-html="previewTableHtml"></div>
-      </div>
-    </div>
   </div>
 </template>
 
@@ -437,6 +424,7 @@
     (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;
   }>();
 
   const isEditableFile = (filename: string) => isMdFile(filename) || isTextFile(filename);
@@ -608,10 +596,6 @@
   const isAtBottom = ref(true);
   const copiedMessageIndex = ref(-1);
 
-  // 表格预览状态
-  const previewTableHtml = ref('');
-  const showPreviewModal = ref(false);
-
   const handleTableAction = (e: Event) => {
     const target = (e.target as HTMLElement).closest('.table-action-btn') as HTMLElement;
     if (!target) return;
@@ -670,18 +654,13 @@
         break;
       }
       case 'preview': {
-        previewTableHtml.value = tableHtml;
-        showPreviewModal.value = true;
+        // 在右侧面板新建标签页展示表格渲染效果
+        emit('open-markdown-tab', { title: '表格预览', content: tableHtmlToMarkdown(tableHtml) });
         break;
       }
     }
   };
 
-  const closePreview = () => {
-    showPreviewModal.value = false;
-    previewTableHtml.value = '';
-  };
-
   const checkScrollBottom = () => {
     const el = messagesRef.value;
     if (!el) return;
@@ -1859,99 +1838,6 @@
     }
   }
 
-  /* 表格预览弹窗 */
-  .table-preview-overlay {
-    position: fixed;
-    top: 0;
-    left: 0;
-    width: 100%;
-    height: 100%;
-    background: rgba(0, 0, 0, 0.6);
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    z-index: 1000;
-  }
-
-  .table-preview-modal {
-    background: #1a1f2e;
-    border: 1px solid rgba(63, 80, 106, 0.5);
-    border-radius: 8px;
-    width: 90%;
-    max-width: 1200px;
-    max-height: 700px;
-    display: flex;
-    flex-direction: column;
-    overflow: hidden;
-
-    .table-preview-header {
-      display: flex;
-      justify-content: space-between;
-      align-items: center;
-      padding: 12px 16px;
-      border-bottom: 1px solid rgba(63, 80, 106, 0.3);
-      flex-shrink: 0;
-
-      .table-preview-title {
-        font-size: 15px;
-        font-weight: 500;
-        color: #e0e6ed;
-      }
-
-      .table-preview-close {
-        width: 32px;
-        height: 32px;
-        display: flex;
-        align-items: center;
-        justify-content: center;
-        border-radius: 4px;
-        color: #8b949e;
-        cursor: pointer;
-        transition: all 0.2s;
-
-        &:hover {
-          color: #e0e6ed;
-          background: rgba(10, 132, 255, 0.15);
-        }
-      }
-    }
-
-    :deep(.table-preview-body) {
-      padding: 20px;
-      overflow: auto;
-      flex: 1;
-
-      table {
-        width: 100%;
-        border-collapse: collapse;
-      }
-
-      th,
-      td {
-        border: 1px solid #3f506a;
-        padding: 10px 14px;
-        text-align: left;
-        color: #e0e6ed;
-        font-size: 14px;
-      }
-
-      th {
-        background: #1e2538;
-        font-weight: 600;
-      }
-
-      tbody tr {
-        &:nth-child(even) td {
-          background: rgba(10, 132, 255, 0.04);
-        }
-
-        &:hover td {
-          background: rgba(10, 132, 255, 0.1);
-        }
-      }
-    }
-  }
-
   .scroll-to-bottom {
     position: absolute;
     bottom: 20px;

+ 1 - 1
src/views/ventAI/manageAssistent/components/chatModal/CreateScheduleModal.vue

@@ -447,7 +447,7 @@
     }
 
     .zxm-modal-body {
-      padding: 24px;
+      padding: 24px !important;
     }
 
     .zxm-modal-close {

+ 1 - 1
src/views/ventAI/manageAssistent/components/chatModal/CreateSubAgentModal.vue

@@ -628,7 +628,7 @@
     }
 
     .zxm-modal-body {
-      padding: 24px;
+      padding: 24px !important;
       max-height: 70vh;
       overflow-y: auto;
     }

+ 118 - 2
src/views/ventAI/manageAssistent/components/chatModal/FilePreviewPanel.vue

@@ -85,7 +85,7 @@
           <div v-else class="no-preview-text">CSV文件内容为空</div>
         </div>
         <div v-else-if="isMdFile(file.name)" class="markdown-preview">
-          <div class="markdown-body" v-html="renderedMarkdown"></div>
+          <div class="markdown-body" v-html="renderedMarkdown" @click="handleTableAction"></div>
         </div>
         <div v-else-if="isTextFile(file.name)" class="text-preview">
           <pre>{{ file.content || '文件内容加载中...' }}</pre>
@@ -118,6 +118,7 @@
     getFileExtension,
     getFileIconVar,
     renderMarkdown,
+    wrapMarkdownCodeBlocks,
   } from './utils';
   import type { AttachedFile } from './types';
 
@@ -126,6 +127,13 @@
     return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
   };
 
+  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 props = withDefaults(
     defineProps<{
       file: AttachedFile;
@@ -139,6 +147,7 @@
   const emit = defineEmits<{
     (e: 'close'): void;
     (e: 'save-success', content: string): void;
+    (e: 'open-markdown-tab', payload: { title: string; content: string }): void;
   }>();
 
   // 编辑模式
@@ -232,9 +241,77 @@
   });
 
   const renderedMarkdown = computed(() => {
-    return renderMarkdown(props.file.content || '');
+    return wrapMarkdownCodeBlocks(renderMarkdown(props.file.content || ''), tableIcons);
   });
 
+  // Markdown 代码块工具栏操作(复制/下载/预览)
+
+  const copyToClipboard = async (text: string) => {
+    try {
+      await navigator.clipboard.writeText(text);
+    } catch {
+      const ta = document.createElement('textarea');
+      ta.value = text;
+      ta.style.position = 'fixed';
+      ta.style.opacity = '0';
+      document.body.appendChild(ta);
+      ta.select();
+      document.execCommand('copy');
+      document.body.removeChild(ta);
+    }
+  };
+
+  const showCopySuccess = (btn: HTMLElement) => {
+    const img = btn.querySelector('img');
+    const successIcon = btn.dataset.iconSuccess;
+    const originalIcon = btn.dataset.icon;
+    if (img && successIcon) {
+      img.src = successIcon;
+      btn.title = '复制成功';
+      const wrapper = btn.closest('.markdown-code-wrapper')!;
+      const reset = () => {
+        img.src = originalIcon!;
+        btn.title = '复制内容';
+        wrapper.removeEventListener('mouseleave', reset);
+      };
+      wrapper.addEventListener('mouseleave', reset);
+    }
+  };
+
+  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 encodedContent = target.dataset.content;
+    if (!action || !encodedContent) return;
+
+    const content = decodeURIComponent(encodedContent);
+
+    switch (action) {
+      case 'copy-md': {
+        copyToClipboard(content).then(() => showCopySuccess(target));
+        break;
+      }
+      case 'download-md': {
+        const blob = new Blob([content], { type: 'text/markdown;charset=utf-8;' });
+        const url = URL.createObjectURL(blob);
+        const a = document.createElement('a');
+        a.href = url;
+        a.download = `markdown_${Date.now()}.md`;
+        a.click();
+        URL.revokeObjectURL(url);
+        break;
+      }
+      case 'preview-md': {
+        // 在右侧面板新建标签页展示渲染效果
+        const title = content.match(/^#{1,3}\s+(.+?)\s*$/m)?.[1] || 'Markdown预览';
+        emit('open-markdown-tab', { title, content });
+        break;
+      }
+    }
+  };
+
   const csvRows = computed(() => {
     const content = props.file.content || '';
     if (!content.trim()) return [];
@@ -770,6 +847,45 @@
           border: none;
           border-top: 1px solid rgb(255 255 255 / 10%);
         }
+
+        :deep(.markdown-code-wrapper) {
+          position: relative;
+          margin: 6px 0;
+
+          .table-actions {
+            position: absolute;
+            top: 4px;
+            right: 4px;
+            display: flex;
+            gap: 2px;
+            opacity: 0;
+            transition: opacity 0.2s;
+            z-index: 1;
+          }
+
+          &:hover .table-actions {
+            opacity: 1;
+          }
+
+          .table-action-btn {
+            width: 26px;
+            height: 26px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            border-radius: 4px;
+            color: #8b949e;
+            cursor: pointer;
+            background: rgba(22, 27, 34, 0.8);
+            backdrop-filter: blur(4px);
+            transition: all 0.2s;
+
+            &:hover {
+              color: #e0e6ed;
+              background: rgba(10, 132, 255, 0.3);
+            }
+          }
+        }
       }
     }
 

+ 2 - 0
src/views/ventAI/manageAssistent/components/chatModal/RightPanelTabs.vue

@@ -53,6 +53,7 @@
         hide-header
         @close="closeActive"
         @save-success="handleSaveSuccess"
+        @open-markdown-tab="emit('open-markdown-tab', $event)"
       />
       <AgentDetailPanel
         v-else-if="activeTab?.type === 'agent'"
@@ -98,6 +99,7 @@
     (e: 'schedule-deleted'): void;
     (e: 'schedule-updated'): void;
     (e: 'open-schedule-session', sessionId: string, prompt: string): void;
+    (e: 'open-markdown-tab', payload: { title: string; content: string }): void;
   }>();
 
   const activeTab = computed(() => props.tabs.find((t) => t.id === props.activeTabId) || null);

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

@@ -91,6 +91,33 @@ export const wrapTables = (html: string, icons: { copy: string; download: string
   });
 };
 
+const decodeHtmlEntities = (escaped: string): string => {
+  const textarea = document.createElement('textarea');
+  textarea.innerHTML = escaped;
+  return textarea.value;
+};
+
+// 给渲染后 HTML 中的 ```markdown / ```md 代码块加上操作工具栏(复制/下载/预览)
+export const wrapMarkdownCodeBlocks = (html: string, icons: { copy: string; download: string; preview: string; copySuccess: string }): string => {
+  return html.replace(/<pre><code class="[^"]*language-(?:markdown|md)[^"]*">([\s\S]*?)<\/code><\/pre>/g, (match, escaped: string) => {
+    const encoded = encodeURIComponent(decodeHtmlEntities(escaped));
+    return `<div class="markdown-code-wrapper">
+      <div class="table-actions">
+        <span class="table-action-btn" data-action="copy-md" data-content="${encoded}" data-icon="${icons.copy}" data-icon-success="${icons.copySuccess}" title="复制内容">
+          <img src="${icons.copy}" width="14" height="14" />
+        </span>
+        <span class="table-action-btn" data-action="download-md" data-content="${encoded}" title="下载.md文件">
+          <img src="${icons.download}" width="14" height="14" />
+        </span>
+        <span class="table-action-btn" data-action="preview-md" data-content="${encoded}" title="预览渲染效果">
+          <img src="${icons.preview}" width="14" height="14" />
+        </span>
+      </div>
+      ${match}
+    </div>`;
+  });
+};
+
 export const tableHtmlToMarkdown = (tableHtml: string): string => {
   const parser = new DOMParser();
   const doc = parser.parseFromString(tableHtml, 'text/html');