Skip to content

Commit 9ac696d

Browse files
committed
fix(chatui): add copy rollback path and error message.
1 parent 94736ff commit 9ac696d

File tree

3 files changed

+141
-65
lines changed

3 files changed

+141
-65
lines changed

dashboard/src/components/chat/MessageList.vue

Lines changed: 139 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,8 @@
143143
</v-card>
144144
</v-menu>
145145
<v-btn :icon="getCopyIcon(index)" size="x-small" variant="text" class="copy-message-btn"
146-
:class="{ 'copy-success': isCopySuccess(index) }"
147-
@click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" />
146+
:class="{ 'copy-success': isCopySuccess(index), 'copy-failed': isCopyFailure(index) }"
147+
@click="copyBotMessage(msg.content.message, index)" :title="getCopyTitle(index)" />
148148
<v-btn icon="mdi-reply-outline" size="x-small" variant="text" class="reply-message-btn"
149149
@click="$emit('replyMessage', msg, index)" :title="tm('actions.reply')" />
150150

@@ -185,6 +185,7 @@ import 'markstream-vue/index.css'
185185
import 'katex/dist/katex.min.css'
186186
import 'highlight.js/styles/github.css';
187187
import axios from 'axios';
188+
import { useToast } from '@/utils/toast'
188189
import ReasoningBlock from './message_list_comps/ReasoningBlock.vue';
189190
import MessagePartsRenderer from './message_list_comps/MessagePartsRenderer.vue';
190191
import RefNode from './message_list_comps/RefNode.vue';
@@ -226,10 +227,12 @@ export default {
226227
setup() {
227228
const { t } = useI18n();
228229
const { tm } = useModuleI18n('features/chat');
230+
const toast = useToast()
229231
230232
return {
231233
t,
232-
tm
234+
tm,
235+
toast
233236
};
234237
},
235238
provide() {
@@ -241,6 +244,7 @@ export default {
241244
data() {
242245
return {
243246
copiedMessages: new Set(),
247+
copyFailedMessages: new Set(),
244248
isUserNearBottom: true,
245249
scrollThreshold: 1,
246250
scrollTimer: null,
@@ -496,98 +500,152 @@ export default {
496500
},
497501
498502
// 复制代码到剪贴板
499-
copyCodeToClipboard(code) {
500-
navigator.clipboard.writeText(code).then(() => {
501-
console.log('代码已复制到剪贴板');
502-
}).catch(err => {
503-
console.error('复制失败:', err);
504-
// 如果现代API失败,使用传统方法
503+
tryExecCommandCopy(text) {
504+
try {
505505
const textArea = document.createElement('textarea');
506-
textArea.value = code;
506+
textArea.value = text;
507507
document.body.appendChild(textArea);
508+
textArea.focus();
508509
textArea.select();
510+
const ok = document.execCommand('copy');
511+
document.body.removeChild(textArea);
512+
return ok;
513+
} catch (_) {
514+
return false;
515+
}
516+
},
517+
518+
async copyTextToClipboard(text) {
519+
// 优先使用同步复制,尽量保留用户手势上下文;
520+
// 在非安全来源(例如通过局域网 IP + vite --host)时成功率更高。
521+
if (this.tryExecCommandCopy(text)) {
522+
return true;
523+
}
524+
525+
if (navigator.clipboard?.writeText) {
509526
try {
510-
document.execCommand('copy');
511-
console.log('代码已复制到剪贴板 (fallback)');
512-
} catch (fallbackErr) {
513-
console.error('复制失败 (fallback):', fallbackErr);
527+
await navigator.clipboard.writeText(text);
528+
return true;
529+
} catch (_) {
530+
return false;
514531
}
515-
document.body.removeChild(textArea);
516-
});
532+
}
533+
534+
return false;
517535
},
518536
519-
// 复制bot消息到剪贴板
520-
copyBotMessage(messageParts, messageIndex) {
521-
let textToCopy = '';
537+
buildCopyTextFromParts(messageParts) {
538+
if (typeof messageParts === 'string') {
539+
return messageParts.trim();
540+
}
541+
if (!Array.isArray(messageParts)) {
542+
return '';
543+
}
522544
523-
if (Array.isArray(messageParts)) {
524-
// 提取所有文本内容
525-
const textContents = messageParts
526-
.filter(part => part.type === 'plain' && part.text)
527-
.map(part => part.text);
528-
textToCopy = textContents.join('\n');
545+
const textContents = messageParts
546+
.filter(part => part && typeof part === 'object' && part.type === 'plain' && part.text)
547+
.map(part => part.text);
529548
530-
// 检查是否有图片
531-
const imageCount = messageParts.filter(part => part.type === 'image' && part.embedded_url).length;
532-
if (imageCount > 0) {
533-
if (textToCopy) textToCopy += '\n\n';
534-
textToCopy += `[包含 ${imageCount} 张图片]`;
535-
}
549+
let textToCopy = textContents.join('\n');
536550
537-
// 检查是否有音频
538-
const hasAudio = messageParts.some(part => part.type === 'record' && part.embedded_url);
539-
if (hasAudio) {
540-
if (textToCopy) textToCopy += '\n\n';
541-
textToCopy += '[包含音频内容]';
542-
}
551+
const imageCount = messageParts.filter(part => part?.type === 'image' && part.embedded_url).length;
552+
if (imageCount > 0) {
553+
if (textToCopy) textToCopy += '\n\n';
554+
textToCopy += `[包含 ${imageCount} 张图片]`;
543555
}
544556
545-
// 如果没有任何内容,使用默认文本
546-
if (!textToCopy.trim()) {
547-
textToCopy = '[媒体内容]';
557+
const hasAudio = messageParts.some(part => part?.type === 'record' && part.embedded_url);
558+
if (hasAudio) {
559+
if (textToCopy) textToCopy += '\n\n';
560+
textToCopy += '[包含音频内容]';
561+
}
562+
563+
return String(textToCopy || '').trim();
564+
},
565+
566+
async copyCodeToClipboard(code) {
567+
const text = String(code ?? '');
568+
if (!text) return false;
569+
570+
const ok = await this.copyTextToClipboard(text);
571+
if (ok) {
572+
this.toast?.success?.(this.t('core.common.copied'));
573+
} else {
574+
this.toast?.error?.(this.t('core.common.copyFailed'));
548575
}
576+
return ok;
577+
},
549578
550-
navigator.clipboard.writeText(textToCopy).then(() => {
551-
console.log('消息已复制到剪贴板');
579+
// 复制bot消息到剪贴板
580+
async copyBotMessage(messageParts, messageIndex) {
581+
let textToCopy = this.buildCopyTextFromParts(messageParts);
582+
if (!textToCopy) textToCopy = '[媒体内容]';
583+
584+
const ok = await this.copyTextToClipboard(textToCopy);
585+
if (ok) {
552586
this.showCopySuccess(messageIndex);
553-
}).catch(err => {
554-
console.error('复制失败:', err);
555-
// 如果现代API失败,使用传统方法
556-
const textArea = document.createElement('textarea');
557-
textArea.value = textToCopy;
558-
document.body.appendChild(textArea);
559-
textArea.select();
560-
try {
561-
document.execCommand('copy');
562-
console.log('消息已复制到剪贴板 (fallback)');
563-
this.showCopySuccess(messageIndex);
564-
} catch (fallbackErr) {
565-
console.error('复制失败 (fallback):', fallbackErr);
566-
}
567-
document.body.removeChild(textArea);
568-
});
587+
this.toast?.success?.(this.t('core.common.copied'));
588+
} else {
589+
this.showCopyFailure(messageIndex);
590+
this.toast?.error?.(this.t('core.common.copyFailed'));
591+
}
569592
},
570593
571594
// 显示复制成功提示
572595
showCopySuccess(messageIndex) {
596+
if (this.copyFailedMessages.has(messageIndex)) {
597+
this.copyFailedMessages.delete(messageIndex);
598+
this.copyFailedMessages = new Set(this.copyFailedMessages);
599+
}
573600
this.copiedMessages.add(messageIndex);
601+
this.copiedMessages = new Set(this.copiedMessages);
574602
575603
// 2秒后移除成功状态
576604
setTimeout(() => {
577605
this.copiedMessages.delete(messageIndex);
606+
this.copiedMessages = new Set(this.copiedMessages);
607+
}, 2000);
608+
},
609+
610+
// 显示复制失败提示
611+
showCopyFailure(messageIndex) {
612+
if (this.copiedMessages.has(messageIndex)) {
613+
this.copiedMessages.delete(messageIndex);
614+
this.copiedMessages = new Set(this.copiedMessages);
615+
}
616+
this.copyFailedMessages.add(messageIndex);
617+
this.copyFailedMessages = new Set(this.copyFailedMessages);
618+
619+
setTimeout(() => {
620+
this.copyFailedMessages.delete(messageIndex);
621+
this.copyFailedMessages = new Set(this.copyFailedMessages);
578622
}, 2000);
579623
},
580624
581625
// 获取复制按钮图标
582626
getCopyIcon(messageIndex) {
583-
return this.copiedMessages.has(messageIndex) ? 'mdi-check' : 'mdi-content-copy';
627+
if (this.copiedMessages.has(messageIndex)) return 'mdi-check';
628+
if (this.copyFailedMessages.has(messageIndex)) return 'mdi-alert-circle-outline';
629+
return 'mdi-content-copy';
584630
},
585631
586632
// 检查是否为复制成功状态
587633
isCopySuccess(messageIndex) {
588634
return this.copiedMessages.has(messageIndex);
589635
},
590636
637+
// 检查是否为复制失败状态
638+
isCopyFailure(messageIndex) {
639+
return this.copyFailedMessages.has(messageIndex);
640+
},
641+
642+
// 获取复制按钮提示文本
643+
getCopyTitle(messageIndex) {
644+
if (this.isCopySuccess(messageIndex)) return this.t('core.common.copied');
645+
if (this.isCopyFailure(messageIndex)) return this.t('core.common.copyFailed');
646+
return this.t('core.common.copy');
647+
},
648+
591649
// 获取复制图标SVG
592650
getCopyIconSvg() {
593651
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 +656,11 @@ export default {
598656
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>';
599657
},
600658
659+
// 获取失败图标SVG
660+
getErrorIconSvg() {
661+
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>';
662+
},
663+
601664
// 初始化代码块复制按钮
602665
initCodeCopyButtons() {
603666
this.$nextTick(() => {
@@ -608,15 +671,16 @@ export default {
608671
const button = document.createElement('button');
609672
button.className = 'copy-code-btn';
610673
button.innerHTML = this.getCopyIconSvg();
611-
button.title = '复制代码';
612-
button.addEventListener('click', () => {
613-
this.copyCodeToClipboard(codeBlock.textContent);
614-
// 显示复制成功提示
615-
button.innerHTML = this.getSuccessIconSvg();
616-
button.style.color = '#4caf50';
674+
button.title = this.t('core.common.copy');
675+
button.addEventListener('click', async () => {
676+
const ok = await this.copyCodeToClipboard(codeBlock.textContent || '');
677+
button.innerHTML = ok ? this.getSuccessIconSvg() : this.getErrorIconSvg();
678+
button.style.color = ok ? '#4caf50' : '#f44336';
679+
button.setAttribute("title", this.t(`core.common.${ok ? "copied" : "copyFailed"}`));
617680
setTimeout(() => {
618681
button.innerHTML = this.getCopyIconSvg();
619682
button.style.color = '';
683+
button.setAttribute("title", this.t('core.common.copy'));
620684
}, 2000);
621685
});
622686
pre.style.position = 'relative';
@@ -1086,6 +1150,16 @@ export default {
10861150
background-color: rgba(76, 175, 80, 0.1);
10871151
}
10881152
1153+
.copy-message-btn.copy-failed {
1154+
color: #f44336;
1155+
opacity: 1;
1156+
}
1157+
1158+
.copy-message-btn.copy-failed:hover {
1159+
color: #f44336;
1160+
background-color: rgba(244, 67, 54, 0.1);
1161+
}
1162+
10891163
.reply-message-btn {
10901164
opacity: 0.6;
10911165
transition: all 0.2s ease;

dashboard/src/i18n/locales/en-US/core/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"close": "Close",
55
"copy": "Copy",
66
"copied": "Copied",
7+
"copyFailed": "Copy failed",
78
"delete": "Delete",
89
"edit": "Edit",
910
"add": "Add",

dashboard/src/i18n/locales/zh-CN/core/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"close": "关闭",
55
"copy": "复制",
66
"copied": "已复制",
7+
"copyFailed": "复制失败",
78
"delete": "删除",
89
"edit": "编辑",
910
"add": "添加",

0 commit comments

Comments
 (0)