Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
Expand Down Expand Up @@ -69,10 +70,12 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"jsdom": "^26.1.0",
"tw-animate-css": "^1.2.8",
"typescript": "~5.7.2",
"typescript-eslint": "^8.26.1",
"vite": "^6.3.1"
"vite": "^6.3.1",
"vitest": "^3.2.4"
},
"packageManager": "pnpm@10.32.0+sha512.9b2634bb3fed5601c33633f2d92593f506270a3963b8c51d2b2d6a828da615ce4e9deebef9614ccebbc13ac8d3c0f9c9ccceb583c69c8578436fa477dbb20d70"
}
650 changes: 650 additions & 0 deletions web/pnpm-lock.yaml

Large diffs are not rendered by default.

10 changes: 6 additions & 4 deletions web/src/components/common/CopyButton.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Check, Copy } from 'lucide-react'
import { writeTextToClipboard } from '@/lib/clipboard'

interface CopyButtonProps {
text: string
Expand All @@ -11,9 +12,10 @@ export const CopyButton = ({ text, className }: CopyButtonProps) => {
const [copied, setCopied] = useState(false)

const copyToClipboard = () => {
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
writeTextToClipboard(text).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}).catch(() => {})
}

return (
Expand All @@ -26,4 +28,4 @@ export const CopyButton = ({ text, className }: CopyButtonProps) => {
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
)
}
}
5 changes: 4 additions & 1 deletion web/src/feature/group/components/CreateGroupTokenDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { Loader2 } from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { tokenApi } from '@/api/token'
import { toast } from 'sonner'
import { writeTextToClipboard } from '@/lib/clipboard'

