Explorar el Código

feat:AI助手中的代码块添加一键复制按钮

Jerry Liang hace 2 meses
padre
commit
8a06b43d4e
Se han modificado 1 ficheros con 95 adiciones y 4 borrados
  1. 95 4
      src/layout/header/Actions/AiChat/MessageItem.vue

+ 95 - 4
src/layout/header/Actions/AiChat/MessageItem.vue

@@ -1,8 +1,9 @@
 <script setup lang="ts">
 import { NTag, NSpin, NButton, NCard } from 'naive-ui'
-import { computed } from 'vue'
-import { marked } from 'marked'
+import { computed, ref } from 'vue'
+import { marked, Renderer } from 'marked'
 import DOMPurify from 'dompurify'
+import { useClipboard } from '@vueuse/core'
 import { downloadAiFile } from '@/service/aiChat'
 import type { ChatMessage } from './interface'
 
@@ -16,16 +17,58 @@ const emit = defineEmits<{
 
 const isUser = computed(() => props.message.role === 'user')
 
+// 自定义 marked renderer,为代码块添加复制按钮
+const renderer = new Renderer()
+renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
+  const escaped = text
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+  const langLabel = lang ? `<span class="code-lang-label">${lang}</span>` : ''
+  return `<div class="code-block-wrapper">${langLabel}<button class="copy-btn" data-code="${escaped}">复制</button><pre><code>${text}</code></pre></div>`
+}
+
 const formattedContent = computed(() => {
   if (isUser.value) {
     // 用户消息保持纯文本显示
     return props.message.content.replace(/\n/g, '<br/>')
   }
   // AI 回复使用 Markdown 渲染
-  const raw = marked.parse(props.message.content, { async: false }) as string
-  return DOMPurify.sanitize(raw)
+  const raw = marked.parse(props.message.content, { async: false, renderer }) as string
+  return DOMPurify.sanitize(raw, { ADD_ATTR: ['data-code'] })
 })
 
+// 复制到剪贴板
+const { copy } = useClipboard()
+const copiedIndex = ref<string | null>(null)
+
+function handleContentClick(e: MouseEvent) {
+  const target = e.target as HTMLElement
+  if (target.classList.contains('copy-btn')) {
+    const code = target.getAttribute('data-code')
+      ?.replace(/&amp;/g, '&')
+      .replace(/&lt;/g, '<')
+      .replace(/&gt;/g, '>')
+      .replace(/&quot;/g, '"')
+    if (code) {
+      copy(code)
+      // 显示已复制反馈
+      const id = Math.random().toString(36).slice(2)
+      copiedIndex.value = id
+      target.textContent = '已复制 ✓'
+      target.classList.add('copied')
+      setTimeout(() => {
+        if (copiedIndex.value === id) {
+          target.textContent = '复制'
+          target.classList.remove('copied')
+          copiedIndex.value = null
+        }
+      }, 2000)
+    }
+  }
+}
+
 function downloadFile(key: string, fileName: string) {
   downloadAiFile(key, fileName)
 }
@@ -112,6 +155,7 @@ function formatSize(bytes: number): string {
         v-if="message.content"
         :class="{ 'markdown-body': !isUser }"
         v-html="formattedContent"
+        @click="handleContentClick"
       />
     </div>
   </div>
@@ -174,6 +218,53 @@ function formatSize(bytes: number): string {
     }
   }
 
+  :deep(.code-block-wrapper) {
+    position: relative;
+    margin: 0.5em 0;
+
+    pre {
+      margin: 0;
+      padding-top: 2em;
+    }
+
+    .code-lang-label {
+      position: absolute;
+      top: 6px;
+      left: 12px;
+      font-size: 0.7em;
+      color: rgba(127, 127, 127, 0.7);
+      user-select: none;
+      pointer-events: none;
+    }
+
+    .copy-btn {
+      position: absolute;
+      top: 6px;
+      right: 8px;
+      padding: 2px 8px;
+      font-size: 0.7em;
+      color: rgba(127, 127, 127, 0.8);
+      background: rgba(127, 127, 127, 0.1);
+      border: 1px solid rgba(127, 127, 127, 0.2);
+      border-radius: 4px;
+      cursor: pointer;
+      transition: all 0.2s;
+      user-select: none;
+      z-index: 1;
+
+      &:hover {
+        color: rgba(127, 127, 127, 1);
+        background: rgba(127, 127, 127, 0.2);
+      }
+
+      &.copied {
+        color: #10b981;
+        border-color: #10b981;
+        background: rgba(16, 185, 129, 0.1);
+      }
+    }
+  }
+
   :deep(blockquote) {
     border-left: 3px solid rgba(127, 127, 127, 0.4);
     padding-left: 0.75em;