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,161 @@ 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失败,使用传统方法
505- const textArea = document .createElement (' textarea' );
506- textArea .value = code;
503+ tryExecCommandCopy (text ) {
504+ let textArea = null ;
505+ try {
506+ textArea = document .createElement (' textarea' );
507+ textArea .value = text;
507508 document .body .appendChild (textArea);
509+ textArea .focus ();
508510 textArea .select ();
511+ const ok = document .execCommand (' copy' );
512+ return ok;
513+ } catch (_) {
514+ return false ;
515+ } finally {
509516 try {
510- document .execCommand (' copy' );
511- console .log (' 代码已复制到剪贴板 (fallback)' );
512- } catch (fallbackErr) {
513- console .error (' 复制失败 (fallback):' , fallbackErr);
517+ textArea? .remove ? .();
518+ } catch (_) {
519+ // ignore cleanup errors
514520 }
515- document .body .removeChild (textArea);
516- });
521+ }
517522 },
518523
519- // 复制bot消息到剪贴板
520- copyBotMessage (messageParts , messageIndex ) {
521- let textToCopy = ' ' ;
522-
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 ' );
524+ async copyTextToClipboard (text ) {
525+ // 优先使用同步复制,尽量保留用户手势上下文;
526+ // 在非安全来源(例如通过局域网 IP + vite --host)时成功率更高。
527+ if (this .tryExecCommandCopy (text)) {
528+ return { ok: true , method: ' execCommand' };
529+ }
529530
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} 张图片]` ;
531+ if (navigator .clipboard ? .writeText ) {
532+ try {
533+ await navigator .clipboard .writeText (text);
534+ return { ok: true , method: ' clipboard' };
535+ } catch (error) {
536+ return { ok: false , method: ' clipboard' , error };
535537 }
538+ }
536539
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- }
540+ return { ok: false , method: ' unavailable' };
541+ },
542+
543+ async copyWithFeedback (text , messageIndex = null ) {
544+ const result = await this .copyTextToClipboard (text);
545+ const ok = !! result? .ok ;
546+
547+ if (messageIndex !== null && messageIndex !== undefined ) {
548+ if (ok) this .showCopySuccess (messageIndex);
549+ else this .showCopyFailure (messageIndex);
543550 }
544551
545- // 如果没有任何内容,使用默认文本
546- if (! textToCopy .trim ()) {
547- textToCopy = ' [媒体内容]' ;
552+ if (ok) {
553+ this .toast ? .success ? .(this .t (' core.common.copied' ));
554+ } else {
555+ this .toast ? .error ? .(this .t (' core.common.copyFailed' ));
548556 }
549557
550- navigator .clipboard .writeText (textToCopy).then (() => {
551- console .log (' 消息已复制到剪贴板' );
552- 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- });
558+ return result;
559+ },
560+
561+ buildCopyTextFromParts (messageParts ) {
562+ if (typeof messageParts === ' string' ) {
563+ return messageParts .trim ();
564+ }
565+ if (! Array .isArray (messageParts)) {
566+ return ' ' ;
567+ }
568+
569+ const textContents = messageParts
570+ .filter (part => part && typeof part === ' object' && part .type === ' plain' && part .text )
571+ .map (part => part .text );
572+
573+ let textToCopy = textContents .join (' \n ' );
574+
575+ const imageCount = messageParts .filter (part => part? .type === ' image' && part .embedded_url ).length ;
576+ if (imageCount > 0 ) {
577+ if (textToCopy) textToCopy += ' \n\n ' ;
578+ textToCopy += ` [包含 ${ imageCount} 张图片]` ;
579+ }
580+
581+ const hasAudio = messageParts .some (part => part? .type === ' record' && part .embedded_url );
582+ if (hasAudio) {
583+ if (textToCopy) textToCopy += ' \n\n ' ;
584+ textToCopy += ' [包含音频内容]' ;
585+ }
586+
587+ return String (textToCopy || ' ' ).trim ();
588+ },
589+
590+ async copyCodeToClipboard (code ) {
591+ const text = String (code ?? ' ' );
592+ if (! text) return { ok: false , method: ' empty' };
593+ return await this .copyWithFeedback (text, null );
594+ },
595+
596+ // 复制bot消息到剪贴板
597+ async copyBotMessage (messageParts , messageIndex ) {
598+ let textToCopy = this .buildCopyTextFromParts (messageParts);
599+ if (! textToCopy) textToCopy = ' [媒体内容]' ;
600+ await this .copyWithFeedback (textToCopy, messageIndex);
569601 },
570602
571603 // 显示复制成功提示
572604 showCopySuccess (messageIndex ) {
605+ if (this .copyFailedMessages .has (messageIndex)) {
606+ this .copyFailedMessages .delete (messageIndex);
607+ this .copyFailedMessages = new Set (this .copyFailedMessages );
608+ }
573609 this .copiedMessages .add (messageIndex);
610+ this .copiedMessages = new Set (this .copiedMessages );
574611
575612 // 2秒后移除成功状态
576613 setTimeout (() => {
577614 this .copiedMessages .delete (messageIndex);
615+ this .copiedMessages = new Set (this .copiedMessages );
616+ }, 2000 );
617+ },
618+
619+ // 显示复制失败提示
620+ showCopyFailure (messageIndex ) {
621+ if (this .copiedMessages .has (messageIndex)) {
622+ this .copiedMessages .delete (messageIndex);
623+ this .copiedMessages = new Set (this .copiedMessages );
624+ }
625+ this .copyFailedMessages .add (messageIndex);
626+ this .copyFailedMessages = new Set (this .copyFailedMessages );
627+
628+ setTimeout (() => {
629+ this .copyFailedMessages .delete (messageIndex);
630+ this .copyFailedMessages = new Set (this .copyFailedMessages );
578631 }, 2000 );
579632 },
580633
581634 // 获取复制按钮图标
582635 getCopyIcon (messageIndex ) {
583- return this .copiedMessages .has (messageIndex) ? ' mdi-check' : ' mdi-content-copy' ;
636+ if (this .copiedMessages .has (messageIndex)) return ' mdi-check' ;
637+ if (this .copyFailedMessages .has (messageIndex)) return ' mdi-alert-circle-outline' ;
638+ return ' mdi-content-copy' ;
584639 },
585640
586641 // 检查是否为复制成功状态
587642 isCopySuccess (messageIndex ) {
588643 return this .copiedMessages .has (messageIndex);
589644 },
590645
646+ // 检查是否为复制失败状态
647+ isCopyFailure (messageIndex ) {
648+ return this .copyFailedMessages .has (messageIndex);
649+ },
650+
651+ // 获取复制按钮提示文本
652+ getCopyTitle (messageIndex ) {
653+ if (this .isCopySuccess (messageIndex)) return this .t (' core.common.copied' );
654+ if (this .isCopyFailure (messageIndex)) return this .t (' core.common.copyFailed' );
655+ return this .t (' core.common.copy' );
656+ },
657+
591658 // 获取复制图标SVG
592659 getCopyIconSvg () {
593660 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 {
598665 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>' ;
599666 },
600667
668+ // 获取失败图标SVG
669+ getErrorIconSvg () {
670+ 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>' ;
671+ },
672+
601673 // 初始化代码块复制按钮
602674 initCodeCopyButtons () {
603675 this .$nextTick (() => {
@@ -608,15 +680,19 @@ export default {
608680 const button = document .createElement (' button' );
609681 button .className = ' copy-code-btn' ;
610682 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' ;
683+ button .title = this .t (' core.common.copy' );
684+ button .addEventListener (' click' , async () => {
685+ const res = await this .copyCodeToClipboard (codeBlock .textContent || ' ' );
686+ const ok = !! res? .ok ;
687+ button .innerHTML = ok ? this .getSuccessIconSvg () : this .getErrorIconSvg ();
688+ button .style .color = ok
689+ ? ' rgb(var(--v-theme-success))'
690+ : ' rgb(var(--v-theme-error))' ;
691+ button .setAttribute (" title" , this .t (` core.common.${ ok ? " copied" : " copyFailed" } ` ));
617692 setTimeout (() => {
618693 button .innerHTML = this .getCopyIconSvg ();
619694 button .style .color = ' ' ;
695+ button .setAttribute (" title" , this .t (' core.common.copy' ));
620696 }, 2000 );
621697 });
622698 pre .style .position = ' relative' ;
@@ -1077,13 +1153,23 @@ export default {
10771153}
10781154
10791155.copy - message- btn .copy - success {
1080- color: # 4caf50 ;
1156+ color: rgb ( var ( -- v - theme - success)) ;
10811157 opacity: 1 ;
10821158}
10831159
10841160.copy - message- btn .copy - success: hover {
1085- color: #4caf50 ;
1086- background- color: rgba (76 , 175 , 80 , 0.1 );
1161+ color: rgb (var (-- v- theme- success));
1162+ background- color: rgba (var (-- v- theme- success), 0.1 );
1163+ }
1164+
1165+ .copy - message- btn .copy - failed {
1166+ color: rgb (var (-- v- theme- error));
1167+ opacity: 1 ;
1168+ }
1169+
1170+ .copy - message- btn .copy - failed: hover {
1171+ color: rgb (var (-- v- theme- error));
1172+ background- color: rgba (var (-- v- theme- error), 0.1 );
10871173}
10881174
10891175.reply - message- btn {
0 commit comments