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

@@ -48,11 +48,3 @@ func (UsageStats) TableName() string {
return "usage_stats" return "usage_stats"
} }
// MaskAPIKey 掩码 API Key仅显示最后 4 个字符)
func (p *Provider) MaskAPIKey() {
if len(p.APIKey) > 4 {
p.APIKey = "***" + p.APIKey[len(p.APIKey)-4:]
} else {
p.APIKey = "***"
}
}

View File

@@ -14,11 +14,3 @@ type Provider struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// MaskAPIKey 掩码 API Key仅显示最后 4 个字符)
func (p *Provider) MaskAPIKey() {
if len(p.APIKey) > 4 {
p.APIKey = "***" + p.APIKey[len(p.APIKey)-4:]
} else {
p.APIKey = "***"
}
}

View File

@@ -33,7 +33,7 @@ func TestProviderHandler_CreateProvider_Success(t *testing.T) {
var result domain.Provider var result domain.Provider
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result))
assert.Equal(t, "p1", result.ID) assert.Equal(t, "p1", result.ID)
assert.Contains(t, result.APIKey, "***") assert.Equal(t, "sk-test", result.APIKey)
} }
func TestProviderHandler_CreateProvider_WithProtocol(t *testing.T) { func TestProviderHandler_CreateProvider_WithProtocol(t *testing.T) {
@@ -57,7 +57,7 @@ func TestProviderHandler_CreateProvider_WithProtocol(t *testing.T) {
func TestProviderHandler_UpdateProvider(t *testing.T) { func TestProviderHandler_UpdateProvider(t *testing.T) {
h := NewProviderHandler(&mockProviderService{ h := NewProviderHandler(&mockProviderService{
provider: &domain.Provider{ID: "p1", Name: "Updated", APIKey: "***"}, provider: &domain.Provider{ID: "p1", Name: "Updated", APIKey: "sk-test"},
}) })
body, _ := json.Marshal(map[string]string{"name": "Updated"}) body, _ := json.Marshal(map[string]string{"name": "Updated"})

View File

@@ -65,7 +65,7 @@ func (m *mockProviderService) GetModelByProviderAndName(providerID, modelName st
} }
func (m *mockProviderService) Create(provider *domain.Provider) error { return m.err } func (m *mockProviderService) Create(provider *domain.Provider) error { return m.err }
func (m *mockProviderService) Get(id string, maskKey bool) (*domain.Provider, error) { func (m *mockProviderService) Get(id string) (*domain.Provider, error) {
return m.provider, m.err return m.provider, m.err
} }
func (m *mockProviderService) List() ([]domain.Provider, error) { return m.providers, m.err } func (m *mockProviderService) List() ([]domain.Provider, error) { return m.providers, m.err }
@@ -148,7 +148,7 @@ func TestProviderHandler_ListProviders(t *testing.T) {
func TestProviderHandler_GetProvider(t *testing.T) { func TestProviderHandler_GetProvider(t *testing.T) {
h := NewProviderHandler(&mockProviderService{ h := NewProviderHandler(&mockProviderService{
provider: &domain.Provider{ID: "p1", Name: "P1", APIKey: "***"}, provider: &domain.Provider{ID: "p1", Name: "P1", APIKey: "sk-test"},
}) })
w := httptest.NewRecorder() w := httptest.NewRecorder()

View File

@@ -66,7 +66,6 @@ func (h *ProviderHandler) CreateProvider(c *gin.Context) {
return return
} }
provider.MaskAPIKey()
c.JSON(http.StatusCreated, provider) c.JSON(http.StatusCreated, provider)
} }
@@ -85,7 +84,7 @@ func (h *ProviderHandler) ListProviders(c *gin.Context) {
func (h *ProviderHandler) GetProvider(c *gin.Context) { func (h *ProviderHandler) GetProvider(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
provider, err := h.providerService.Get(id, true) provider, err := h.providerService.Get(id)
if err != nil { if err != nil {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
@@ -131,7 +130,7 @@ func (h *ProviderHandler) UpdateProvider(c *gin.Context) {
return return
} }
provider, err := h.providerService.Get(id, true) provider, err := h.providerService.Get(id)
if err != nil { if err != nil {
writeError(c, err) writeError(c, err)
return return

View File

@@ -80,7 +80,7 @@ func (m *mockProxyProviderService) GetModelByProviderAndName(providerID, modelNa
} }
func (m *mockProxyProviderService) Create(p *domain.Provider) error { return nil } func (m *mockProxyProviderService) Create(p *domain.Provider) error { return nil }
func (m *mockProxyProviderService) Get(id string, maskKey bool) (*domain.Provider, error) { return nil, nil } func (m *mockProxyProviderService) Get(id string) (*domain.Provider, error) { return nil, nil }
func (m *mockProxyProviderService) List() ([]domain.Provider, error) { return m.providers, m.err } func (m *mockProxyProviderService) List() ([]domain.Provider, error) { return m.providers, m.err }
func (m *mockProxyProviderService) Update(id string, updates map[string]interface{}) error { return nil } func (m *mockProxyProviderService) Update(id string, updates map[string]interface{}) error { return nil }
func (m *mockProxyProviderService) Delete(id string) error { return nil } func (m *mockProxyProviderService) Delete(id string) error { return nil }

View File

@@ -5,7 +5,7 @@ import "nex/backend/internal/domain"
// ProviderService 供应商服务接口 // ProviderService 供应商服务接口
type ProviderService interface { type ProviderService interface {
Create(provider *domain.Provider) error Create(provider *domain.Provider) error
Get(id string, maskKey bool) (*domain.Provider, error) Get(id string) (*domain.Provider, error)
List() ([]domain.Provider, error) List() ([]domain.Provider, error)
Update(id string, updates map[string]interface{}) error Update(id string, updates map[string]interface{}) error
Delete(id string) error Delete(id string) error

View File

@@ -32,26 +32,12 @@ func (s *providerService) Create(provider *domain.Provider) error {
return err return err
} }
func (s *providerService) Get(id string, maskKey bool) (*domain.Provider, error) { func (s *providerService) Get(id string) (*domain.Provider, error) {
provider, err := s.providerRepo.GetByID(id) return s.providerRepo.GetByID(id)
if err != nil {
return nil, err
}
if maskKey {
provider.MaskAPIKey()
}
return provider, nil
} }
func (s *providerService) List() ([]domain.Provider, error) { func (s *providerService) List() ([]domain.Provider, error) {
providers, err := s.providerRepo.List() return s.providerRepo.List()
if err != nil {
return nil, err
}
for i := range providers {
providers[i].MaskAPIKey()
}
return providers, nil
} }
func (s *providerService) Update(id string, updates map[string]interface{}) error { func (s *providerService) Update(id string, updates map[string]interface{}) error {

View File

@@ -21,7 +21,7 @@ func TestProviderService_Update(t *testing.T) {
err := svc.Update("p1", map[string]interface{}{"name": "Updated"}) err := svc.Update("p1", map[string]interface{}{"name": "Updated"})
require.NoError(t, err) require.NoError(t, err)
result, err := svc.Get("p1", false) result, err := svc.Get("p1")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "Updated", result.Name) assert.Equal(t, "Updated", result.Name)
} }

View File

@@ -268,7 +268,7 @@ func TestProviderService_Update_Success(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
updated, err := svc.Get("openai", false) updated, err := svc.Get("openai")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "OpenAI Updated", updated.Name) assert.Equal(t, "OpenAI Updated", updated.Name)
} }

View File

@@ -533,8 +533,7 @@ func TestConversion_ProviderWithProtocol(t *testing.T) {
var created map[string]any var created map[string]any
json.Unmarshal(w.Body.Bytes(), &created) json.Unmarshal(w.Body.Bytes(), &created)
// API Key 被掩码 assert.Equal(t, "sk-test", created["api_key"])
assert.Contains(t, created["api_key"], "***")
// 获取时应包含 protocol // 获取时应包含 protocol
w = httptest.NewRecorder() w = httptest.NewRecorder()

View File

@@ -103,7 +103,7 @@ func TestOpenAI_CompleteFlow(t *testing.T) {
var providers []domain.Provider var providers []domain.Provider
json.Unmarshal(w.Body.Bytes(), &providers) json.Unmarshal(w.Body.Bytes(), &providers)
assert.Len(t, providers, 1) assert.Len(t, providers, 1)
assert.Contains(t, providers[0].APIKey, "***") // 已掩码 assert.Equal(t, "sk-test-key", providers[0].APIKey)
// 4. 列出 Model // 4. 列出 Model
w = httptest.NewRecorder() w = httptest.NewRecorder()

View File

@@ -12,7 +12,7 @@ function formInputs(page: import('@playwright/test').Page) {
return { return {
id: dialog.locator('input[placeholder="例如: openai"]'), id: dialog.locator('input[placeholder="例如: openai"]'),
name: 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"]'), baseUrl: dialog.locator('input[placeholder="例如: https://api.openai.com/v1"]'),
protocol: dialog.locator('.t-select'), protocol: dialog.locator('.t-select'),
saveBtn: dialog.locator('.t-dialog__footer').getByRole('button', { name: '保存' }), saveBtn: dialog.locator('.t-dialog__footer').getByRole('button', { name: '保存' }),
@@ -66,9 +66,6 @@ test.describe('供应商管理', () => {
await responsePromise await responsePromise
await expect(page.locator('.t-table__body').getByText('Before Edit')).toBeVisible({ timeout: 5000 }) 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 page.locator('.t-table__body button:has-text("编辑")').first().click()
await expect(page.locator('.t-dialog:visible')).toBeVisible() 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 page.waitForSelector('.t-select__dropdown', { state: 'hidden', timeout: 3000 })
await inputs.saveBtn.click() await inputs.saveBtn.click()
await expect(page.locator('.t-table__body').getByText('To Delete')).toBeVisible({ timeout: 10000 }) 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 page.locator('.t-table__body button:has-text("删除")').first().click()
await expect(page.getByText('确定要删除这个供应商吗?')).toBeVisible() 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 }) 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() const testId = nextId()
await page.getByRole('button', { name: '添加供应商' }).click() await page.getByRole('button', { name: '添加供应商' }).click()
await expect(page.locator('.t-dialog:visible')).toBeVisible() await expect(page.locator('.t-dialog:visible')).toBeVisible()
@@ -123,6 +117,6 @@ test.describe('供应商管理', () => {
await inputs.saveBtn.click() await inputs.saveBtn.click()
await expect(page.locator('.t-table__body').getByText('Mask Test')).toBeVisible({ timeout: 10000 }) 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 { return {
id: dialog.locator('input[placeholder="例如: openai"]'), id: dialog.locator('input[placeholder="例如: openai"]'),
name: 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"]'), baseUrl: dialog.locator('input[placeholder="例如: https://api.openai.com/v1"]'),
saveBtn: dialog.locator('.t-dialog__footer').getByRole('button', { name: '保存' }), saveBtn: dialog.locator('.t-dialog__footer').getByRole('button', { name: '保存' }),
cancelBtn: 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 }) => { test('应显示必填字段验证', async ({ page }) => {
await page.getByRole('button', { name: '添加供应商' }).click() 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() await formInputs(page).saveBtn.click()
@@ -32,7 +32,7 @@ test.describe('供应商表单验证', () => {
test('应验证URL格式', async ({ page }) => { test('应验证URL格式', async ({ page }) => {
await page.getByRole('button', { name: '添加供应商' }).click() 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) const inputs = formInputs(page)
await inputs.id.fill('test_url') await inputs.id.fill('test_url')
@@ -46,17 +46,17 @@ test.describe('供应商表单验证', () => {
test('取消后表单应重置', async ({ page }) => { test('取消后表单应重置', async ({ page }) => {
await page.getByRole('button', { name: '添加供应商' }).click() 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) let inputs = formInputs(page)
await inputs.id.fill('should_be_reset') await inputs.id.fill('should_be_reset')
await inputs.name.fill('Should Be Reset') await inputs.name.fill('Should Be Reset')
await inputs.cancelBtn.click() 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 page.getByRole('button', { name: '添加供应商' }).click()
await expect(page.locator('.t-dialog')).toBeVisible() await expect(page.locator('.t-dialog:visible')).toBeVisible()
inputs = formInputs(page) inputs = formInputs(page)
await expect(inputs.id).toHaveValue('') await expect(inputs.id).toHaveValue('')
@@ -65,11 +65,11 @@ test.describe('供应商表单验证', () => {
test('快速连续点击只打开一个对话框', async ({ page }) => { test('快速连续点击只打开一个对话框', async ({ page }) => {
await page.getByRole('button', { name: '添加供应商' }).click() 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 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(); const dialog = getDialog();
expect(within(dialog).getByText('编辑模型')).toBeInTheDocument(); expect(within(dialog).getByText('编辑模型')).toBeInTheDocument();
// Check that unified ID field is displayed
expect(within(dialog).getByText('统一模型 ID')).toBeInTheDocument();
// Check model name input // Check model name input
const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o') as HTMLInputElement; const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o') as HTMLInputElement;
expect(modelNameInput.value).toBe('gpt-4o'); 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; const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement;
expect(baseUrlInput.value).toBe('https://api.openai.com/v1'); 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} />); render(<ProviderForm {...defaultProps} provider={mockProvider} />);
const dialog = getDialog(); 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 () => { 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 userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { ProviderTable } from '@/pages/Providers/ProviderTable'; import { ProviderTable } from '@/pages/Providers/ProviderTable';
@@ -48,19 +48,18 @@ const defaultProps = {
}; };
describe('ProviderTable', () => { 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} />); render(<ProviderTable {...defaultProps} />);
expect(screen.getByText('供应商列表')).toBeInTheDocument(); 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.getAllByText('OpenAI').length).toBeGreaterThan(0);
expect(screen.getByText('https://api.openai.com/v1')).toBeInTheDocument(); 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.getAllByText('Anthropic').length).toBeGreaterThan(0);
expect(screen.getByText('https://api.anthropic.com')).toBeInTheDocument(); 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 enabledTags = screen.getAllByText('启用');
const disabledTags = screen.getAllByText('禁用'); const disabledTags = screen.getAllByText('禁用');
@@ -77,7 +76,7 @@ describe('ProviderTable', () => {
expect(container.querySelector('.t-card__body')).toBeInTheDocument(); expect(container.querySelector('.t-card__body')).toBeInTheDocument();
}); });
it('renders short api keys fully masked', () => { it('renders short api keys directly', () => {
const shortKeyProvider: Provider[] = [ const shortKeyProvider: Provider[] = [
{ {
...mockProviders[0], ...mockProviders[0],
@@ -88,7 +87,7 @@ describe('ProviderTable', () => {
]; ];
render(<ProviderTable {...defaultProps} providers={shortKeyProvider} />); render(<ProviderTable {...defaultProps} providers={shortKeyProvider} />);
expect(screen.getByText('****')).toBeInTheDocument(); expect(screen.getByText('ab')).toBeInTheDocument();
}); });
it('calls onAdd when clicking "添加供应商" button', async () => { it('calls onAdd when clicking "添加供应商" button', async () => {

View File

@@ -65,6 +65,7 @@ export function ModelForm({
visible={open} visible={open}
closeOnOverlayClick={false} closeOnOverlayClick={false}
closeOnEscKeydown={false} closeOnEscKeydown={false}
lazy={false}
onConfirm={() => { form?.submit(); return false; }} onConfirm={() => { form?.submit(); return false; }}
onClose={onCancel} onClose={onCancel}
confirmLoading={loading} confirmLoading={loading}
@@ -72,15 +73,6 @@ export function ModelForm({
cancelBtn="取消" cancelBtn="取消"
> >
<Form form={form} layout="vertical" onSubmit={handleSubmit}> <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 <Form.FormItem
label="供应商" label="供应商"
name="providerId" name="providerId"

View File

@@ -36,7 +36,7 @@ export function ProviderForm({
form.setFieldsValue({ form.setFieldsValue({
id: provider.id, id: provider.id,
name: provider.name, name: provider.name,
apiKey: '', apiKey: provider.apiKey,
baseUrl: provider.baseUrl, baseUrl: provider.baseUrl,
protocol: provider.protocol, protocol: provider.protocol,
enabled: provider.enabled, enabled: provider.enabled,
@@ -61,6 +61,7 @@ export function ProviderForm({
visible={open} visible={open}
closeOnOverlayClick={false} closeOnOverlayClick={false}
closeOnEscKeydown={false} closeOnEscKeydown={false}
lazy={false}
onConfirm={() => { form?.submit(); return false; }} onConfirm={() => { form?.submit(); return false; }}
onClose={onCancel} onClose={onCancel}
confirmLoading={loading} confirmLoading={loading}
@@ -77,11 +78,11 @@ export function ProviderForm({
</Form.FormItem> </Form.FormItem>
<Form.FormItem <Form.FormItem
label={isEdit ? 'API Key留空则不修改' : 'API Key'} label="API Key"
name="apiKey" 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>
<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 { PrimaryTableCol } from 'tdesign-react/es/table/type';
import type { Provider, Model } from '@/types'; import type { Provider, Model } from '@/types';
import { ModelTable } from './ModelTable'; import { ModelTable } from './ModelTable';
@@ -13,12 +13,6 @@ interface ProviderTableProps {
onEditModel: (model: Model) => void; 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({ export function ProviderTable({
providers, providers,
loading, loading,
@@ -53,13 +47,7 @@ export function ProviderTable({
{ {
title: 'API Key', title: 'API Key',
colKey: 'apiKey', colKey: 'apiKey',
width: 120,
ellipsis: true, ellipsis: true,
cell: ({ row }) => (
<Tooltip content={maskApiKey(row.apiKey)}>
<span>{maskApiKey(row.apiKey)}</span>
</Tooltip>
),
}, },
{ {
title: '状态', title: '状态',

View File

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

View File

@@ -15,7 +15,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 加载供应商管理页面 - **WHEN** 加载供应商管理页面
- **THEN** 前端 SHALL 使用 TDesign Table 显示所有已配置供应商 - **THEN** 前端 SHALL 使用 TDesign Table 显示所有已配置供应商
- **THEN** 每个供应商 SHALL 显示 name、base_url 和 enabled 状态(使用 Tag 组件) - **THEN** 每个供应商 SHALL 显示 name、base_url 和 enabled 状态(使用 Tag 组件)
- **THEN** API Key SHALL 被脱敏显示(掩码处理) - **THEN** API Key SHALL 显示完整值(不进行掩码处理)
- **THEN** 表格 SHALL 支持展开行以显示关联模型 - **THEN** 表格 SHALL 支持展开行以显示关联模型
#### Scenario: 表格列宽约束 #### Scenario: 表格列宽约束
@@ -23,7 +23,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 渲染供应商表格 - **WHEN** 渲染供应商表格
- **THEN** 名称列 SHALL 固定宽度 180px 并启用 ellipsis超长文本显示省略号hover 显示 Tooltip - **THEN** 名称列 SHALL 固定宽度 180px 并启用 ellipsis超长文本显示省略号hover 显示 Tooltip
- **THEN** Base URL 列 SHALL 不设固定宽度(浮动填充剩余空间)并启用 ellipsis + Tooltip - **THEN** Base URL 列 SHALL 不设固定宽度(浮动填充剩余空间)并启用 ellipsis + Tooltip
- **THEN** API Key 列 SHALL 固定宽度 120px 并启用 ellipsis - **THEN** API Key 列 SHALL 不设固定宽度(浮动填充剩余空间)并启用 ellipsis + Tooltip
- **THEN** 状态列 SHALL 固定宽度 80px - **THEN** 状态列 SHALL 固定宽度 80px
- **THEN** 操作列 SHALL 固定宽度 160px - **THEN** 操作列 SHALL 固定宽度 160px
@@ -39,6 +39,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **THEN** 表单 SHALL 包含 id、name、api_key、base_url 字段,带校验规则 - **THEN** 表单 SHALL 包含 id、name、api_key、base_url 字段,带校验规则
- **THEN** Dialog SHALL 禁用蒙版点击关闭closeOnOverlayClick={false} - **THEN** Dialog SHALL 禁用蒙版点击关闭closeOnOverlayClick={false}
- **THEN** Dialog SHALL 禁用 ESC 键关闭closeOnEscKeydown={false} - **THEN** Dialog SHALL 禁用 ESC 键关闭closeOnEscKeydown={false}
- **THEN** Dialog SHALL 设置 lazy={false} 禁用懒加载
- **WHEN** 用户提交包含有效数据的表单 - **WHEN** 用户提交包含有效数据的表单
- **THEN** 前端 SHALL 通过 mutateAsync 调用创建 API - **THEN** 前端 SHALL 通过 mutateAsync 调用创建 API
- **THEN** 成功后 SHALL 关闭 Dialog 并刷新供应商列表 - **THEN** 成功后 SHALL 关闭 Dialog 并刷新供应商列表
@@ -48,8 +49,12 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 用户点击供应商的"编辑"按钮 - **WHEN** 用户点击供应商的"编辑"按钮
- **THEN** 前端 SHALL 使用 TDesign Dialog + Form 显示预填充数据的表单 - **THEN** 前端 SHALL 使用 TDesign Dialog + Form 显示预填充数据的表单
- **THEN** API Key SHALL 回显当前值(完整值)
- **THEN** API Key 输入框 SHALL 为普通文本输入(不使用 password 类型)
- **THEN** API Key 字段 SHALL 始终为必填
- **THEN** Dialog SHALL 禁用蒙版点击关闭closeOnOverlayClick={false} - **THEN** Dialog SHALL 禁用蒙版点击关闭closeOnOverlayClick={false}
- **THEN** Dialog SHALL 禁用 ESC 键关闭closeOnEscKeydown={false} - **THEN** Dialog SHALL 禁用 ESC 键关闭closeOnEscKeydown={false}
- **THEN** Dialog SHALL 设置 lazy={false} 禁用懒加载
- **WHEN** 用户提交包含更新数据的表单 - **WHEN** 用户提交包含更新数据的表单
- **THEN** 前端 SHALL 通过 mutateAsync 调用更新 API - **THEN** 前端 SHALL 通过 mutateAsync 调用更新 API
- **THEN** 成功后 SHALL 关闭 Dialog 并刷新供应商列表 - **THEN** 成功后 SHALL 关闭 Dialog 并刷新供应商列表
@@ -97,10 +102,10 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 用户点击模型的"编辑" - **WHEN** 用户点击模型的"编辑"
- **THEN** 前端 SHALL 显示编辑表单 - **THEN** 前端 SHALL 显示编辑表单
- **THEN** 编辑表单 SHALL 显示统一模型 ID(只读) - **THEN** 编辑表单 SHALL NOT 包含统一模型 ID 字段
- **THEN** ID 字段 SHALL 为禁用状态
- **THEN** Dialog SHALL 禁用蒙版点击关闭closeOnOverlayClick={false} - **THEN** Dialog SHALL 禁用蒙版点击关闭closeOnOverlayClick={false}
- **THEN** Dialog SHALL 禁用 ESC 键关闭closeOnEscKeydown={false} - **THEN** Dialog SHALL 禁用 ESC 键关闭closeOnEscKeydown={false}
- **THEN** Dialog SHALL 设置 lazy={false} 禁用懒加载
- **WHEN** 用户提交表单 - **WHEN** 用户提交表单
- **THEN** 前端 SHALL 通过 mutateAsync 调用更新 API - **THEN** 前端 SHALL 通过 mutateAsync 调用更新 API
- **THEN** 成功后 SHALL 关闭 Dialog 并刷新模型列表 - **THEN** 成功后 SHALL 关闭 Dialog 并刷新模型列表
@@ -135,24 +140,17 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
### Requirement: 显示统一模型 ID ### Requirement: 显示统一模型 ID
前端 SHALL 在所有显示模型的地方使用统一模型 ID。 前端 SHALL 在所有显示模型的地方使用统一模型 ID。
#### Scenario: 模型表格显示统一 ID 列 #### Scenario: 模型表格显示统一 ID 列
- **WHEN** 渲染模型表格 - **WHEN** 渲染模型表格
- **THEN** 表格 SHALL 包含统一模型 ID 列 - **THEN** 表格 SHALL 包含统一模型 ID 列
- **THEN** 统一模型 ID 列 SHALL 显示 `provider_id/model_name` 格式 - **THEN** 统一模型 ID 列 SHALL 显示 `provider_id/model_name` 格式
- **THEN** 统一模型 ID 列 SHALL 启用 ellipsis超长文本显示省略号hover 显示 Tooltip - **THEN** 统一模型 ID 列 SHALL 启用 ellipsis超长文本显示省略号hover 显示 Tooltip
- **THEN** 统一模型 ID 列 SHALL 固定宽度 250px - **THEN** 统一模型 ID 列 SHALL 固定宽度 250px
#### Scenario: 编辑模型显示统一 ID #### Scenario: 统一模型 ID 降级显示
- **WHEN** 编辑模型表单
- **THEN** 表单 SHALL 显示统一模型 ID 字段
- **THEN** 统一模型 ID 字段 SHALL 为只读disabled
- **THEN** 统一模型 ID 字段 SHALL 显示格式说明 "格式provider_id/model_name"
#### Scenario: 统一模型 ID 降级显示
- **WHEN** 后端未返回 unified_id 字段 - **WHEN** 后端未返回 unified_id 字段
- **THEN** 前端 SHALL 拼接 providerId 和 modelName 显示 - **THEN** 前端 SHALL 拼接 providerId 和 modelName 显示

View File

@@ -37,7 +37,7 @@
- **WHEN** 向 `/api/providers` 发送 POST 请求携带有效的供应商数据id, name, api_key, base_url, protocol - **WHEN** 向 `/api/providers` 发送 POST 请求携带有效的供应商数据id, name, api_key, base_url, protocol
- **THEN** 网关 SHALL 在数据库中创建新的供应商记录 - **THEN** 网关 SHALL 在数据库中创建新的供应商记录
- **THEN** 网关 SHALL 返回创建的供应商,状态码为 201 - **THEN** 网关 SHALL 返回创建的供应商api_key 为完整值),状态码为 201
- **THEN** 供应商 SHALL 默认启用 - **THEN** 供应商 SHALL 默认启用
- **THEN** protocol 字段 SHALL 为必填项,值为 "openai" 或 "anthropic" - **THEN** protocol 字段 SHALL 为必填项,值为 "openai" 或 "anthropic"
@@ -99,8 +99,8 @@
- **WHEN** 向 `/api/providers` 发送 GET 请求 - **WHEN** 向 `/api/providers` 发送 GET 请求
- **THEN** 网关 SHALL 返回所有供应商的列表 - **THEN** 网关 SHALL 返回所有供应商的列表
- **THEN** 每个供应商 SHALL 包含 id, name, api_key已掩码, base_url, protocol, enabled, created_at, updated_at - **THEN** 每个供应商 SHALL 包含 id, name, api_key完整值, base_url, protocol, enabled, created_at, updated_at
- **THEN** api_key SHALL 被掩码(仅显示最后 4 个字符 - **THEN** api_key SHALL 返回完整值(不进行掩码处理
**变更说明:** 数据访问从 config 包迁移到 ProviderRepository。API 接口保持不变。 **变更说明:** 数据访问从 config 包迁移到 ProviderRepository。API 接口保持不变。
@@ -113,7 +113,7 @@
- **WHEN** 向 `/api/providers/:id` 发送 GET 请求,携带有效的供应商 ID - **WHEN** 向 `/api/providers/:id` 发送 GET 请求,携带有效的供应商 ID
- **THEN** 网关 SHALL 返回供应商详情 - **THEN** 网关 SHALL 返回供应商详情
- **THEN** SHALL 包含 protocol 字段 - **THEN** SHALL 包含 protocol 字段
- **THEN** api_key SHALL 被掩码 - **THEN** api_key SHALL 返回完整值(不进行掩码处理)
#### Scenario: 获取不存在的供应商 #### Scenario: 获取不存在的供应商
@@ -130,7 +130,7 @@
- **WHEN** 向 `/api/providers/:id` 发送 PUT 请求,携带有效的供应商数据 - **WHEN** 向 `/api/providers/:id` 发送 PUT 请求,携带有效的供应商数据
- **THEN** 网关 SHALL 更新数据库中的供应商记录 - **THEN** 网关 SHALL 更新数据库中的供应商记录
- **THEN** 网关 SHALL 返回更新后的供应商 - **THEN** 网关 SHALL 返回更新后的供应商api_key 为完整值)
- **THEN** 更新 SHALL 支持修改 protocol 字段 - **THEN** 更新 SHALL 支持修改 protocol 字段
**变更说明:** 通过 ProviderService 和 ProviderRepository 实现。API 接口保持不变。 **变更说明:** 通过 ProviderService 和 ProviderRepository 实现。API 接口保持不变。