interface CreateGroupTokenDialogProps {
open: boolean
Expand Down Expand Up @@ -59,8 +60,10 @@ export function CreateGroupTokenDialog({
queryClient.invalidateQueries({ queryKey: ['groups'] })
toast.success(t('common.success'))
if (data?.key) {
navigator.clipboard.writeText(data.key).then(() => {
writeTextToClipboard(data.key).then(() => {
toast.success(t('common.copied'))
}).catch(() => {
toast.error(t('common.copyFailed'))
})
}
resetForm()
Expand Down
3 changes: 2 additions & 1 deletion web/src/feature/group/components/GroupModelsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { PriceDisplay } from '@/components/price/PriceDisplay'
import { toast } from 'sonner'
import { Copy, Search } from 'lucide-react'
import { useGroupModelMetrics } from '@/feature/monitor/runtime-hooks'
import { writeTextToClipboard } from '@/lib/clipboard'

interface GroupModelsTabProps {
groupId: string
Expand Down Expand Up @@ -79,7 +80,7 @@ export function GroupModelsTab({ groupId }: GroupModelsTabProps) {
}, [models, searchKeyword, ownerFilter])
const { data: runtimeMetrics } = useGroupModelMetrics(groupId, !!groupId && filteredModels.length > 0)
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
writeTextToClipboard(text).then(() => {
toast.success(t('common.copied'))
}).catch(() => {
toast.error(t('common.copyFailed'))
Expand Down
3 changes: 2 additions & 1 deletion web/src/feature/group/components/GroupTokensTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { useGroupTokenMetrics } from '@/feature/monitor/runtime-hooks'
import { format } from 'date-fns'
import { writeTextToClipboard } from '@/lib/clipboard'

// Mask API key - show prefix and last 4 chars
const maskApiKey = (key: string): string => {
Expand Down Expand Up @@ -168,7 +169,7 @@ export function GroupTokensTab({ groupId, onNavigateDashboard }: GroupTokensTabP
}

const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
writeTextToClipboard(text).then(() => {
toast.success(t('common.copied'))
}).catch(() => {
toast.error(t('common.copyFailed'))
Expand Down
3 changes: 2 additions & 1 deletion web/src/feature/log/components/ExpandedLogContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ChannelDialog } from '@/feature/channel/components/ChannelDialog'
import type { Channel } from '@/types/channel'
import { toast } from 'sonner'
import { openResourceDialog, showDeletedResourceToast } from '@/utils/resource-dialog'
import { writeTextToClipboard } from '@/lib/clipboard'

// Format price with unit
const formatPrice = (price: number, unit: number): string => {
Expand Down Expand Up @@ -99,7 +100,7 @@ export const ExpandedLogContent = ({ log }: { log: LogRecord }) => {

const copyToClipboard = (text?: string) => {
if (!text) return
navigator.clipboard.writeText(text).then(() => {
writeTextToClipboard(text).then(() => {
toast.success(t('common.copied'))
}).catch(() => {
toast.error(t('common.copyFailed'))
Expand Down
3 changes: 2 additions & 1 deletion web/src/feature/log/components/LogTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button'
import { ExpandedLogContent } from './ExpandedLogContent'
import { toast } from 'sonner'
import type { LogRecord } from '@/types/log'
import { writeTextToClipboard } from '@/lib/clipboard'

const columnHelper = createColumnHelper<LogRecord>()

Expand Down Expand Up @@ -55,7 +56,7 @@ export function LogTable({
}

const copyToClipboard = useCallback((text: string) => {
navigator.clipboard.writeText(text).then(() => {
writeTextToClipboard(text).then(() => {
toast.success(t('common.copied'))
}).catch(() => {
toast.error(t('common.copyFailed'))
Expand Down
5 changes: 4 additions & 1 deletion web/src/feature/model/components/ModelTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import { openResourceDialog, showDeletedResourceToast } from "@/utils/resource-dialog";
import { getChannelModelMetric } from "@/utils/runtime-metrics";
import { writeTextToClipboard } from "@/lib/clipboard";

export function ModelTable() {
const { t } = useTranslation();
Expand Down Expand Up @@ -213,8 +214,10 @@ export function ModelTable() {
<div
className="font-medium cursor-pointer hover:text-primary transition-colors"
onClick={() => {
navigator.clipboard.writeText(row.original.model).then(() => {
writeTextToClipboard(row.original.model).then(() => {
toast.success(t("common.copied"));
}).catch(() => {
toast.error(t("common.copyFailed"));
});
}}
>
Expand Down
5 changes: 3 additions & 2 deletions web/src/feature/model/components/api-doc/ApiDoc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next'
import CodeBlock from './CodeHight'
import { toast } from 'sonner'
import { TFunction } from 'i18next'
import { writeTextToClipboard } from '@/lib/clipboard'

interface ApiDocContent {
title: string
Expand Down Expand Up @@ -1002,7 +1003,7 @@ const ApiDocDrawer: React.FC<ApiDocDrawerProps> = ({ isOpen, onClose, modelConfi

<Button
onClick={() => {
navigator.clipboard.writeText(apiDoc.requestExample).then(
writeTextToClipboard(apiDoc.requestExample).then(
() => {
toast.success(t('common.copied'))
},
Expand Down Expand Up @@ -1124,7 +1125,7 @@ const ApiDocDrawer: React.FC<ApiDocDrawerProps> = ({ isOpen, onClose, modelConfi

<Button
onClick={() => {
navigator.clipboard.writeText(apiDoc.responseExample).then(
writeTextToClipboard(apiDoc.responseExample).then(
() => {
toast.success(t('common.copied'))
},
Expand Down
3 changes: 2 additions & 1 deletion web/src/feature/token/components/TokenTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { GroupDialog } from '@/feature/group/components/GroupDialog'
import { useRef } from 'react'
import { useBatchGroupTokenMetrics } from '@/feature/monitor/runtime-hooks'
import { format } from 'date-fns'
import { writeTextToClipboard } from '@/lib/clipboard'

// 遮蔽 API Key,只显示前缀和最后4位
const maskApiKey = (key: string): string => {
Expand Down Expand Up @@ -191,7 +192,7 @@ export function TokenTable() {

// 复制Token到剪贴板
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
writeTextToClipboard(text).then(() => {
toast.success(t('common.copied'))
}).catch(() => {
toast.error(t('common.copyFailed'))
Expand Down
92 changes: 92 additions & 0 deletions web/src/lib/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// @vitest-environment jsdom

import { afterEach, describe, expect, it, vi } from 'vitest'
import { writeTextToClipboard } from './clipboard'

const setClipboard = (clipboard: Pick<Clipboard, 'writeText'> | undefined) => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: clipboard,
})
}

const setExecCommand = (implementation: (command: string) => boolean) => {
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: vi.fn(implementation),
})
}

afterEach(() => {
vi.restoreAllMocks()
setClipboard(undefined)
document.getSelection()?.removeAllRanges()
document.body.replaceChildren()
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: undefined,
})
})

describe('writeTextToClipboard', () => {
it('uses the Clipboard API when it is available', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
const execCommand = vi.fn(() => true)
setClipboard({ writeText })
setExecCommand(execCommand)

await writeTextToClipboard('api-key')

expect(writeText).toHaveBeenCalledWith('api-key')
expect(execCommand).not.toHaveBeenCalled()
})

it('uses the fallback when the Clipboard API is unavailable', async () => {
setClipboard(undefined)
setExecCommand((command) => {
const textarea = document.querySelector('textarea')
expect(command).toBe('copy')
expect(textarea?.value).toBe('http-api-key')
return true
})

await writeTextToClipboard('http-api-key')

expect(document.querySelector('textarea')).toBeNull()
})

it('uses the fallback when the Clipboard API rejects', async () => {
const writeText = vi.fn().mockRejectedValue(new Error('permission denied'))
setClipboard({ writeText })
setExecCommand(() => true)

await expect(writeTextToClipboard('api-key')).resolves.toBeUndefined()
expect(document.execCommand).toHaveBeenCalledWith('copy')
})

it('restores focus and selection after fallback copying', async () => {
const input = document.createElement('input')
const selectedText = document.createTextNode('selected text')
const range = document.createRange()
document.body.append(input, selectedText)
input.focus()
range.selectNodeContents(selectedText)
document.getSelection()?.removeAllRanges()
document.getSelection()?.addRange(range)
setClipboard(undefined)
setExecCommand(() => true)

await writeTextToClipboard('api-key')

expect(document.activeElement).toBe(input)
expect(document.getSelection()?.toString()).toBe('selected text')
})

it('cleans up and rejects when the fallback cannot copy', async () => {
setClipboard(undefined)
setExecCommand(() => false)

await expect(writeTextToClipboard('api-key')).rejects.toThrow('Failed to copy text to clipboard')
expect(document.querySelector('textarea')).toBeNull()
})
})
62 changes: 62 additions & 0 deletions web/src/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const COPY_ERROR_MESSAGE = 'Failed to copy text to clipboard'

const copyTextWithExecCommand = (text: string): void => {
if (typeof document === 'undefined' || !document.body) {
throw new Error(COPY_ERROR_MESSAGE)
}

const activeElement = document.activeElement
const selection = document.getSelection()
const ranges = selection
? Array.from({ length: selection.rangeCount }, (_, index) => selection.getRangeAt(index).cloneRange())
: []
const textarea = document.createElement('textarea')

textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.top = '0'
textarea.style.left = '0'
textarea.style.width = '1px'
textarea.style.height = '1px'
textarea.style.opacity = '0'
textarea.style.pointerEvents = 'none'

document.body.appendChild(textarea)

try {
textarea.select()
textarea.setSelectionRange(0, text.length)

if (typeof document.execCommand !== 'function' || !document.execCommand('copy')) {
throw new Error(COPY_ERROR_MESSAGE)
}
} finally {
textarea.remove()

if (activeElement instanceof HTMLElement) {
activeElement.focus()
}

if (selection) {
selection.removeAllRanges()
ranges.forEach((range) => selection.addRange(range))
}
}
}

export const writeTextToClipboard = async (text: string): Promise<void> => {
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard

if (clipboard?.writeText) {
try {
await clipboard.writeText(text)
return
} catch {
copyTextWithExecCommand(text)
return
}
}

copyTextWithExecCommand(text)
}
Loading