diff --git a/frontend/src/components/ai-elements/masked-code-block.tsx b/frontend/src/components/ai-elements/masked-code-block.tsx index a8fd491f2..610479da3 100644 --- a/frontend/src/components/ai-elements/masked-code-block.tsx +++ b/frontend/src/components/ai-elements/masked-code-block.tsx @@ -4,6 +4,7 @@ import { type ComponentProps, createContext, type HTMLAttributes, useContext, us import type { Element } from 'hast'; import { CheckIcon, CopyIcon } from 'lucide-react'; import { type BundledLanguage, codeToHtml, type ShikiTransformer } from 'shiki'; +import { copyTextToClipboard } from '@/lib/clipboard'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; @@ -126,13 +127,8 @@ export const MaskedCodeBlockCopyButton = ({ onCopy, onError, timeout = 2000, chi const { realCode } = useContext(MaskedCodeBlockContext); const copyToClipboard = async () => { - if (typeof window === 'undefined' || !navigator?.clipboard?.writeText) { - onError?.(new Error('Clipboard API not available')); - return; - } - try { - await navigator.clipboard.writeText(realCode); + await copyTextToClipboard(realCode); setIsCopied(true); onCopy?.(); setTimeout(() => setIsCopied(false), timeout); diff --git a/frontend/src/features/apikeys/components/apikeys-columns.tsx b/frontend/src/features/apikeys/components/apikeys-columns.tsx index 28d7d4b00..d7eac0ed5 100644 --- a/frontend/src/features/apikeys/components/apikeys-columns.tsx +++ b/frontend/src/features/apikeys/components/apikeys-columns.tsx @@ -2,8 +2,8 @@ import { format } from 'date-fns'; import { ColumnDef, Table, Row } from '@tanstack/react-table'; import { Copy, Eye } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { toast } from 'sonner'; -import { cn, extractNumberID } from '@/lib/utils'; +import { extractNumberID } from '@/lib/utils'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { DataTableColumnHeader } from '@/components/data-table-column-header'; @@ -19,10 +19,10 @@ function ApiKeyCell({ apiKey, fullApiKey }: { apiKey: string; fullApiKey: ApiKey // 显示前8个字符和后4个字符,中间用省略号 const maskedKey = apiKey.replace(/./g, '*').slice(0, -4) + apiKey.slice(-4); - const copyToClipboard = () => { - navigator.clipboard.writeText(apiKey); - toast.success(t('apikeys.messages.copied')); - }; + const { handleCopy: copyToClipboard } = useCopyToClipboard({ + text: apiKey, + copyMessage: t('apikeys.messages.copied'), + }); const handleViewKey = () => { openDialog('view', fullApiKey); diff --git a/frontend/src/features/apikeys/components/apikeys-rotate-dialog.tsx b/frontend/src/features/apikeys/components/apikeys-rotate-dialog.tsx index c2118c0ac..6998fee6c 100644 --- a/frontend/src/features/apikeys/components/apikeys-rotate-dialog.tsx +++ b/frontend/src/features/apikeys/components/apikeys-rotate-dialog.tsx @@ -1,7 +1,6 @@ import { useState } from 'react'; import { Copy, RefreshCw, AlertTriangle, CheckIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { toast } from 'sonner'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { @@ -12,6 +11,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useApiKeysContext } from '../context/apikeys-context'; import { useRotateApiKey } from '../data/apikeys'; @@ -20,7 +20,10 @@ export function ApiKeysRotateDialog() { const { isDialogOpen, closeDialog, selectedApiKey, setSelectedApiKey } = useApiKeysContext(); const rotateApiKey = useRotateApiKey(); const [newKey, setNewKey] = useState(null); - const [isCopied, setIsCopied] = useState(false); + const { isCopied, handleCopy } = useCopyToClipboard({ + text: newKey ?? '', + copyMessage: t('apikeys.messages.copied'), + }); const handleRotate = async () => { if (!selectedApiKey) return; @@ -35,15 +38,6 @@ export function ApiKeysRotateDialog() { } }; - const handleCopy = async () => { - if (newKey) { - await navigator.clipboard.writeText(newKey); - setIsCopied(true); - toast.success(t('apikeys.messages.copied')); - setTimeout(() => setIsCopied(false), 2000); - } - }; - const handleClose = () => { setNewKey(null); closeDialog(); diff --git a/frontend/src/features/apikeys/components/apikeys-view-dialog.tsx b/frontend/src/features/apikeys/components/apikeys-view-dialog.tsx index 0f1b0273e..ed7c40a6a 100644 --- a/frontend/src/features/apikeys/components/apikeys-view-dialog.tsx +++ b/frontend/src/features/apikeys/components/apikeys-view-dialog.tsx @@ -1,25 +1,21 @@ import { useState, useEffect, useMemo } from 'react'; import { Copy, Eye, EyeOff, AlertTriangle, Link, CheckIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { toast } from 'sonner'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { MaskedCodeBlock, MaskedCodeBlockCopyButton, highlightMaskedCode } from '@/components/ai-elements/masked-code-block'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useApiKeysContext } from '../context/apikeys-context'; function CopyBaseUrlButton({ baseUrl }: { baseUrl: string }) { const { t } = useTranslation(); - const [isCopied, setIsCopied] = useState(false); - - const handleCopy = async () => { - await navigator.clipboard.writeText(baseUrl); - setIsCopied(true); - toast.success(t('apikeys.messages.baseUrlCopied')); - setTimeout(() => setIsCopied(false), 2000); - }; + const { isCopied, handleCopy } = useCopyToClipboard({ + text: baseUrl, + copyMessage: t('apikeys.messages.baseUrlCopied'), + }); return ( @@ -38,6 +34,10 @@ export function ApiKeysViewDialog() { const { isDialogOpen, closeDialog, selectedApiKey } = useApiKeysContext(); const [isVisible, setIsVisible] = useState(false); const [preRenderedCode, setPreRenderedCode] = useState>({}); + const { handleCopy: copyApiKey } = useCopyToClipboard({ + text: selectedApiKey?.key ?? '', + copyMessage: t('apikeys.messages.copied'), + }); const apiKey = selectedApiKey?.key || ''; const maskedApiKey = selectedApiKey?.key ? selectedApiKey.key.slice(0, 3) + '...' + selectedApiKey.key.slice(-4) : ''; @@ -245,13 +245,6 @@ print(response.text)` renderAllCodeBlocks(); }, [selectedApiKey?.key, codeExamples]); - const copyToClipboard = () => { - if (selectedApiKey?.key) { - navigator.clipboard.writeText(selectedApiKey.key); - toast.success(t('apikeys.messages.copied')); - } - }; - const maskedKey = selectedApiKey?.key ? selectedApiKey.key.replace(/./g, '*').slice(0, -4) + selectedApiKey.key.slice(-4) : ''; return ( @@ -282,7 +275,7 @@ print(response.text)` - diff --git a/frontend/src/hooks/use-copy-to-clipboard.ts b/frontend/src/hooks/use-copy-to-clipboard.ts index 66bff69ef..1aa931f2f 100644 --- a/frontend/src/hooks/use-copy-to-clipboard.ts +++ b/frontend/src/hooks/use-copy-to-clipboard.ts @@ -1,34 +1,33 @@ import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; +import { copyTextToClipboard } from '@/lib/clipboard'; type UseCopyToClipboardProps = { text: string; copyMessage?: string; }; -export function useCopyToClipboard({ text, copyMessage = 'Copied to clipboard!' }: UseCopyToClipboardProps) { +export function useCopyToClipboard({ text, copyMessage }: UseCopyToClipboardProps) { const { t } = useTranslation(); const [isCopied, setIsCopied] = useState(false); const timeoutRef = useRef(null); - const handleCopy = useCallback(() => { - navigator.clipboard - .writeText(text) - .then(() => { - toast.success(t('common.success.copiedToClipboard')); - setIsCopied(true); - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - timeoutRef.current = setTimeout(() => { - setIsCopied(false); - }, 2000); - }) - .catch(() => { - toast.error(t('common.errors.copyFailed')); - }); + const handleCopy = useCallback(async () => { + try { + await copyTextToClipboard(text); + toast.success(copyMessage ?? t('common.success.copiedToClipboard')); + setIsCopied(true); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + timeoutRef.current = setTimeout(() => { + setIsCopied(false); + }, 2000); + } catch { + toast.error(t('common.errors.copyFailed')); + } }, [text, copyMessage, t]); return { isCopied, handleCopy }; diff --git a/frontend/src/lib/clipboard.ts b/frontend/src/lib/clipboard.ts new file mode 100644 index 000000000..c34e5fa61 --- /dev/null +++ b/frontend/src/lib/clipboard.ts @@ -0,0 +1,30 @@ +function copyTextWithDocumentCommand(text: string): boolean { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.opacity = '0'; + + document.body.appendChild(textarea); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + try { + // Clipboard API is unavailable on non-secure origins; this is the only broadly supported fallback. + return document.execCommand('copy'); + } finally { + textarea.remove(); + } +} + +export async function copyTextToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + if (!copyTextWithDocumentCommand(text)) { + throw new Error('Clipboard API is unavailable and the compatibility copy command failed.'); + } +}