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'
185185import ' katex/dist/katex.min.css'
186186import ' highlight.js/styles/github.css' ;
187187import axios from ' axios' ;
188+ import { useToast } from ' @/utils/toast'
188189import ReasoningBlock from ' ./message_list_comps/ReasoningBlock.vue' ;
189190import MessagePartsRenderer from ' ./message_list_comps/MessagePartsRenderer.vue' ;
190191import 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;
0 commit comments