fix(admin): copy buttons dead on HTTP origin — execCommand fallback
navigator.clipboard 仅安全上下文(HTTPS/localhost)可用,测试服务器经 http://IP:32005 访问时为 undefined,四处复制按钮(公开令牌/分享链接/ 嵌入代码/重置密码)点击即抛 TypeError 且无提示。统一 utils/clipboard copyText:API 优先、失败降级隐藏 textarea + execCommand。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -125,6 +125,7 @@
|
|||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { formatDateTime } from '@/utils/albums/format'
|
import { formatDateTime } from '@/utils/albums/format'
|
||||||
|
import { copyText } from '@/utils/clipboard'
|
||||||
|
|
||||||
defineOptions({ name: 'AlbumSummaryHeader' })
|
defineOptions({ name: 'AlbumSummaryHeader' })
|
||||||
|
|
||||||
@@ -163,8 +164,11 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
async function copyToken() {
|
async function copyToken() {
|
||||||
await navigator.clipboard.writeText(props.album.publicToken)
|
if (await copyText(props.album.publicToken)) {
|
||||||
ElMessage.success(t('albumsManage.list.copied'))
|
ElMessage.success(t('albumsManage.list.copied'))
|
||||||
|
} else {
|
||||||
|
ElMessage.error(t('setting.actions.copyFailed'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
viewerBaseFromEnv,
|
viewerBaseFromEnv,
|
||||||
type QrDraw
|
type QrDraw
|
||||||
} from '@/utils/albums/shareLink'
|
} from '@/utils/albums/shareLink'
|
||||||
|
import { copyText } from '@/utils/clipboard'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
album: Api.Album.Detail
|
album: Api.Album.Detail
|
||||||
@@ -104,8 +105,11 @@
|
|||||||
)
|
)
|
||||||
|
|
||||||
async function copyLink(): Promise<void> {
|
async function copyLink(): Promise<void> {
|
||||||
await navigator.clipboard.writeText(viewerUrl.value)
|
if (await copyText(viewerUrl.value)) {
|
||||||
ElMessage.success(t('albumsManage.list.copied'))
|
ElMessage.success(t('albumsManage.list.copied'))
|
||||||
|
} else {
|
||||||
|
ElMessage.error(t('setting.actions.copyFailed'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { buildEmbedSnippet, embedOriginFromEnv } from '@/utils/system/embedSlots'
|
import { buildEmbedSnippet, embedOriginFromEnv } from '@/utils/system/embedSlots'
|
||||||
|
import { copyText } from '@/utils/clipboard'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** 目标展示位(publicToken 拼入 data-slot;settings 生成显式 data-* 覆盖) */
|
/** 目标展示位(publicToken 拼入 data-slot;settings 生成显式 data-* 覆盖) */
|
||||||
@@ -81,7 +82,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copySnippet(): Promise<void> {
|
async function copySnippet(): Promise<void> {
|
||||||
await navigator.clipboard.writeText(snippet.value)
|
if (await copyText(snippet.value)) {
|
||||||
ElMessage.success(t('embedSlotsManage.code.copied'))
|
ElMessage.success(t('embedSlotsManage.code.copied'))
|
||||||
|
} else {
|
||||||
|
ElMessage.error(t('setting.actions.copyFailed'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useResetPassword } from './composables/useResetPassword'
|
import { useResetPassword } from './composables/useResetPassword'
|
||||||
|
import { copyText } from '@/utils/clipboard'
|
||||||
import type { ResetPasswordMode } from '@/utils/system/userAdmin'
|
import type { ResetPasswordMode } from '@/utils/system/userAdmin'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -129,7 +130,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyPassword(): Promise<void> {
|
async function copyPassword(): Promise<void> {
|
||||||
await navigator.clipboard.writeText(flow.password.value)
|
if (await copyText(flow.password.value)) {
|
||||||
ElMessage.success(t('usersManage.reset.copied'))
|
ElMessage.success(t('usersManage.reset.copied'))
|
||||||
|
} else {
|
||||||
|
ElMessage.error(t('setting.actions.copyFailed'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user