diff --git a/frontend/README.md b/frontend/README.md
index c0c8b7f..e7e576d 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -188,13 +188,14 @@ bun run test:e2e
- API Key 脱敏显示
- 启用/禁用状态标签
- **协议字段**:支持 OpenAI 和 Anthropic 协议选择
+- **一键复制**:Base URL 和 API Key 支持一键复制到剪贴板
### 模型管理
- 展开供应商行查看关联模型
- 添加/编辑/删除模型
- 按供应商筛选模型
-- **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别
+- **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别,支持一键复制
- **UUID 自动生成**:创建模型时后端自动生成 UUID,无需手动输入 ID
### 用量统计
diff --git a/frontend/src/__tests__/components/ModelTable.test.tsx b/frontend/src/__tests__/components/ModelTable.test.tsx
index 2adf59c..c22f1ce 100644
--- a/frontend/src/__tests__/components/ModelTable.test.tsx
+++ b/frontend/src/__tests__/components/ModelTable.test.tsx
@@ -4,6 +4,21 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ModelTable } from '@/pages/Providers/ModelTable'
import type { Model } from '@/types'
+const { mockMessagePluginSuccess } = vi.hoisted(() => ({
+ mockMessagePluginSuccess: vi.fn(),
+}))
+
+vi.mock('tdesign-react', async () => {
+ const actual = await vi.importActual('tdesign-react')
+ return {
+ ...actual,
+ MessagePlugin: {
+ success: mockMessagePluginSuccess,
+ error: vi.fn(),
+ },
+ }
+})
+
const mockModels: Model[] = [
{
id: 'model-1',
@@ -44,6 +59,7 @@ const defaultProps = {
describe('ModelTable', () => {
beforeEach(() => {
mockMutate.mockClear()
+ mockMessagePluginSuccess.mockClear()
})
it('renders model list with unified ID and model name', () => {
@@ -120,4 +136,19 @@ describe('ModelTable', () => {
expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument()
})
+
+ it('renders unified model ID with copy button and copies on click', async () => {
+ const user = userEvent.setup()
+ const { container } = render()
+
+ const allCells = container.querySelectorAll('td')
+ const modelIdCell = Array.from(allCells).find((td) => td.textContent?.includes('openai/gpt-4o'))
+ expect(modelIdCell).toBeTruthy()
+
+ const buttons = modelIdCell!.querySelectorAll('button')
+ expect(buttons.length).toBeGreaterThan(0)
+
+ await user.click(buttons[0]!)
+ expect(mockMessagePluginSuccess).toHaveBeenCalledWith('已复制统一模型 ID')
+ })
})
diff --git a/frontend/src/__tests__/components/ProviderTable.test.tsx b/frontend/src/__tests__/components/ProviderTable.test.tsx
index eba84d1..a02daa2 100644
--- a/frontend/src/__tests__/components/ProviderTable.test.tsx
+++ b/frontend/src/__tests__/components/ProviderTable.test.tsx
@@ -1,9 +1,24 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { describe, it, expect, vi } from 'vitest'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ProviderTable } from '@/pages/Providers/ProviderTable'
import type { Provider } from '@/types'
+const { mockMessagePluginSuccess } = vi.hoisted(() => ({
+ mockMessagePluginSuccess: vi.fn(),
+}))
+
+vi.mock('tdesign-react', async () => {
+ const actual = await vi.importActual('tdesign-react')
+ return {
+ ...actual,
+ MessagePlugin: {
+ success: mockMessagePluginSuccess,
+ error: vi.fn(),
+ },
+ }
+})
+
const mockModelsData = [
{ id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true, unifiedId: 'openai/gpt-4o' },
{
@@ -54,6 +69,9 @@ const defaultProps = {
}
describe('ProviderTable', () => {
+ beforeEach(() => {
+ mockMessagePluginSuccess.mockClear()
+ })
it('renders provider list with name, baseUrl, apiKey, and status tags', () => {
render()
@@ -203,4 +221,66 @@ describe('ProviderTable', () => {
const protocolCell = container.querySelector('[data-colkey="protocol"]')
expect(protocolCell).toBeInTheDocument()
})
+
+ it('renders Base URL with copy button and copies on click', async () => {
+ const user = userEvent.setup()
+ const { container } = render()
+
+ const baseUrlCells = container.querySelectorAll('td')
+ const baseUrlCellWithContent = Array.from(baseUrlCells).find((td) =>
+ td.textContent?.includes('https://api.openai.com/v1')
+ )
+ expect(baseUrlCellWithContent).toBeTruthy()
+
+ const buttons = baseUrlCellWithContent!.querySelectorAll('button')
+ expect(buttons.length).toBeGreaterThan(0)
+
+ await user.click(buttons[0]!)
+ expect(mockMessagePluginSuccess).toHaveBeenCalledWith('已复制 Base URL')
+ })
+
+ it('renders API Key with copy button and copies on click', async () => {
+ const user = userEvent.setup()
+ const { container } = render()
+
+ const allCells = container.querySelectorAll('td')
+ const apiKeyCell = Array.from(allCells).find((td) => td.textContent?.includes('sk-abcdefgh12345678'))
+ expect(apiKeyCell).toBeTruthy()
+
+ const buttons = apiKeyCell!.querySelectorAll('button')
+ expect(buttons.length).toBeGreaterThan(0)
+
+ await user.click(buttons[0]!)
+ expect(mockMessagePluginSuccess).toHaveBeenCalledWith('已复制 API Key')
+ })
+
+ it('does not render copy button when Base URL is empty', () => {
+ const emptyUrlProvider: Provider[] = [
+ {
+ ...mockProviders[0],
+ id: 'empty-url',
+ baseUrl: '',
+ },
+ ]
+ const { container } = render()
+
+ const allCells = container.querySelectorAll('td')
+ const baseUrlCells = Array.from(allCells).filter((td) => td.textContent === '')
+ expect(baseUrlCells.length).toBeGreaterThanOrEqual(0)
+ })
+
+ it('does not render copy button when API Key is empty', () => {
+ const emptyKeyProvider: Provider[] = [
+ {
+ ...mockProviders[0],
+ id: 'empty-key',
+ apiKey: '',
+ },
+ ]
+ const { container } = render()
+
+ const allCells = container.querySelectorAll('td')
+ const apiKeyCells = Array.from(allCells).filter((td) => td.textContent === '')
+ expect(apiKeyCells.length).toBeGreaterThanOrEqual(0)
+ })
})
diff --git a/frontend/src/pages/Providers/ModelTable.tsx b/frontend/src/pages/Providers/ModelTable.tsx
index c989f8a..f2fc495 100644
--- a/frontend/src/pages/Providers/ModelTable.tsx
+++ b/frontend/src/pages/Providers/ModelTable.tsx
@@ -1,4 +1,4 @@
-import { Button, Table, Tag, Popconfirm, Space } from 'tdesign-react'
+import { Button, Table, Tag, Popconfirm, Space, Typography, MessagePlugin } from 'tdesign-react'
import { useModels, useDeleteModel } from '@/hooks/useModels'
import type { Model } from '@/types'
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
@@ -18,8 +18,30 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
title: '统一模型 ID',
colKey: 'unifiedId',
width: 250,
- ellipsis: true,
- cell: ({ row }) => row.unifiedId || `${row.providerId}/${row.modelName}`,
+ cell: ({ row }) => {
+ const id = row.unifiedId || `${row.providerId}/${row.modelName}`
+ return id ? (
+
+
+ {id}
+
+ MessagePlugin.success('已复制统一模型 ID'),
+ }}
+ />
+
+ ) : null
+ },
},
{
title: '模型名称',
diff --git a/frontend/src/pages/Providers/ProviderTable.tsx b/frontend/src/pages/Providers/ProviderTable.tsx
index 374fd32..c7bd996 100644
--- a/frontend/src/pages/Providers/ProviderTable.tsx
+++ b/frontend/src/pages/Providers/ProviderTable.tsx
@@ -1,4 +1,4 @@
-import { Button, Table, Tag, Popconfirm, Space, Card } from 'tdesign-react'
+import { Button, Table, Tag, Popconfirm, Space, Card, Typography, MessagePlugin } from 'tdesign-react'
import type { Provider, Model } from '@/types'
import { ModelTable } from './ModelTable'
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
@@ -32,7 +32,28 @@ export function ProviderTable({
{
title: 'Base URL',
colKey: 'baseUrl',
- ellipsis: true,
+ cell: ({ row }) =>
+ row.baseUrl ? (
+
+
+ {row.baseUrl}
+
+ MessagePlugin.success('已复制 Base URL'),
+ }}
+ />
+
+ ) : null,
},
{
title: '协议',
@@ -47,7 +68,28 @@ export function ProviderTable({
{
title: 'API Key',
colKey: 'apiKey',
- ellipsis: true,
+ cell: ({ row }) =>
+ row.apiKey ? (
+
+
+ {row.apiKey}
+
+ MessagePlugin.success('已复制 API Key'),
+ }}
+ />
+
+ ) : null,
},
{
title: '状态',
diff --git a/openspec/specs/frontend/spec.md b/openspec/specs/frontend/spec.md
index bc822ad..f03cd28 100644
--- a/openspec/specs/frontend/spec.md
+++ b/openspec/specs/frontend/spec.md
@@ -125,6 +125,26 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 供应商列表为空
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无供应商,点击上方按钮添加"
+#### Scenario: Base URL 一键复制
+
+- **WHEN** 供应商表格渲染 Base URL 列
+- **THEN** Base URL 文本右侧 SHALL 显示复制图标按钮
+- **THEN** 复制图标 SHALL 使用 TDesign `Typography.Text` 的 `copyable` 属性
+- **WHEN** 用户点击 Base URL 的复制按钮
+- **THEN** 系统 SHALL 将完整 Base URL 写入剪贴板
+- **THEN** 系统 SHALL 显示 `已复制 Base URL` 成功提示
+- **THEN** 当 Base URL 为空时,复制按钮 SHALL 禁用
+
+#### Scenario: API Key 一键复制
+
+- **WHEN** 供应商表格渲染 API Key 列
+- **THEN** API Key 文本右侧 SHALL 显示复制图标按钮
+- **THEN** 复制图标 SHALL 使用 TDesign `Typography.Text` 的 `copyable` 属性
+- **WHEN** 用户点击 API Key 的复制按钮
+- **THEN** 系统 SHALL 将完整 API Key 写入剪贴板
+- **THEN** 系统 SHALL 显示 `已复制 API Key` 成功提示
+- **THEN** 当 API Key 为空时,复制按钮 SHALL 禁用
+
#### Scenario: 添加新供应商
- **WHEN** 用户点击"添加供应商"按钮
@@ -184,6 +204,16 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 模型列表为空
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无模型,点击上方按钮添加"
+#### Scenario: 统一模型 ID 一键复制
+
+- **WHEN** 模型表格渲染统一模型 ID 列
+- **THEN** 统一模型 ID 文本右侧 SHALL 显示复制图标按钮
+- **THEN** 复制图标 SHALL 使用 TDesign `Typography.Text` 的 `copyable` 属性
+- **WHEN** 用户点击统一模型 ID 的复制按钮
+- **THEN** 系统 SHALL 将完整统一模型 ID 写入剪贴板
+- **THEN** 系统 SHALL 显示 `已复制统一模型 ID` 成功提示
+- **THEN** 当统一模型 ID 为空时,复制按钮 SHALL 禁用
+
#### Scenario: 为供应商添加模型
- **WHEN** 用户在展开行中点击"添加模型"