diff --git a/apps/admin/src/components/business/albums/AlbumSummaryHeader.vue b/apps/admin/src/components/business/albums/AlbumSummaryHeader.vue index c1767ff..b45f138 100644 --- a/apps/admin/src/components/business/albums/AlbumSummaryHeader.vue +++ b/apps/admin/src/components/business/albums/AlbumSummaryHeader.vue @@ -125,6 +125,7 @@ } from '@element-plus/icons-vue' import { ElMessage } from 'element-plus' import { formatDateTime } from '@/utils/albums/format' + import { copyText } from '@/utils/clipboard' defineOptions({ name: 'AlbumSummaryHeader' }) @@ -163,8 +164,11 @@ ]) async function copyToken() { - await navigator.clipboard.writeText(props.album.publicToken) - ElMessage.success(t('albumsManage.list.copied')) + if (await copyText(props.album.publicToken)) { + ElMessage.success(t('albumsManage.list.copied')) + } else { + ElMessage.error(t('setting.actions.copyFailed')) + } } diff --git a/apps/admin/src/utils/clipboard.spec.ts b/apps/admin/src/utils/clipboard.spec.ts new file mode 100644 index 0000000..d050bed --- /dev/null +++ b/apps/admin/src/utils/clipboard.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' +import { copyText } from './clipboard' + +describe('copyText 跨上下文复制', () => { + it('安全上下文:走 navigator.clipboard.writeText', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) + await expect(copyText('token-1')).resolves.toBe(true) + expect(writeText).toHaveBeenCalledWith('token-1') + }) + + it('clipboard API 抛错 → 降级 execCommand 仍可复制', async () => { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, + configurable: true, + }) + const exec = vi.fn().mockReturnValue(true) + Object.defineProperty(document, 'execCommand', { value: exec, configurable: true }) + await expect(copyText('token-2')).resolves.toBe(true) + expect(exec).toHaveBeenCalledWith('copy') + }) + + it('非安全上下文(clipboard undefined):经临时 textarea execCommand 复制并清理 DOM', async () => { + Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true }) + const exec = vi.fn().mockImplementation(() => { + // 断言降级瞬间 DOM 中恰有一个屏外 textarea 且携带目标文本 + const ta = document.querySelector('textarea') + expect(ta?.value).toBe('http://x/a/tok') + expect(ta?.style.top).toBe('-9999px') + return true + }) + Object.defineProperty(document, 'execCommand', { value: exec, configurable: true }) + await expect(copyText('http://x/a/tok')).resolves.toBe(true) + expect(document.querySelector('textarea')).toBeNull() // 用后即删 + }) + + it('execCommand 也失败 → false(调用方提示失败)', async () => { + Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true }) + Object.defineProperty(document, 'execCommand', { + value: vi.fn().mockReturnValue(false), + configurable: true, + }) + await expect(copyText('x')).resolves.toBe(false) + }) +}) diff --git a/apps/admin/src/utils/clipboard.ts b/apps/admin/src/utils/clipboard.ts new file mode 100644 index 0000000..0b15979 --- /dev/null +++ b/apps/admin/src/utils/clipboard.ts @@ -0,0 +1,34 @@ +/** + * 复制文本到剪贴板(跨上下文兼容)。 + * + * navigator.clipboard 仅**安全上下文**(HTTPS / localhost)可用——测试环境经 + * http://IP:32005 访问时该 API 为 undefined,直接调用抛 TypeError(按钮 + * 「点击无效」即此因)。降级路径:隐藏 textarea + select + execCommand('copy') + * (旧 API,HTTP 下可用);两条路径都失败返回 false 由调用方提示。 + */ +export async function copyText(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + // 权限拒绝/焦点丢失等 → 落入 execCommand 降级 + } + } + const textarea = document.createElement('textarea') + textarea.value = text + textarea.setAttribute('readonly', '') + // fixed + 屏外定位:不触发页面滚动,也避免移动端键盘弹出 + textarea.style.position = 'fixed' + textarea.style.top = '-9999px' + document.body.appendChild(textarea) + textarea.select() + let ok = false + try { + ok = document.execCommand('copy') + } catch { + ok = false + } + document.body.removeChild(textarea) + return ok +} diff --git a/apps/admin/src/views/albums/detail/AlbumShareCard.vue b/apps/admin/src/views/albums/detail/AlbumShareCard.vue index 9970094..fafab35 100644 --- a/apps/admin/src/views/albums/detail/AlbumShareCard.vue +++ b/apps/admin/src/views/albums/detail/AlbumShareCard.vue @@ -53,6 +53,7 @@ viewerBaseFromEnv, type QrDraw } from '@/utils/albums/shareLink' + import { copyText } from '@/utils/clipboard' const props = defineProps<{ album: Api.Album.Detail @@ -104,8 +105,11 @@ ) async function copyLink(): Promise { - await navigator.clipboard.writeText(viewerUrl.value) - ElMessage.success(t('albumsManage.list.copied')) + if (await copyText(viewerUrl.value)) { + ElMessage.success(t('albumsManage.list.copied')) + } else { + ElMessage.error(t('setting.actions.copyFailed')) + } } diff --git a/apps/admin/src/views/system/embed-slots/EmbedCodeDialog.vue b/apps/admin/src/views/system/embed-slots/EmbedCodeDialog.vue index 4a0c385..80bbf7f 100644 --- a/apps/admin/src/views/system/embed-slots/EmbedCodeDialog.vue +++ b/apps/admin/src/views/system/embed-slots/EmbedCodeDialog.vue @@ -61,6 +61,7 @@ import { useI18n } from 'vue-i18n' import { ElMessage } from 'element-plus' import { buildEmbedSnippet, embedOriginFromEnv } from '@/utils/system/embedSlots' + import { copyText } from '@/utils/clipboard' const props = defineProps<{ /** 目标展示位(publicToken 拼入 data-slot;settings 生成显式 data-* 覆盖) */ @@ -81,7 +82,10 @@ } async function copySnippet(): Promise { - await navigator.clipboard.writeText(snippet.value) - ElMessage.success(t('embedSlotsManage.code.copied')) + if (await copyText(snippet.value)) { + ElMessage.success(t('embedSlotsManage.code.copied')) + } else { + ElMessage.error(t('setting.actions.copyFailed')) + } } diff --git a/apps/admin/src/views/system/users/ResetPasswordDialog.vue b/apps/admin/src/views/system/users/ResetPasswordDialog.vue index 7992a19..805d5d4 100644 --- a/apps/admin/src/views/system/users/ResetPasswordDialog.vue +++ b/apps/admin/src/views/system/users/ResetPasswordDialog.vue @@ -91,6 +91,7 @@ import { useI18n } from 'vue-i18n' import { ElMessage } from 'element-plus' import { useResetPassword } from './composables/useResetPassword' + import { copyText } from '@/utils/clipboard' import type { ResetPasswordMode } from '@/utils/system/userAdmin' const props = defineProps<{ @@ -129,7 +130,10 @@ } async function copyPassword(): Promise { - await navigator.clipboard.writeText(flow.password.value) - ElMessage.success(t('usersManage.reset.copied')) + if (await copyText(flow.password.value)) { + ElMessage.success(t('usersManage.reset.copied')) + } else { + ElMessage.error(t('setting.actions.copyFailed')) + } }