fix(web): 兼容 HTTP 密钥复制并统一失败弹窗 (#602)

* fix(web): 统一复制失败的明文弹窗兜底

* fix(web): 将复制兜底内容改为只读单行输入框
This commit is contained in:
tbphp authored and GitHub committed 2026-09-09 09:51:31 +08:00
1 parent d4699dd2b1
commit f0bd09b31a
14 files changed
+316 -75

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
import { onScopeDispose, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { copyText } from '@/lib/clipboard'
import { registerEphemeralStateCleaner } from './ephemeral-state'
export type ClipboardCopyResult = 'success' | 'fallback' | 'cancelled'
export function useClipboardCopy() {
const fallbackText = ref<string>()
const pending = ref(false)
const route = useRoute()
let sequence = 0
let disposed = false
function reset(): void {
sequence++
fallbackText.value = undefined
pending.value = false
}
async function copy(
source: string | (() => string | Promise<string>),
): Promise<ClipboardCopyResult> {
if (disposed) return 'cancelled'
reset()
const operation = sequence
const isCurrent = () => !disposed && sequence === operation
pending.value = true
try {
// 已有文本直接复制,避免在用户点击与兼容复制之间额外等待。
const value = typeof source === 'function' ? await source() : source
if (!isCurrent()) return 'cancelled'
const copied = await copyText(value, undefined, isCurrent)
if (!isCurrent()) return 'cancelled'
if (copied) return 'success'
fallbackText.value = value
return 'fallback'
} catch (error) {
if (!isCurrent()) return 'cancelled'
throw error
} finally {
if (isCurrent()) pending.value = false
}
}
watch(() => route.fullPath, reset, { flush: 'sync' })
const unregister = registerEphemeralStateCleaner(reset)
onScopeDispose(() => {
disposed = true
reset()
unregister()
})
return { copy, fallbackText, pending, reset }
}
+28 -14
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { Check, Copy } from '@lucide/vue'
import { onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { onBeforeUnmount, ref, watch } from 'vue'
import { copyText } from '@/lib/clipboard'
import { useClipboardCopy } from '@/app/use-clipboard-copy'
import CopyFallbackDialog from './CopyFallbackDialog.vue'
const props = defineProps<{
value: string
@@ -11,13 +11,17 @@ const props = defineProps<{
successLabel: string
failureLabel: string
}>()
const { t } = useI18n()
const state = ref<'idle' | 'success' | 'unsupported' | 'failure'>('idle')
const { copy: copyValue, fallbackText, pending, reset } = useClipboardCopy()
const state = ref<'idle' | 'success' | 'failure'>('idle')
let resetTimer: number | undefined
async function copy(): Promise<void> {
if (pending.value) return
state.value = 'idle'
try {
state.value = (await copyText(props.value)) ? 'success' : 'failure'
const result = await copyValue(props.value)
if (result === 'cancelled') return
state.value = result === 'success' ? 'success' : 'idle'
} catch {
state.value = 'failure'
}
@@ -25,12 +29,27 @@ async function copy(): Promise<void> {
resetTimer = window.setTimeout(() => (state.value = 'idle'), 2000)
}
watch(
() => props.value,
() => {
reset()
state.value = 'idle'
},
{ flush: 'sync' },
)
onBeforeUnmount(() => window.clearTimeout(resetTimer))
</script>
<template>
<span class="copy-control">
<button type="button" :aria-label="label" @click="copy">
<button
type="button"
:aria-label="label"
:aria-busy="pending"
:disabled="pending"
@click="copy"
>
<Check v-if="state === 'success'" :size="16" aria-hidden="true" />
<Copy v-else :size="16" aria-hidden="true" />
</button>
@@ -41,14 +60,9 @@ onBeforeUnmount(() => window.clearTimeout(resetTimer))
aria-live="polite"
aria-atomic="true"
>
{{
state === 'unsupported'
? t('common.copyUnsupported')
: state === 'success'
? successLabel
: failureLabel
}}
{{ state === 'success' ? successLabel : failureLabel }}
</span>
<CopyFallbackDialog v-if="fallbackText !== undefined" :value="fallbackText" @close="reset" />
</span>
</template>
+23 -23
View File
@@ -7,10 +7,10 @@ import {
TooltipRoot,
TooltipTrigger,
} from 'reka-ui'
import { onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { onBeforeUnmount, ref, watch } from 'vue'
import { canWriteToClipboardNatively, copyText } from '@/lib/clipboard'
import { useClipboardCopy } from '@/app/use-clipboard-copy'
import CopyFallbackDialog from './CopyFallbackDialog.vue'
import OverflowTooltip from './OverflowTooltip.vue'
const props = withDefaults(
@@ -29,9 +29,9 @@ const props = withDefaults(
{ resolveValue: undefined, layout: 'leading' },
)
type CopyState = 'idle' | 'success' | 'unsupported' | 'failure'
type CopyState = 'idle' | 'success' | 'failure'
const { t } = useI18n()
const { copy, fallbackText, pending, reset } = useClipboardCopy()
const state = ref<CopyState>('idle')
let resetTimer: ReturnType<typeof setTimeout> | undefined
@@ -44,21 +44,27 @@ function scheduleReset(): void {
}
async function copyValue(): Promise<void> {
if (props.resolveValue && !canWriteToClipboardNatively()) {
state.value = 'unsupported'
scheduleReset()
return
}
if (pending.value) return
state.value = 'idle'
try {
const value = props.resolveValue ? await props.resolveValue() : props.value
state.value = (await copyText(value)) ? 'success' : 'failure'
const result = await copy(props.resolveValue ?? props.value)
if (result === 'cancelled') return
state.value = result === 'success' ? 'success' : 'idle'
} catch {
state.value = 'failure'
}
scheduleReset()
}
watch(
() => props.value,
() => {
reset()
state.value = 'idle'
},
{ flush: 'sync' },
)
onBeforeUnmount(() => {
if (resetTimer !== undefined) clearTimeout(resetTimer)
})
@@ -79,6 +85,8 @@ onBeforeUnmount(() => {
:data-state="state"
type="button"
:aria-label="label"
:aria-busy="pending"
:disabled="pending"
@click="copyValue"
>
<Copy v-if="layout === 'leading'" :size="14" aria-hidden="true" />
@@ -105,17 +113,12 @@ onBeforeUnmount(() => {
role="status"
aria-live="polite"
>
{{
state === 'unsupported'
? t('common.copyUnsupported')
: state === 'success'
? successLabel
: failureLabel
}}
{{ state === 'success' ? successLabel : failureLabel }}
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</TooltipProvider>
<CopyFallbackDialog v-if="fallbackText !== undefined" :value="fallbackText" @close="reset" />
</span>
</template>
@@ -172,7 +175,6 @@ onBeforeUnmount(() => {
}
.copy-chip:hover,
.copy-chip[data-state='unsupported'],
.copy-chip[data-state='success'],
.copy-chip[data-state='failure'] {
background: var(--color-surface-sunken);
@@ -183,7 +185,6 @@ onBeforeUnmount(() => {
color: var(--color-success);
}
.copy-chip[data-state='unsupported'],
.copy-chip[data-state='failure'] {
color: var(--color-danger);
}
@@ -210,7 +211,6 @@ onBeforeUnmount(() => {
white-space: nowrap;
}
.copy-chip__feedback--unsupported,
.copy-chip__feedback--failure {
border-color: var(--color-feedback-danger-border);
color: var(--color-danger);
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { copyText } from '@/lib/clipboard'
import AppButton from './AppButton.vue'
import AppDialog from './AppDialog.vue'
import InlineFeedback from './InlineFeedback.vue'
const props = defineProps<{ value?: string }>()
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
const pending = ref(false)
const state = ref<'idle' | 'success' | 'failure'>('idle')
let attempt = 0
function reset(): void {
attempt += 1
pending.value = false
state.value = 'idle'
}
function close(): void {
reset()
emit('close')
}
async function copyValue(): Promise<void> {
if (props.value === undefined || pending.value) return
const currentAttempt = ++attempt
pending.value = true
state.value = 'idle'
try {
const copied = await copyText(props.value, undefined, () => currentAttempt === attempt)
if (currentAttempt === attempt) state.value = copied ? 'success' : 'failure'
} catch {
if (currentAttempt === attempt) state.value = 'failure'
} finally {
if (currentAttempt === attempt) pending.value = false
}
}
function preserveMultilineCopy(event: ClipboardEvent): void {
const value = props.value
if (value === undefined || !/[\r\n]/.test(value) || !event.clipboardData) return
const input = event.target as HTMLInputElement
if (input.selectionStart !== 0 || input.selectionEnd !== input.value.length) return
// 单行输入框会移除换行,全选手动复制时仍保留原始配置。
event.clipboardData.setData('text/plain', value)
event.preventDefault()
}
watch(() => props.value, reset, { flush: 'sync' })
onBeforeUnmount(reset)
</script>
<template>
<AppDialog
:open="value !== undefined"
:title="t('common.copyFallback.title')"
:description="t('common.copyFallback.description')"
:close-label="t('common.close')"
@update:open="!$event && close()"
>
<template #body>
<div class="copy-fallback">
<label class="copy-fallback__field">
<span>{{ t('common.copyFallback.valueLabel') }}</span>
<input
class="copy-fallback__value"
type="text"
:value="value"
readonly
autocomplete="off"
spellcheck="false"
@copy="preserveMultilineCopy"
/>
</label>
<InlineFeedback v-if="state === 'success'" tone="success">
{{ t('common.copied') }}
</InlineFeedback>
<InlineFeedback v-else-if="state === 'failure'" tone="warning">
{{ t('common.copyFallback.failed') }}
</InlineFeedback>
</div>
</template>
<template #footer>
<AppButton variant="secondary" @click="close">{{ t('common.close') }}</AppButton>
<AppButton :busy="pending" @click="copyValue">{{ t('common.copy') }}</AppButton>
</template>
</AppDialog>
</template>
<style scoped>
.copy-fallback {
display: grid;
gap: var(--space-3);
}
.copy-fallback__field {
display: grid;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.copy-fallback__value {
width: 100%;
min-height: var(--control-md);
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface-sunken);
color: var(--color-text);
padding: 0 var(--space-3);
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: var(--line-normal);
}
</style>
@@ -70,6 +70,7 @@ function viewLogs(id: number): void {
const client = useApiClient()
const { locale, t } = useI18n()
const copyControllers = useAbortControllerPool()
const copyGeneration = ref(0)
const sources = computed(
() => new Map(props.accessKeys.map((accessKey) => [accessKey.id, accessKey])),
)
@@ -169,6 +170,7 @@ async function resolveCopyValue(id: number): Promise<string> {
}
function conceal(): void {
copyGeneration.value++
menuKeyID.value = undefined
copyControllers.abortAll()
}
@@ -211,6 +213,7 @@ watch(
<div class="ledger-record-list__cell access-key-secret-cell" role="cell">
<span class="mobile-label">{{ t('accessKeys.columns.key') }}</span>
<CopyChip
:key="`${copyGeneration}:${source(record.id).updated_at_ms}`"
layout="trailing"
:value="record.maskedKey"
:label="t('accessKeys.copy')"
@@ -257,6 +257,8 @@ onBeforeUnmount(() => controller?.abort())
t(result.key ? 'accessKeys.rotate.newKey' : 'accessKeys.rotate.currentKey')
}}</strong>
<CopyChip
v-if="open"
:key="`${accessKey.id}:${result.updated_at_ms}`"
:value="displayKey"
:label="t('accessKeys.copy')"
:success-label="t('common.copied')"
@@ -148,6 +148,7 @@ function runMenuAction(action: 'test' | 'toggle' | 'restore' | 'remove'): void {
}}</span>
<span class="group-credential-record__credential">
<CopyChip
:key="item.secret_version"
:value="item.mask"
:label="t('group.credentials.copy')"
:success-label="t('common.copied')"
+30 -26
View File
@@ -8,15 +8,17 @@ import { useApiClient } from '@/api/client-context'
import type { HomeBaseDto } from '@/app/resources/home'
import { accessKeysLocation } from '@/app/route-locations'
import { revealAccessKey } from '@/app/resources/access-keys'
import { registerEphemeralStateCleaner } from '@/app/ephemeral-state'
import { type ClipboardCopyResult, useClipboardCopy } from '@/app/use-clipboard-copy'
import AppButton from '@/components/ui/AppButton.vue'
import AppConfirmDialog from '@/components/ui/AppConfirmDialog.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import AppTextInput from '@/components/ui/AppTextInput.vue'
import CodeBlock from '@/components/ui/CodeBlock.vue'
import CopyAction from '@/components/ui/CopyAction.vue'
import CopyFallbackDialog from '@/components/ui/CopyFallbackDialog.vue'
import InlineFeedback from '@/components/ui/InlineFeedback.vue'
import ChannelIcon from '@/components/brand/ChannelIcon.vue'
import { canWriteToClipboardNatively, copyText } from '@/lib/clipboard'
import ClientPicker from './ClientPicker.vue'
import HomeSectionHeading from './HomeSectionHeading.vue'
@@ -45,7 +47,7 @@ const emit = defineEmits<{
}>()
type ActionTarget = 'key' | 'configuration' | 'quick-import' | 'baseUrl' | 'apiKey'
type FeedbackKind = 'success' | 'failure' | 'popup-blocked' | 'unsupported'
type FeedbackKind = 'success' | 'failure' | 'popup-blocked'
interface OperationIdentity {
operationID: number
@@ -60,6 +62,7 @@ interface ActionFeedback extends OperationIdentity {
const client = useApiClient()
const { t } = useI18n()
const { copy, fallbackText, reset: resetCopy } = useClipboardCopy()
const selectedKeyID = computed(() => props.selectedAccessKeyId)
const activeClient = computed(() => props.clientId)
const feedback = ref<ActionFeedback | null>(null)
@@ -135,16 +138,13 @@ function fieldCopyState(id: ActionTarget): 'idle' | 'success' {
async function copyField(field: { id: 'baseUrl' | 'apiKey'; secret?: boolean }): Promise<void> {
if (!selectedKeySupportsClient.value) return
if (field.secret && !canWriteToClipboardNatively()) {
setImmediateFeedback(field.id, 'unsupported')
return
}
if (!field.secret) {
// 非密钥字段不需要解密,直接复制展示值即可。
const entry = clientFieldList.value.find((candidate) => candidate.id === field.id)
if (!entry) return
try {
setImmediateFeedback(field.id, (await copyText(entry.value)) ? 'success' : 'failure')
const result = await copy(entry.value)
if (result === 'success') setImmediateFeedback(field.id, 'success')
} catch {
setImmediateFeedback(field.id, 'failure')
}
@@ -153,7 +153,7 @@ async function copyField(field: { id: 'baseUrl' | 'apiKey'; secret?: boolean }):
const clientID = activeClient.value
await withRevealedKey(field.id, clientID, async (key, isCurrent) => {
if (!isCurrent()) return
if (!(await copyText(key))) throw new Error('COPY_FAILED')
return copy(key)
})
}
@@ -170,13 +170,11 @@ const visibleFeedback = computed(() => {
})
const feedbackTone = computed(() => {
if (visibleFeedback.value?.kind === 'success') return 'success'
if (visibleFeedback.value?.kind === 'unsupported') return 'warning'
return 'danger'
})
const feedbackMessage = computed(() => {
const current = visibleFeedback.value
if (!current) return ''
if (current.kind === 'unsupported') return t('common.copyUnsupported')
if (current.target === 'key') {
return t(
current.kind === 'success'
@@ -280,6 +278,7 @@ function setImmediateFeedback(target: ActionTarget, kind: FeedbackKind): void {
}
function invalidateSensitiveAction(): void {
resetCopy()
activeOperationID = ++operationSequence
actionController?.abort()
actionController = undefined
@@ -318,6 +317,12 @@ watch(
{ immediate: true },
)
watch(
() => [ccSwitchTargetID.value, ccSwitchModel.value, props.credential],
invalidateSensitiveAction,
{ flush: 'sync' },
)
function selectKey(value: string): void {
if (actionBusy.value) return
const id = Number(value)
@@ -352,7 +357,7 @@ function selectFirstSupportedCCSwitchTarget(): void {
async function withRevealedKey(
target: ActionTarget,
clientID: GatewayClientID,
operation: (key: string, isCurrent: () => boolean) => Promise<void> | void,
operation: (key: string, isCurrent: () => boolean) => Promise<ClipboardCopyResult | void> | void,
): Promise<boolean> {
const accessKey = selectedKey.value
if (!accessKey || actionBusy.value || unmounted || activeClient.value !== clientID) {
@@ -379,8 +384,9 @@ async function withRevealedKey(
: (await revealAccessKey(client, identity.accessKeyID, controller.signal)).key
if (!secret) throw new Error('ACCESS_KEY_UNAVAILABLE')
if (!isCurrent()) return false
await operation(secret, isCurrent)
const result = await operation(secret, isCurrent)
if (!isCurrent()) return false
if (result !== undefined && result !== 'success') return false
setFeedback(identity, target, 'success')
return true
} catch {
@@ -396,14 +402,10 @@ async function withRevealedKey(
}
async function copyAccessKey(): Promise<void> {
if (!canWriteToClipboardNatively()) {
setImmediateFeedback('key', 'unsupported')
return
}
const clientID = activeClient.value
await withRevealedKey('key', clientID, async (key, isCurrent) => {
if (!isCurrent()) return
if (!(await copyText(key))) throw new Error('COPY_FAILED')
return copy(key)
})
}
@@ -413,19 +415,13 @@ async function copyClientConfiguration(): Promise<void> {
if (clientID === 'codex') {
try {
setImmediateFeedback(
'configuration',
(await copyText(maskedSnippet.value)) ? 'success' : 'failure',
)
const result = await copy(maskedSnippet.value)
if (result === 'success') setImmediateFeedback('configuration', 'success')
} catch {
setImmediateFeedback('configuration', 'failure')
}
return
}
if (!canWriteToClipboardNatively()) {
setImmediateFeedback('configuration', 'unsupported')
return
}
await withRevealedKey('configuration', clientID, async (key, isCurrent) => {
if (!isCurrent()) return
@@ -440,7 +436,7 @@ async function copyClientConfiguration(): Promise<void> {
`GPT-Load · ${selectedKey.value?.name ?? ''}`,
)
if (!isCurrent()) return
if (!(await copyText(configuration))) throw new Error('COPY_FAILED')
return await copy(configuration)
} finally {
configuration = undefined
}
@@ -485,9 +481,11 @@ async function openQuickImport(): Promise<void> {
if (!opened) popup.close()
}
const unregisterCopyCleaner = registerEphemeralStateCleaner(invalidateSensitiveAction)
onBeforeUnmount(() => {
unmounted = true
invalidateSensitiveAction()
unregisterCopyCleaner()
})
</script>
@@ -712,6 +710,12 @@ onBeforeUnmount(() => {
{{ feedbackMessage }}
</InlineFeedback>
<CopyFallbackDialog
v-if="fallbackText !== undefined"
:value="fallbackText"
@close="resetCopy"
/>
<AppConfirmDialog
v-model:open="quickImportConfirmationOpen"
:title="
@@ -129,8 +129,9 @@ defineExpose({ requestClose, confirmDiscardSwitch, discardChanges, hasUnsavedCha
:close-label="t('common.close')"
@update:open="requestClose"
>
<template v-if="detail" #title-adornment>
<template v-if="open && detail" #title-adornment>
<CopyChip
:key="priceId ?? undefined"
layout="icon"
:value="detail.model_id"
:label="t('models.drawer.copyModel', { model: detail.model_id })"
+3 -1
View File
@@ -147,6 +147,7 @@ const costAmountLabel = computed(() => {
watch(
() => props.requestId,
() => {
copyControllers.abortAll()
errorMessageExpanded.value = false
expandedAttemptErrorMessages.value = new Set()
},
@@ -479,7 +480,8 @@ function toggleAttemptErrorMessage(sequence: number): void {
appearance="plain"
/>
<CopyChip
v-if="log.credential_name"
v-if="open && log.credential_name"
:key="`${requestId}:${log.group_id}:${log.credential_id}`"
layout="icon"
:value="log.credential_name"
:label="t('monitor.logs.drawer.copyCredential')"
+7 -1
View File
@@ -27,7 +27,13 @@ export default {
copy: 'Copy',
copied: 'Copied',
copyFailed: 'Copy failed',
copyUnsupported: 'Copy is unavailable. Use HTTPS.',
copyFallback: {
title: 'Copy content',
description:
'Automatic copying failed. Try Copy again, or select the content below to copy it manually.',
valueLabel: 'Content to copy',
failed: 'Copying still failed. Select the content above to copy it manually.',
},
pagination: {
label: 'Pagination',
total: 'Total: {total}',
+7 -1
View File
@@ -27,7 +27,13 @@ export default {
copy: 'コピー',
copied: 'コピーしました',
copyFailed: 'コピーに失敗しました',
copyUnsupported: 'コピーを使用できません。HTTPS を使用してください',
copyFallback: {
title: '内容をコピー',
description:
'自動コピーに失敗しました。もう一度コピーを押すか、下の内容を選択して手動でコピーしてください。',
valueLabel: 'コピーする内容',
failed: 'コピーに失敗しました。上の内容を選択して手動でコピーしてください。',
},
pagination: {
label: 'ページ送り',
total: '全 {total} 件',
+6 -1
View File
@@ -25,7 +25,12 @@ export default {
copy: '复制',
copied: '已复制',
copyFailed: '复制失败',
copyUnsupported: '当前环境不支持复制,请使用 HTTPS',
copyFallback: {
title: '复制内容',
description: '自动复制未成功,请再次点击复制,或选中下方内容手动复制。',
valueLabel: '待复制内容',
failed: '复制仍未成功,请选中上方内容手动复制。',
},
pagination: {
label: '分页',
total: '共 {total} 条',
+27 -7
View File
@@ -4,7 +4,12 @@ export function canWriteToClipboardNatively(): boolean {
)
}
export async function copyText(value: string): Promise<boolean> {
export async function copyText(
value: string,
target?: HTMLTextAreaElement,
isCurrent: () => boolean = () => true,
): Promise<boolean> {
if (!isCurrent()) return false
const writeText = globalThis.navigator?.clipboard?.writeText
if (canWriteToClipboardNatively() && typeof writeText === 'function') {
try {
@@ -15,17 +20,32 @@ export async function copyText(value: string): Promise<boolean> {
}
}
const textarea = document.createElement('textarea')
textarea.value = value
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.append(textarea)
// 原生接口等待期间可能已经关闭弹窗或切换账号,失效后不再尝试兼容复制。
if (!isCurrent()) return false
const activeElement = document.activeElement
const textarea = target ?? document.createElement('textarea')
try {
if (target && !target.isConnected) return false
textarea.value = value
if (!target) {
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
// 保持在当前弹窗的焦点范围内,避免选中被焦点锁打断。
const container =
activeElement?.closest('[role="dialog"], [role="alertdialog"]') ?? document.body
container.append(textarea)
}
textarea.focus({ preventScroll: true })
textarea.select()
return document.execCommand('copy')
} catch {
return false
} finally {
textarea.remove()
if (!target) {
textarea.remove()
if (activeElement instanceof HTMLElement && activeElement.isConnected) {
activeElement.focus({ preventScroll: true })
}
}
}
}