fix(ui): clipboard copy fails in non-HTTPS context

Use textarea + execCommand fallback when navigator.clipboard is unavailable. Consolidates 5 call sites into a single helper.
This commit is contained in:
lambert 2026-05-08 10:23:58 +08:00 committed by GitHub
parent 023f0cfb06
commit a92f76ff87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 29 additions and 6 deletions

View File

@ -424,6 +424,7 @@ import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { CHANNEL_FIELD_DEFS } from '@/types'
import { copyToClipboard } from '@/utils/clipboard'
import type { Agent, Channel, ChannelFieldDef } from '@/types'
import {
buildConfigJson,
@ -629,7 +630,7 @@ const copyLabel = ref(t('channels.webhook.copy'))
async function copyWebhookUrl() {
try {
await navigator.clipboard.writeText(webhookUrl.value)
await copyToClipboard(webhookUrl.value)
copyLabel.value = t('channels.webhook.copied')
setTimeout(() => { copyLabel.value = t('channels.webhook.copy') }, 2000)
} catch {
@ -639,7 +640,7 @@ async function copyWebhookUrl() {
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
await copyToClipboard(text)
ElMessage.success(t('common.copied'))
} catch {
ElMessage.warning(t('channels.webhook.copyFailed'))

View File

@ -366,6 +366,7 @@ import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment'
import { useToolLabel } from '@/composables/useToolLabel'
import { http } from '@/api'
import { copyToClipboard } from '@/utils/clipboard'
import TypingCursor from './TypingCursor.vue'
import BrowserTimeline from './BrowserTimeline.vue'
import ToolCallSegment from './ToolCallSegment.vue'
@ -563,7 +564,7 @@ let copyTimer: ReturnType<typeof setTimeout> | null = null
function copyMessage() {
const text = displayContent.value || props.message.content || ''
if (!text) return
navigator.clipboard.writeText(text).then(() => {
copyToClipboard(text).then(() => {
copyState.value = 'copied'
if (copyTimer) clearTimeout(copyTimer)
copyTimer = setTimeout(() => { copyState.value = 'idle' }, 2000)

View File

@ -0,0 +1,18 @@
/**
* Copy text to clipboard with fallback for non-HTTPS contexts
* where navigator.clipboard is undefined.
*/
export async function copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard) {
return navigator.clipboard.writeText(text)
}
// Fallback: textarea + execCommand('copy')
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}

View File

@ -208,6 +208,7 @@ import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { mcConfirm } from '@/components/common/useConfirm'
import { agentApi, agentContextApi } from '@/api/index'
import { copyToClipboard } from '@/utils/clipboard'
import type { Agent, WorkspaceFile } from '@/types/index'
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
import { plainTextIcon } from '@/composables/usePixelarticons'
@ -330,7 +331,7 @@ function handlePreviewClick(e: MouseEvent) {
const encoded = btn.getAttribute('data-code')
if (!encoded) return
const code = decodeURIComponent(encoded)
navigator.clipboard.writeText(code).then(() => {
copyToClipboard(code).then(() => {
btn.classList.add('copied')
const textEl = btn.querySelector('.code-block__copy-text')
if (textEl) textEl.textContent = t('chat.copied') || 'Copied'

View File

@ -317,6 +317,7 @@ import { mcConfirm } from '@/components/common/useConfirm'
import { ChatDotRound, Delete, Plus, Setting, UploadFilled } from '@element-plus/icons-vue'
import { conversationApi, agentApi, modelApi, chatApi, cronJobApi } from '@/api/index'
import { channelIconUrl } from '@/utils/channelSource'
import { copyToClipboard } from '@/utils/clipboard'
import { useChat } from '@/composables/chat/useChat'
import { reconstructErrorInfo } from '@/types/chatError'
import { reconcileMessages, extractMessages } from '@/utils/messageReconcile'
@ -1741,7 +1742,7 @@ function handleCodeCopy(e: MouseEvent) {
const encoded = btn.getAttribute('data-code')
if (!encoded) return
const code = decodeURIComponent(encoded)
navigator.clipboard.writeText(code).then(() => {
copyToClipboard(code).then(() => {
btn.classList.add('copied')
const textEl = btn.querySelector('.code-block__copy-text')
if (textEl) textEl.textContent = t('chat.copied')

View File

@ -41,6 +41,7 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { copyToClipboard } from '@/utils/clipboard'
const props = defineProps<{
visible: boolean
@ -83,7 +84,7 @@ onBeforeUnmount(() => {
async function copyCode() {
try {
await navigator.clipboard.writeText(props.userCode)
await copyToClipboard(props.userCode)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {