feat: 供应商列表 Base URL、API Key 和模型列表统一模型 ID 增加一键复制按钮
This commit is contained in:
@@ -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
|
||||
|
||||
### 用量统计
|
||||
|
||||
@@ -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(<ModelTable {...defaultProps} />)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(<ProviderTable {...defaultProps} />)
|
||||
|
||||
@@ -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(<ProviderTable {...defaultProps} />)
|
||||
|
||||
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(<ProviderTable {...defaultProps} />)
|
||||
|
||||
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(<ProviderTable {...defaultProps} providers={emptyUrlProvider} />)
|
||||
|
||||
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(<ProviderTable {...defaultProps} providers={emptyKeyProvider} />)
|
||||
|
||||
const allCells = container.querySelectorAll('td')
|
||||
const apiKeyCells = Array.from(allCells).filter((td) => td.textContent === '')
|
||||
expect(apiKeyCells.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: '100%' }}>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{id}
|
||||
</span>
|
||||
<Typography.Text
|
||||
style={{ flexShrink: 0 }}
|
||||
copyable={{
|
||||
text: id,
|
||||
onCopy: () => MessagePlugin.success('已复制统一模型 ID'),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : null
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '模型名称',
|
||||
|
||||
@@ -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 ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: '100%' }}>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{row.baseUrl}
|
||||
</span>
|
||||
<Typography.Text
|
||||
style={{ flexShrink: 0 }}
|
||||
copyable={{
|
||||
text: row.baseUrl,
|
||||
onCopy: () => MessagePlugin.success('已复制 Base URL'),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
title: '协议',
|
||||
@@ -47,7 +68,28 @@ export function ProviderTable({
|
||||
{
|
||||
title: 'API Key',
|
||||
colKey: 'apiKey',
|
||||
ellipsis: true,
|
||||
cell: ({ row }) =>
|
||||
row.apiKey ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: '100%' }}>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{row.apiKey}
|
||||
</span>
|
||||
<Typography.Text
|
||||
style={{ flexShrink: 0 }}
|
||||
copyable={{
|
||||
text: row.apiKey,
|
||||
onCopy: () => MessagePlugin.success('已复制 API Key'),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
|
||||
@@ -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** 用户在展开行中点击"添加模型"
|
||||
|
||||
Reference in New Issue
Block a user