fix(chatui): add copy rollback path and error message. (#5352)
* fix(chatui): add copy rollback path and error message. * fix(chatui): fixed textarea leak in the copy button. * fix(chatui): use color styles from the component library.
This commit is contained in:
@@ -143,8 +143,8 @@
|
||||
</v-card>
|
||||
</v-menu>
|
||||
<v-btn :icon="getCopyIcon(index)" size="x-small" variant="text" class="copy-message-btn"
|
||||
:class="{ 'copy-success': isCopySuccess(index) }"
|
||||
@click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" />
|
||||
:class="{ 'copy-success': isCopySuccess(index), 'copy-failed': isCopyFailure(index) }"
|
||||
@click="copyBotMessage(msg.content.message, index)" :title="getCopyTitle(index)" />
|
||||
<v-btn icon="mdi-reply-outline" size="x-small" variant="text" class="reply-message-btn"
|
||||
@click="$emit('replyMessage', msg, index)" :title="tm('actions.reply')" />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async copyTextToClipboard(text) {
|
||||
// 优先使用同步复制,尽量保留用户手势上下文;
|
||||
// 在非安全来源(例如通过局域网 IP + vite --host)时成功率更高。
|
||||
if (this.tryExecCommandCopy(text)) {
|
||||
return { ok: true, method: 'execCommand' };
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return { ok: true, method: 'clipboard' };
|
||||
} catch (error) {
|
||||
return { ok: false, method: 'clipboard', error };
|
||||
}
|
||||
}
|
||||
|
||||
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 (ok) {
|
||||
this.toast?.success?.(this.t('core.common.copied'));
|
||||
} else {
|
||||
this.toast?.error?.(this.t('core.common.copyFailed'));
|
||||
}
|
||||
|
||||
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消息到剪贴板
|
||||
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');
|
||||
|
||||
// 检查是否有图片
|
||||
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 += '[包含音频内容]';
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有任何内容,使用默认文本
|
||||
if (!textToCopy.trim()) {
|
||||
textToCopy = '[媒体内容]';
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
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 '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
|
||||
@@ -598,6 +665,11 @@ export default {
|
||||
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20,6 9,17 4,12"></polyline></svg>';
|
||||
},
|
||||
|
||||
// 获取失败图标SVG
|
||||
getErrorIconSvg() {
|
||||
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="13"></line><circle cx="12" cy="16.5" r="1"></circle></svg>';
|
||||
},
|
||||
|
||||
// 初始化代码块复制按钮
|
||||
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 {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"close": "Close",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copyFailed": "Copy failed",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"close": "关闭",
|
||||
"copy": "复制",
|
||||
"copied": "已复制",
|
||||
"copyFailed": "复制失败",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"add": "添加",
|
||||
|
||||
Reference in New Issue
Block a user