From 598e2acb7e274039c987fbd859b47dc03f566064 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 6 May 2026 00:43:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BE=9B=E5=BA=94=E5=95=86=E5=88=97?= =?UTF-8?q?=E8=A1=A8=20Base=20URL=E3=80=81API=20Key=20=E5=92=8C=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=88=97=E8=A1=A8=E7=BB=9F=E4=B8=80=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=20ID=20=E5=A2=9E=E5=8A=A0=E4=B8=80=E9=94=AE=E5=A4=8D=E5=88=B6?= =?UTF-8?q?=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/README.md | 3 +- .../__tests__/components/ModelTable.test.tsx | 31 +++++++ .../components/ProviderTable.test.tsx | 82 ++++++++++++++++++- frontend/src/pages/Providers/ModelTable.tsx | 28 ++++++- .../src/pages/Providers/ProviderTable.tsx | 48 ++++++++++- openspec/specs/frontend/spec.md | 30 +++++++ 6 files changed, 214 insertions(+), 8 deletions(-) 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** 用户在展开行中点击"添加模型"