From f0bd09b31aacb6b91e320ece0d9d7089ab8ea7f1 Mon Sep 17 00:00:00 2001 From: tbphp Date: Wed, 9 Sep 2026 09:51:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E5=85=BC=E5=AE=B9=20HTTP=20?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E5=A4=8D=E5=88=B6=E5=B9=B6=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=BC=B9=E7=AA=97=20(#602)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): 统一复制失败的明文弹窗兜底 * fix(web): 将复制兜底内容改为只读单行输入框 --- web/src/app/use-clipboard-copy.ts | 57 +++++++++ web/src/components/ui/CopyButton.vue | 42 ++++-- web/src/components/ui/CopyChip.vue | 46 +++---- web/src/components/ui/CopyFallbackDialog.vue | 120 ++++++++++++++++++ .../access-keys/AccessKeyCollection.vue | 3 + .../access-keys/AccessKeyRotateDialog.vue | 2 + .../credentials/GroupCredentialRecord.vue | 1 + web/src/features/home/GatewayConnection.vue | 56 ++++---- .../features/models/ModelUpstreamDrawer.vue | 3 +- web/src/features/monitor/LogDetailDrawer.vue | 4 +- web/src/i18n/locales/en-US/core.ts | 8 +- web/src/i18n/locales/ja-JP/core.ts | 8 +- web/src/i18n/locales/zh-CN/core.ts | 7 +- web/src/lib/clipboard.ts | 34 ++++- 14 files changed, 316 insertions(+), 75 deletions(-) create mode 100644 web/src/app/use-clipboard-copy.ts create mode 100644 web/src/components/ui/CopyFallbackDialog.vue diff --git a/web/src/app/use-clipboard-copy.ts b/web/src/app/use-clipboard-copy.ts new file mode 100644 index 00000000..53cf7c39 --- /dev/null +++ b/web/src/app/use-clipboard-copy.ts @@ -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() + 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), + ): Promise { + 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 } +} diff --git a/web/src/components/ui/CopyButton.vue b/web/src/components/ui/CopyButton.vue index bcf315cb..aad48459 100644 --- a/web/src/components/ui/CopyButton.vue +++ b/web/src/components/ui/CopyButton.vue @@ -1,9 +1,9 @@ diff --git a/web/src/components/ui/CopyChip.vue b/web/src/components/ui/CopyChip.vue index 34065ed5..2a59b076 100644 --- a/web/src/components/ui/CopyChip.vue +++ b/web/src/components/ui/CopyChip.vue @@ -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('idle') let resetTimer: ReturnType | undefined @@ -44,21 +44,27 @@ function scheduleReset(): void { } async function copyValue(): Promise { - 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" >