diff --git a/dashboard/src/components/chat/MessageList.vue b/dashboard/src/components/chat/MessageList.vue index 42129d792..e30a34a40 100644 --- a/dashboard/src/components/chat/MessageList.vue +++ b/dashboard/src/components/chat/MessageList.vue @@ -143,8 +143,8 @@ + :class="{ 'copy-success': isCopySuccess(index), 'copy-failed': isCopyFailure(index) }" + @click="copyBotMessage(msg.content.message, index)" :title="getCopyTitle(index)" /> @@ -185,6 +185,7 @@ import 'markstream-vue/index.css' import 'katex/dist/katex.min.css' import 'highlight.js/styles/github.css'; import axios from 'axios'; +import { useToast } from '@/utils/toast' import ReasoningBlock from './message_list_comps/ReasoningBlock.vue'; import MessagePartsRenderer from './message_list_comps/MessagePartsRenderer.vue'; import RefNode from './message_list_comps/RefNode.vue'; @@ -226,10 +227,12 @@ export default { setup() { const { t } = useI18n(); const { tm } = useModuleI18n('features/chat'); + const toast = useToast() return { t, - tm + tm, + toast }; }, provide() { @@ -241,6 +244,7 @@ export default { data() { return { copiedMessages: new Set(), + copyFailedMessages: new Set(), isUserNearBottom: true, scrollThreshold: 1, scrollTimer: null, @@ -496,91 +500,142 @@ export default { }, // 复制代码到剪贴板 - copyCodeToClipboard(code) { - navigator.clipboard.writeText(code).then(() => { - console.log('代码已复制到剪贴板'); - }).catch(err => { - console.error('复制失败:', err); - // 如果现代API失败,使用传统方法 - const textArea = document.createElement('textarea'); - textArea.value = code; + tryExecCommandCopy(text) { + let textArea = null; + try { + textArea = document.createElement('textarea'); + textArea.value = text; document.body.appendChild(textArea); + textArea.focus(); textArea.select(); + const ok = document.execCommand('copy'); + return ok; + } catch (_) { + return false; + } finally { try { - document.execCommand('copy'); - console.log('代码已复制到剪贴板 (fallback)'); - } catch (fallbackErr) { - console.error('复制失败 (fallback):', fallbackErr); + textArea?.remove?.(); + } catch (_) { + // ignore cleanup errors } - document.body.removeChild(textArea); - }); + } }, - // 复制bot消息到剪贴板 - copyBotMessage(messageParts, messageIndex) { - let textToCopy = ''; - - if (Array.isArray(messageParts)) { - // 提取所有文本内容 - const textContents = messageParts - .filter(part => part.type === 'plain' && part.text) - .map(part => part.text); - textToCopy = textContents.join('\n'); + async copyTextToClipboard(text) { + // 优先使用同步复制,尽量保留用户手势上下文; + // 在非安全来源(例如通过局域网 IP + vite --host)时成功率更高。 + if (this.tryExecCommandCopy(text)) { + return { ok: true, method: 'execCommand' }; + } - // 检查是否有图片 - const imageCount = messageParts.filter(part => part.type === 'image' && part.embedded_url).length; - if (imageCount > 0) { - if (textToCopy) textToCopy += '\n\n'; - textToCopy += `[包含 ${imageCount} 张图片]`; + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return { ok: true, method: 'clipboard' }; + } catch (error) { + return { ok: false, method: 'clipboard', error }; } + } - // 检查是否有音频 - const hasAudio = messageParts.some(part => part.type === 'record' && part.embedded_url); - if (hasAudio) { - if (textToCopy) textToCopy += '\n\n'; - textToCopy += '[包含音频内容]'; - } + return { ok: false, method: 'unavailable' }; + }, + + async copyWithFeedback(text, messageIndex = null) { + const result = await this.copyTextToClipboard(text); + const ok = !!result?.ok; + + if (messageIndex !== null && messageIndex !== undefined) { + if (ok) this.showCopySuccess(messageIndex); + else this.showCopyFailure(messageIndex); } - // 如果没有任何内容,使用默认文本 - if (!textToCopy.trim()) { - textToCopy = '[媒体内容]'; + if (ok) { + this.toast?.success?.(this.t('core.common.copied')); + } else { + this.toast?.error?.(this.t('core.common.copyFailed')); } - navigator.clipboard.writeText(textToCopy).then(() => { - console.log('消息已复制到剪贴板'); - this.showCopySuccess(messageIndex); - }).catch(err => { - console.error('复制失败:', err); - // 如果现代API失败,使用传统方法 - const textArea = document.createElement('textarea'); - textArea.value = textToCopy; - document.body.appendChild(textArea); - textArea.select(); - try { - document.execCommand('copy'); - console.log('消息已复制到剪贴板 (fallback)'); - this.showCopySuccess(messageIndex); - } catch (fallbackErr) { - console.error('复制失败 (fallback):', fallbackErr); - } - document.body.removeChild(textArea); - }); + return result; + }, + + buildCopyTextFromParts(messageParts) { + if (typeof messageParts === 'string') { + return messageParts.trim(); + } + if (!Array.isArray(messageParts)) { + return ''; + } + + const textContents = messageParts + .filter(part => part && typeof part === 'object' && part.type === 'plain' && part.text) + .map(part => part.text); + + let textToCopy = textContents.join('\n'); + + const imageCount = messageParts.filter(part => part?.type === 'image' && part.embedded_url).length; + if (imageCount > 0) { + if (textToCopy) textToCopy += '\n\n'; + textToCopy += `[包含 ${imageCount} 张图片]`; + } + + const hasAudio = messageParts.some(part => part?.type === 'record' && part.embedded_url); + if (hasAudio) { + if (textToCopy) textToCopy += '\n\n'; + textToCopy += '[包含音频内容]'; + } + + return String(textToCopy || '').trim(); + }, + + async copyCodeToClipboard(code) { + const text = String(code ?? ''); + if (!text) return { ok: false, method: 'empty' }; + return await this.copyWithFeedback(text, null); + }, + + // 复制bot消息到剪贴板 + async copyBotMessage(messageParts, messageIndex) { + let textToCopy = this.buildCopyTextFromParts(messageParts); + if (!textToCopy) textToCopy = '[媒体内容]'; + await this.copyWithFeedback(textToCopy, messageIndex); }, // 显示复制成功提示 showCopySuccess(messageIndex) { + if (this.copyFailedMessages.has(messageIndex)) { + this.copyFailedMessages.delete(messageIndex); + this.copyFailedMessages = new Set(this.copyFailedMessages); + } this.copiedMessages.add(messageIndex); + this.copiedMessages = new Set(this.copiedMessages); // 2秒后移除成功状态 setTimeout(() => { this.copiedMessages.delete(messageIndex); + this.copiedMessages = new Set(this.copiedMessages); + }, 2000); + }, + + // 显示复制失败提示 + showCopyFailure(messageIndex) { + if (this.copiedMessages.has(messageIndex)) { + this.copiedMessages.delete(messageIndex); + this.copiedMessages = new Set(this.copiedMessages); + } + this.copyFailedMessages.add(messageIndex); + this.copyFailedMessages = new Set(this.copyFailedMessages); + + setTimeout(() => { + this.copyFailedMessages.delete(messageIndex); + this.copyFailedMessages = new Set(this.copyFailedMessages); }, 2000); }, // 获取复制按钮图标 getCopyIcon(messageIndex) { - return this.copiedMessages.has(messageIndex) ? 'mdi-check' : 'mdi-content-copy'; + if (this.copiedMessages.has(messageIndex)) return 'mdi-check'; + if (this.copyFailedMessages.has(messageIndex)) return 'mdi-alert-circle-outline'; + return 'mdi-content-copy'; }, // 检查是否为复制成功状态 @@ -588,6 +643,18 @@ export default { return this.copiedMessages.has(messageIndex); }, + // 检查是否为复制失败状态 + isCopyFailure(messageIndex) { + return this.copyFailedMessages.has(messageIndex); + }, + + // 获取复制按钮提示文本 + getCopyTitle(messageIndex) { + if (this.isCopySuccess(messageIndex)) return this.t('core.common.copied'); + if (this.isCopyFailure(messageIndex)) return this.t('core.common.copyFailed'); + return this.t('core.common.copy'); + }, + // 获取复制图标SVG getCopyIconSvg() { return ''; @@ -598,6 +665,11 @@ export default { return ''; }, + // 获取失败图标SVG + getErrorIconSvg() { + return ''; + }, + // 初始化代码块复制按钮 initCodeCopyButtons() { this.$nextTick(() => { @@ -608,15 +680,19 @@ export default { const button = document.createElement('button'); button.className = 'copy-code-btn'; button.innerHTML = this.getCopyIconSvg(); - button.title = '复制代码'; - button.addEventListener('click', () => { - this.copyCodeToClipboard(codeBlock.textContent); - // 显示复制成功提示 - button.innerHTML = this.getSuccessIconSvg(); - button.style.color = '#4caf50'; + button.title = this.t('core.common.copy'); + button.addEventListener('click', async () => { + const res = await this.copyCodeToClipboard(codeBlock.textContent || ''); + const ok = !!res?.ok; + button.innerHTML = ok ? this.getSuccessIconSvg() : this.getErrorIconSvg(); + button.style.color = ok + ? 'rgb(var(--v-theme-success))' + : 'rgb(var(--v-theme-error))'; + button.setAttribute("title", this.t(`core.common.${ok ? "copied" : "copyFailed"}`)); setTimeout(() => { button.innerHTML = this.getCopyIconSvg(); button.style.color = ''; + button.setAttribute("title", this.t('core.common.copy')); }, 2000); }); pre.style.position = 'relative'; @@ -1077,13 +1153,23 @@ export default { } .copy-message-btn.copy-success { - color: #4caf50; + color: rgb(var(--v-theme-success)); opacity: 1; } .copy-message-btn.copy-success:hover { - color: #4caf50; - background-color: rgba(76, 175, 80, 0.1); + color: rgb(var(--v-theme-success)); + background-color: rgba(var(--v-theme-success), 0.1); +} + +.copy-message-btn.copy-failed { + color: rgb(var(--v-theme-error)); + opacity: 1; +} + +.copy-message-btn.copy-failed:hover { + color: rgb(var(--v-theme-error)); + background-color: rgba(var(--v-theme-error), 0.1); } .reply-message-btn { diff --git a/dashboard/src/i18n/locales/en-US/core/common.json b/dashboard/src/i18n/locales/en-US/core/common.json index e89a1ce52..973feea54 100644 --- a/dashboard/src/i18n/locales/en-US/core/common.json +++ b/dashboard/src/i18n/locales/en-US/core/common.json @@ -4,6 +4,7 @@ "close": "Close", "copy": "Copy", "copied": "Copied", + "copyFailed": "Copy failed", "delete": "Delete", "edit": "Edit", "add": "Add", diff --git a/dashboard/src/i18n/locales/zh-CN/core/common.json b/dashboard/src/i18n/locales/zh-CN/core/common.json index 171dfbf16..fa1a8bf8b 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/common.json +++ b/dashboard/src/i18n/locales/zh-CN/core/common.json @@ -4,6 +4,7 @@ "close": "关闭", "copy": "复制", "copied": "已复制", + "copyFailed": "复制失败", "delete": "删除", "edit": "编辑", "add": "添加",