feat: 增强前端clipboard功能

This commit is contained in:
shaw
2025-12-27 15:16:52 +08:00
parent f1e47291cd
commit 016d7ef645
7 changed files with 73 additions and 52 deletions

View File

@@ -280,10 +280,12 @@
import { ref, watch, nextTick } from 'vue' import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Modal from '@/components/common/Modal.vue' import Modal from '@/components/common/Modal.vue'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, ClaudeModel } from '@/types' import type { Account, ClaudeModel } from '@/types'
const { t } = useI18n() const { t } = useI18n()
const { copyToClipboard } = useClipboard()
interface OutputLine { interface OutputLine {
text: string text: string
@@ -501,6 +503,6 @@ const handleEvent = (event: {
const copyOutput = () => { const copyOutput = () => {
const text = outputLines.value.map((l) => l.text).join('\n') const text = outputLines.value.map((l) => l.text).join('\n')
navigator.clipboard.writeText(text) copyToClipboard(text, t('admin.accounts.outputCopied'))
} }
</script> </script>

View File

@@ -119,7 +119,7 @@
import { ref, computed, h, watch, type Component } from 'vue' import { ref, computed, h, watch, type Component } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Modal from '@/components/common/Modal.vue' import Modal from '@/components/common/Modal.vue'
import { useAppStore } from '@/stores/app' import { useClipboard } from '@/composables/useClipboard'
import type { GroupPlatform } from '@/types' import type { GroupPlatform } from '@/types'
interface Props { interface Props {
@@ -150,7 +150,7 @@ const props = defineProps<Props>()
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const { copyToClipboard: clipboardCopy } = useClipboard()
const copiedIndex = ref<number | null>(null) const copiedIndex = ref<number | null>(null)
const activeTab = ref<string>('unix') const activeTab = ref<string>('unix')
@@ -340,14 +340,12 @@ ${key('requires_openai_auth')} ${operator('=')} ${keyword('true')}`
} }
const copyContent = async (content: string, index: number) => { const copyContent = async (content: string, index: number) => {
try { const success = await clipboardCopy(content, t('keys.copied'))
await navigator.clipboard.writeText(content) if (success) {
copiedIndex.value = index copiedIndex.value = index
setTimeout(() => { setTimeout(() => {
copiedIndex.value = null copiedIndex.value = null
}, 2000) }, 2000)
} catch (error) {
appStore.showError(t('common.copyFailed'))
} }
} }
</script> </script>

View File

@@ -1,40 +1,65 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
/**
* 检测是否支持 Clipboard API需要安全上下文HTTPS/localhost
*/
function isClipboardSupported(): boolean {
return !!(navigator.clipboard && window.isSecureContext)
}
/**
* 降级方案:使用 textarea + execCommand
* 使用 textarea 而非 input以正确处理多行文本
*/
function fallbackCopy(text: string): boolean {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.cssText = 'position:fixed;left:-9999px;top:-9999px'
document.body.appendChild(textarea)
textarea.select()
try {
return document.execCommand('copy')
} finally {
document.body.removeChild(textarea)
}
}
export function useClipboard() { export function useClipboard() {
const appStore = useAppStore() const appStore = useAppStore()
const copied = ref(false) const copied = ref(false)
const copyToClipboard = async (text: string, successMessage = 'Copied to clipboard') => { const copyToClipboard = async (
text: string,
successMessage = 'Copied to clipboard'
): Promise<boolean> => {
if (!text) return false if (!text) return false
try { let success = false
await navigator.clipboard.writeText(text)
copied.value = true if (isClipboardSupported()) {
appStore.showSuccess(successMessage) try {
setTimeout(() => { await navigator.clipboard.writeText(text)
copied.value = false success = true
}, 2000) } catch {
return true success = fallbackCopy(text)
} catch { }
// Fallback for older browsers } else {
const input = document.createElement('input') success = fallbackCopy(text)
input.value = text
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
copied.value = true
appStore.showSuccess(successMessage)
setTimeout(() => {
copied.value = false
}, 2000)
return true
} }
if (success) {
copied.value = true
appStore.showSuccess(successMessage)
setTimeout(() => {
copied.value = false
}, 2000)
} else {
appStore.showError('Copy failed')
}
return success
} }
return { return { copied, copyToClipboard }
copied,
copyToClipboard
}
} }

View File

@@ -418,6 +418,7 @@
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
import type { RedeemCode, RedeemCodeType, Group } from '@/types' import type { RedeemCode, RedeemCodeType, Group } from '@/types'
@@ -431,6 +432,7 @@ import Select from '@/components/common/Select.vue'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const { copyToClipboard: clipboardCopy } = useClipboard()
const showGenerateDialog = ref(false) const showGenerateDialog = ref(false)
const showResultDialog = ref(false) const showResultDialog = ref(false)
@@ -618,15 +620,12 @@ const handleGenerateCodes = async () => {
} }
const copyToClipboard = async (text: string) => { const copyToClipboard = async (text: string) => {
try { const success = await clipboardCopy(text, t('admin.redeem.copied'))
await navigator.clipboard.writeText(text) if (success) {
copiedCode.value = text copiedCode.value = text
setTimeout(() => { setTimeout(() => {
copiedCode.value = null copiedCode.value = null
}, 2000) }, 2000)
} catch (error) {
appStore.showError(t('admin.redeem.failedToCopy'))
console.error('Error copying to clipboard:', error)
} }
} }

View File

@@ -1173,6 +1173,7 @@
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
@@ -1191,6 +1192,7 @@ import Select from '@/components/common/Select.vue'
import GroupBadge from '@/components/common/GroupBadge.vue' import GroupBadge from '@/components/common/GroupBadge.vue'
const appStore = useAppStore() const appStore = useAppStore()
const { copyToClipboard: clipboardCopy } = useClipboard()
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'email', label: t('admin.users.columns.user'), sortable: true }, { key: 'email', label: t('admin.users.columns.user'), sortable: true },
@@ -1312,27 +1314,23 @@ const generateEditPassword = () => {
const copyPassword = async () => { const copyPassword = async () => {
if (!createForm.password) return if (!createForm.password) return
try { const success = await clipboardCopy(createForm.password, t('admin.users.passwordCopied'))
await navigator.clipboard.writeText(createForm.password) if (success) {
passwordCopied.value = true passwordCopied.value = true
setTimeout(() => { setTimeout(() => {
passwordCopied.value = false passwordCopied.value = false
}, 2000) }, 2000)
} catch (error) {
appStore.showError(t('common.copyFailed'))
} }
} }
const copyEditPassword = async () => { const copyEditPassword = async () => {
if (!editForm.password) return if (!editForm.password) return
try { const success = await clipboardCopy(editForm.password, t('admin.users.passwordCopied'))
await navigator.clipboard.writeText(editForm.password) if (success) {
editPasswordCopied.value = true editPasswordCopied.value = true
setTimeout(() => { setTimeout(() => {
editPasswordCopied.value = false editPasswordCopied.value = false
}, 2000) }, 2000)
} catch (error) {
appStore.showError(t('common.copyFailed'))
} }
} }

View File

@@ -493,6 +493,7 @@
import { ref, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue' import { ref, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
const { t } = useI18n() const { t } = useI18n()
import { keysAPI, authAPI, usageAPI, userGroupsAPI } from '@/api' import { keysAPI, authAPI, usageAPI, userGroupsAPI } from '@/api'
@@ -520,6 +521,7 @@ interface GroupOption {
} }
const appStore = useAppStore() const appStore = useAppStore()
const { copyToClipboard: clipboardCopy } = useClipboard()
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'name', label: t('common.name'), sortable: true }, { key: 'name', label: t('common.name'), sortable: true },
@@ -616,14 +618,12 @@ const maskKey = (key: string): string => {
} }
const copyToClipboard = async (text: string, keyId: number) => { const copyToClipboard = async (text: string, keyId: number) => {
try { const success = await clipboardCopy(text, t('keys.copied'))
await navigator.clipboard.writeText(text) if (success) {
copiedKeyId.value = keyId copiedKeyId.value = keyId
setTimeout(() => { setTimeout(() => {
copiedKeyId.value = null copiedKeyId.value = null
}, 2000) }, 2000)
} catch (error) {
appStore.showError(t('common.copyFailed'))
} }
} }

File diff suppressed because one or more lines are too long