fix(web): 兼容 HTTP 密钥复制并统一失败弹窗 (#602)
* fix(web): 统一复制失败的明文弹窗兜底 * fix(web): 将复制兜底内容改为只读单行输入框
This commit is contained in:
1 parent
d4699dd2b1
commit
f0bd09b31a
14 files changed
+316
-75
No files matched your search
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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 })"
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -27,7 +27,13 @@ export default {
|
||||
copy: 'コピー',
|
||||
copied: 'コピーしました',
|
||||
copyFailed: 'コピーに失敗しました',
|
||||
copyUnsupported: 'コピーを使用できません。HTTPS を使用してください',
|
||||
copyFallback: {
|
||||
title: '内容をコピー',
|
||||
description:
|
||||
'自動コピーに失敗しました。もう一度コピーを押すか、下の内容を選択して手動でコピーしてください。',
|
||||
valueLabel: 'コピーする内容',
|
||||
failed: 'コピーに失敗しました。上の内容を選択して手動でコピーしてください。',
|
||||
},
|
||||
pagination: {
|
||||
label: 'ページ送り',
|
||||
total: '全 {total} 件',
|
||||
|
||||
@@ -25,7 +25,12 @@ export default {
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
copyFailed: '复制失败',
|
||||
copyUnsupported: '当前环境不支持复制,请使用 HTTPS',
|
||||
copyFallback: {
|
||||
title: '复制内容',
|
||||
description: '自动复制未成功,请再次点击复制,或选中下方内容手动复制。',
|
||||
valueLabel: '待复制内容',
|
||||
failed: '复制仍未成功,请选中上方内容手动复制。',
|
||||
},
|
||||
pagination: {
|
||||
label: '分页',
|
||||
total: '共 {total} 条',
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user