1
0

fix: 修复供应商管理弹窗交互问题并去掉 API Key 脱敏

- Dialog 设置 lazy={false} 修复首次打开编辑弹窗表单为空
- API Key 改为普通字段(前端去掉 password 类型,后端去掉掩码逻辑)
- 删除模型编辑弹窗中的统一模型 ID 字段
- 简化 ProviderService.Get 签名(去掉 maskKey 参数)
- 删除 domain 和 config 层的 MaskAPIKey() 方法
- 更新前后端测试(107 单元测试 + 16 E2E 全部通过)
- 同步 delta spec 到主 spec
This commit is contained in:
2026-04-22 13:13:25 +08:00
parent 81dcecb723
commit 5d58acf5a6
23 changed files with 68 additions and 128 deletions

View File

@@ -12,7 +12,7 @@ function formInputs(page: import('@playwright/test').Page) {
return {
id: dialog.locator('input[placeholder="例如: openai"]'),
name: dialog.locator('input[placeholder="例如: OpenAI"]'),
apiKey: dialog.locator('input[type="password"]'),
apiKey: dialog.locator('input[placeholder="sk-..."]'),
baseUrl: dialog.locator('input[placeholder="例如: https://api.openai.com/v1"]'),
protocol: dialog.locator('.t-select'),
saveBtn: dialog.locator('.t-dialog__footer').getByRole('button', { name: '保存' }),
@@ -66,9 +66,6 @@ test.describe('供应商管理', () => {
await responsePromise
await expect(page.locator('.t-table__body').getByText('Before Edit')).toBeVisible({ timeout: 5000 })
await inputs.cancelBtn.click()
await expect(page.locator('.t-dialog:visible')).not.toBeVisible({ timeout: 3000 })
await page.locator('.t-table__body button:has-text("编辑")').first().click()
await expect(page.locator('.t-dialog:visible')).toBeVisible()
@@ -97,9 +94,6 @@ test.describe('供应商管理', () => {
await page.waitForSelector('.t-select__dropdown', { state: 'hidden', timeout: 3000 })
await inputs.saveBtn.click()
await expect(page.locator('.t-table__body').getByText('To Delete')).toBeVisible({ timeout: 10000 })
await inputs.cancelBtn.click()
await expect(page.locator('.t-dialog:visible')).not.toBeVisible({ timeout: 3000 })
await page.locator('.t-table__body button:has-text("删除")').first().click()
await expect(page.getByText('确定要删除这个供应商吗?')).toBeVisible()
@@ -107,7 +101,7 @@ test.describe('供应商管理', () => {
await expect(page.locator('.t-table__body').getByText('To Delete')).not.toBeVisible({ timeout: 5000 })
})
test('应正确脱敏显示 API Key', async ({ page }) => {
test('应正确显示完整 API Key', async ({ page }) => {
const testId = nextId()
await page.getByRole('button', { name: '添加供应商' }).click()
await expect(page.locator('.t-dialog:visible')).toBeVisible()
@@ -123,6 +117,6 @@ test.describe('供应商管理', () => {
await inputs.saveBtn.click()
await expect(page.locator('.t-table__body').getByText('Mask Test')).toBeVisible({ timeout: 10000 })
await expect(page.locator('.t-table__body')).toContainText('****wxyz')
await expect(page.locator('.t-table__body')).toContainText('sk_abcdefghijklmnopqrstuvwxyz')
})
})

View File

@@ -5,7 +5,7 @@ function formInputs(page: import('@playwright/test').Page) {
return {
id: dialog.locator('input[placeholder="例如: openai"]'),
name: dialog.locator('input[placeholder="例如: OpenAI"]'),
apiKey: dialog.locator('input[type="password"]'),
apiKey: dialog.locator('input[placeholder="sk-..."]'),
baseUrl: dialog.locator('input[placeholder="例如: https://api.openai.com/v1"]'),
saveBtn: dialog.locator('.t-dialog__footer').getByRole('button', { name: '保存' }),
cancelBtn: dialog.locator('.t-dialog__footer').getByRole('button', { name: '取消' }),
@@ -20,7 +20,7 @@ test.describe('供应商表单验证', () => {
test('应显示必填字段验证', async ({ page }) => {
await page.getByRole('button', { name: '添加供应商' }).click()
await expect(page.locator('.t-dialog')).toBeVisible()
await expect(page.locator('.t-dialog:visible')).toBeVisible()
await formInputs(page).saveBtn.click()
@@ -32,7 +32,7 @@ test.describe('供应商表单验证', () => {
test('应验证URL格式', async ({ page }) => {
await page.getByRole('button', { name: '添加供应商' }).click()
await expect(page.locator('.t-dialog')).toBeVisible()
await expect(page.locator('.t-dialog:visible')).toBeVisible()
const inputs = formInputs(page)
await inputs.id.fill('test_url')
@@ -46,17 +46,17 @@ test.describe('供应商表单验证', () => {
test('取消后表单应重置', async ({ page }) => {
await page.getByRole('button', { name: '添加供应商' }).click()
await expect(page.locator('.t-dialog')).toBeVisible()
await expect(page.locator('.t-dialog:visible')).toBeVisible()
let inputs = formInputs(page)
await inputs.id.fill('should_be_reset')
await inputs.name.fill('Should Be Reset')
await inputs.cancelBtn.click()
await expect(page.locator('.t-dialog')).not.toBeVisible()
await expect(page.locator('.t-dialog:visible')).not.toBeVisible()
await page.getByRole('button', { name: '添加供应商' }).click()
await expect(page.locator('.t-dialog')).toBeVisible()
await expect(page.locator('.t-dialog:visible')).toBeVisible()
inputs = formInputs(page)
await expect(inputs.id).toHaveValue('')
@@ -65,11 +65,11 @@ test.describe('供应商表单验证', () => {
test('快速连续点击只打开一个对话框', async ({ page }) => {
await page.getByRole('button', { name: '添加供应商' }).click()
await expect(page.locator('.t-dialog')).toBeVisible()
await expect(page.locator('.t-dialog:visible')).toBeVisible()
expect(await page.locator('.t-dialog').count()).toBe(1)
expect(await page.locator('.t-dialog:visible').count()).toBe(1)
await formInputs(page).cancelBtn.click()
await expect(page.locator('.t-dialog')).not.toBeVisible()
await expect(page.locator('.t-dialog:visible')).not.toBeVisible()
})
})

View File

@@ -125,9 +125,6 @@ describe('ModelForm', () => {
const dialog = getDialog();
expect(within(dialog).getByText('编辑模型')).toBeInTheDocument();
// Check that unified ID field is displayed
expect(within(dialog).getByText('统一模型 ID')).toBeInTheDocument();
// Check model name input
const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o') as HTMLInputElement;
expect(modelNameInput.value).toBe('gpt-4o');

View File

@@ -63,13 +63,16 @@ describe('ProviderForm', () => {
const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement;
expect(baseUrlInput.value).toBe('https://api.openai.com/v1');
const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement;
expect(apiKeyInput.value).toBe('sk-old-key');
});
it('shows API Key label variant in edit mode', () => {
it('shows API Key label in edit mode', () => {
render(<ProviderForm {...defaultProps} provider={mockProvider} />);
const dialog = getDialog();
expect(within(dialog).getByText('API Key(留空则不修改)')).toBeInTheDocument();
expect(within(dialog).getByText('API Key')).toBeInTheDocument();
});
it('shows validation error messages for required fields', async () => {

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { ProviderTable } from '@/pages/Providers/ProviderTable';
@@ -48,19 +48,18 @@ const defaultProps = {
};
describe('ProviderTable', () => {
it('renders provider list with name, baseUrl, masked apiKey, and status tags', () => {
it('renders provider list with name, baseUrl, apiKey, and status tags', () => {
render(<ProviderTable {...defaultProps} />);
expect(screen.getByText('供应商列表')).toBeInTheDocument();
// Check that provider names appear (they will appear in both name column and potentially protocol column)
expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0);
expect(screen.getByText('https://api.openai.com/v1')).toBeInTheDocument();
expect(screen.getByText('****5678')).toBeInTheDocument();
expect(screen.getByText('sk-abcdefgh12345678')).toBeInTheDocument();
expect(screen.getAllByText('Anthropic').length).toBeGreaterThan(0);
expect(screen.getByText('https://api.anthropic.com')).toBeInTheDocument();
expect(screen.getByText('****test')).toBeInTheDocument();
expect(screen.getByText('sk-ant-test')).toBeInTheDocument();
const enabledTags = screen.getAllByText('启用');
const disabledTags = screen.getAllByText('禁用');
@@ -77,7 +76,7 @@ describe('ProviderTable', () => {
expect(container.querySelector('.t-card__body')).toBeInTheDocument();
});
it('renders short api keys fully masked', () => {
it('renders short api keys directly', () => {
const shortKeyProvider: Provider[] = [
{
...mockProviders[0],
@@ -88,7 +87,7 @@ describe('ProviderTable', () => {
];
render(<ProviderTable {...defaultProps} providers={shortKeyProvider} />);
expect(screen.getByText('****')).toBeInTheDocument();
expect(screen.getByText('ab')).toBeInTheDocument();
});
it('calls onAdd when clicking "添加供应商" button', async () => {

View File

@@ -65,6 +65,7 @@ export function ModelForm({
visible={open}
closeOnOverlayClick={false}
closeOnEscKeydown={false}
lazy={false}
onConfirm={() => { form?.submit(); return false; }}
onClose={onCancel}
confirmLoading={loading}
@@ -72,15 +73,6 @@ export function ModelForm({
cancelBtn="取消"
>
<Form form={form} layout="vertical" onSubmit={handleSubmit}>
{isEdit && model?.unifiedId && (
<Form.FormItem label="统一模型 ID">
<Input value={model.unifiedId} disabled />
<div style={{ color: '#999', fontSize: 12, marginTop: 4 }}>
provider_id/model_name
</div>
</Form.FormItem>
)}
<Form.FormItem
label="供应商"
name="providerId"

View File

@@ -36,7 +36,7 @@ export function ProviderForm({
form.setFieldsValue({
id: provider.id,
name: provider.name,
apiKey: '',
apiKey: provider.apiKey,
baseUrl: provider.baseUrl,
protocol: provider.protocol,
enabled: provider.enabled,
@@ -61,6 +61,7 @@ export function ProviderForm({
visible={open}
closeOnOverlayClick={false}
closeOnEscKeydown={false}
lazy={false}
onConfirm={() => { form?.submit(); return false; }}
onClose={onCancel}
confirmLoading={loading}
@@ -77,11 +78,11 @@ export function ProviderForm({
</Form.FormItem>
<Form.FormItem
label={isEdit ? 'API Key留空则不修改' : 'API Key'}
label="API Key"
name="apiKey"
rules={isEdit ? [] : [{ required: true, message: '请输入 API Key' }]}
rules={[{ required: true, message: '请输入 API Key' }]}
>
<Input type="password" placeholder="sk-..." autocomplete="current-password" />
<Input placeholder="sk-..." />
</Form.FormItem>
<Form.FormItem

View File

@@ -1,4 +1,4 @@
import { Button, Table, Tag, Popconfirm, Space, Card, Tooltip } from 'tdesign-react';
import { Button, Table, Tag, Popconfirm, Space, Card } from 'tdesign-react';
import type { PrimaryTableCol } from 'tdesign-react/es/table/type';
import type { Provider, Model } from '@/types';
import { ModelTable } from './ModelTable';
@@ -13,12 +13,6 @@ interface ProviderTableProps {
onEditModel: (model: Model) => void;
}
function maskApiKey(key: string | null | undefined): string {
if (!key) return '****';
if (key.length <= 4) return '****';
return `****${key.slice(-4)}`;
}
export function ProviderTable({
providers,
loading,
@@ -53,13 +47,7 @@ export function ProviderTable({
{
title: 'API Key',
colKey: 'apiKey',
width: 120,
ellipsis: true,
cell: ({ row }) => (
<Tooltip content={maskApiKey(row.apiKey)}>
<span>{maskApiKey(row.apiKey)}</span>
</Tooltip>
),
},
{
title: '状态',

View File

@@ -55,7 +55,7 @@ export function ProvidersPage() {
if (editingProvider) {
const input: Partial<UpdateProviderInput> = {};
if (values.name !== editingProvider.name) input.name = values.name;
if (values.apiKey) input.apiKey = values.apiKey;
if (values.apiKey !== editingProvider.apiKey) input.apiKey = values.apiKey;
if (values.baseUrl !== editingProvider.baseUrl) input.baseUrl = values.baseUrl;
if (values.enabled !== editingProvider.enabled) input.enabled = values.enabled;
await updateProvider.mutateAsync({ id: editingProvider.id, input });