diff --git a/backend/internal/config/models.go b/backend/internal/config/models.go index 739e5c7..628d184 100644 --- a/backend/internal/config/models.go +++ b/backend/internal/config/models.go @@ -48,11 +48,3 @@ func (UsageStats) TableName() string { 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 = "***" - } -} diff --git a/backend/internal/domain/provider.go b/backend/internal/domain/provider.go index f01b2d8..9ec0e47 100644 --- a/backend/internal/domain/provider.go +++ b/backend/internal/domain/provider.go @@ -14,11 +14,3 @@ type Provider struct { 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 = "***" - } -} diff --git a/backend/internal/handler/handler_supplemental_test.go b/backend/internal/handler/handler_supplemental_test.go index a99d886..afe277f 100644 --- a/backend/internal/handler/handler_supplemental_test.go +++ b/backend/internal/handler/handler_supplemental_test.go @@ -40,7 +40,7 @@ func TestProviderHandler_CreateProvider_Success(t *testing.T) { var result domain.Provider require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) 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) { @@ -73,7 +73,7 @@ func TestProviderHandler_UpdateProvider(t *testing.T) { mockSvc := mocks.NewMockProviderService(ctrl) mockSvc.EXPECT().Update(gomock.Eq("p1"), gomock.Any()).Return(nil) - mockSvc.EXPECT().Get(gomock.Eq("p1"), gomock.Eq(true)).Return(&domain.Provider{ID: "p1", Name: "Updated", APIKey: "***"}, nil) + mockSvc.EXPECT().Get(gomock.Eq("p1")).Return(&domain.Provider{ID: "p1", Name: "Updated", APIKey: "sk-test"}, nil) h := NewProviderHandler(mockSvc) body, _ := json.Marshal(map[string]string{"name": "Updated"}) diff --git a/backend/internal/handler/handler_test.go b/backend/internal/handler/handler_test.go index d705025..b490e7f 100644 --- a/backend/internal/handler/handler_test.go +++ b/backend/internal/handler/handler_test.go @@ -24,6 +24,8 @@ func init() { gin.SetMode(gin.TestMode) } + + func TestProviderHandler_CreateProvider_MissingFields(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -69,7 +71,7 @@ func TestProviderHandler_GetProvider(t *testing.T) { defer ctrl.Finish() mockSvc := mocks.NewMockProviderService(ctrl) - mockSvc.EXPECT().Get(gomock.Eq("p1"), gomock.Eq(true)).Return(&domain.Provider{ID: "p1", Name: "P1", APIKey: "***"}, nil) + mockSvc.EXPECT().Get(gomock.Eq("p1")).Return(&domain.Provider{ID: "p1", Name: "P1", APIKey: "sk-test"}, nil) h := NewProviderHandler(mockSvc) w := httptest.NewRecorder() diff --git a/backend/internal/handler/provider_handler.go b/backend/internal/handler/provider_handler.go index db08a32..2ff6a02 100644 --- a/backend/internal/handler/provider_handler.go +++ b/backend/internal/handler/provider_handler.go @@ -66,7 +66,6 @@ func (h *ProviderHandler) CreateProvider(c *gin.Context) { return } - provider.MaskAPIKey() c.JSON(http.StatusCreated, provider) } @@ -85,7 +84,7 @@ func (h *ProviderHandler) ListProviders(c *gin.Context) { func (h *ProviderHandler) GetProvider(c *gin.Context) { id := c.Param("id") - provider, err := h.providerService.Get(id, true) + provider, err := h.providerService.Get(id) if err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{ @@ -131,7 +130,7 @@ func (h *ProviderHandler) UpdateProvider(c *gin.Context) { return } - provider, err := h.providerService.Get(id, true) + provider, err := h.providerService.Get(id) if err != nil { writeError(c, err) return diff --git a/backend/internal/handler/proxy_handler_test.go b/backend/internal/handler/proxy_handler_test.go index 6af2bb2..d953eb0 100644 --- a/backend/internal/handler/proxy_handler_test.go +++ b/backend/internal/handler/proxy_handler_test.go @@ -26,6 +26,8 @@ func init() { gin.SetMode(gin.TestMode) } + + func setupProxyEngine(t *testing.T) *conversion.ConversionEngine { t.Helper() registry := conversion.NewMemoryRegistry() diff --git a/backend/internal/service/provider_service.go b/backend/internal/service/provider_service.go index 7730617..56d6c3e 100644 --- a/backend/internal/service/provider_service.go +++ b/backend/internal/service/provider_service.go @@ -7,7 +7,7 @@ import "nex/backend/internal/domain" // ProviderService 供应商服务接口 type ProviderService interface { Create(provider *domain.Provider) error - Get(id string, maskKey bool) (*domain.Provider, error) + Get(id string) (*domain.Provider, error) List() ([]domain.Provider, error) Update(id string, updates map[string]interface{}) error Delete(id string) error diff --git a/backend/internal/service/provider_service_impl.go b/backend/internal/service/provider_service_impl.go index 28a992a..080b540 100644 --- a/backend/internal/service/provider_service_impl.go +++ b/backend/internal/service/provider_service_impl.go @@ -32,26 +32,12 @@ func (s *providerService) Create(provider *domain.Provider) error { return err } -func (s *providerService) Get(id string, maskKey bool) (*domain.Provider, error) { - provider, err := s.providerRepo.GetByID(id) - if err != nil { - return nil, err - } - if maskKey { - provider.MaskAPIKey() - } - return provider, nil +func (s *providerService) Get(id string) (*domain.Provider, error) { + return s.providerRepo.GetByID(id) } func (s *providerService) List() ([]domain.Provider, error) { - providers, err := s.providerRepo.List() - if err != nil { - return nil, err - } - for i := range providers { - providers[i].MaskAPIKey() - } - return providers, nil + return s.providerRepo.List() } func (s *providerService) Update(id string, updates map[string]interface{}) error { diff --git a/backend/internal/service/service_supplemental_test.go b/backend/internal/service/service_supplemental_test.go index f326f52..dd0d72d 100644 --- a/backend/internal/service/service_supplemental_test.go +++ b/backend/internal/service/service_supplemental_test.go @@ -21,7 +21,7 @@ func TestProviderService_Update(t *testing.T) { err := svc.Update("p1", map[string]interface{}{"name": "Updated"}) require.NoError(t, err) - result, err := svc.Get("p1", false) + result, err := svc.Get("p1") require.NoError(t, err) assert.Equal(t, "Updated", result.Name) } diff --git a/backend/internal/service/service_test.go b/backend/internal/service/service_test.go index 13b49d4..860bbfe 100644 --- a/backend/internal/service/service_test.go +++ b/backend/internal/service/service_test.go @@ -255,7 +255,7 @@ func TestProviderService_Update_Success(t *testing.T) { }) require.NoError(t, err) - updated, err := svc.Get("openai", false) + updated, err := svc.Get("openai") require.NoError(t, err) assert.Equal(t, "OpenAI Updated", updated.Name) } @@ -425,9 +425,9 @@ func TestProviderService_isUniqueConstraintError(t *testing.T) { } } -// ============ ProviderService - List MaskAPIKey 测试 ============ +// ============ ProviderService - List API Key 测试 ============ -func TestProviderService_List_MaskAPIKey(t *testing.T) { +func TestProviderService_List_APIKeyNotMasked(t *testing.T) { db := setupServiceTestDB(t) repo := repository.NewProviderRepository(db) modelRepo := repository.NewModelRepository(db) @@ -442,9 +442,13 @@ func TestProviderService_List_MaskAPIKey(t *testing.T) { require.NoError(t, err) require.Len(t, providers, 2) + expectedKeys := map[string]string{ + "openai": "sk-1234567890", + "anthropic": "sk-anthropic1234", + } for _, p := range providers { - assert.Contains(t, p.APIKey, "***") - assert.Len(t, p.APIKey, 7) + assert.NotContains(t, p.APIKey, "***") + assert.Equal(t, expectedKeys[p.ID], p.APIKey) } } diff --git a/backend/tests/integration/conversion_test.go b/backend/tests/integration/conversion_test.go index dd09194..f5834ea 100644 --- a/backend/tests/integration/conversion_test.go +++ b/backend/tests/integration/conversion_test.go @@ -533,8 +533,7 @@ func TestConversion_ProviderWithProtocol(t *testing.T) { var created map[string]any json.Unmarshal(w.Body.Bytes(), &created) - // API Key 被掩码 - assert.Contains(t, created["api_key"], "***") + assert.Equal(t, "sk-test", created["api_key"]) // 获取时应包含 protocol w = httptest.NewRecorder() diff --git a/backend/tests/integration/integration_test.go b/backend/tests/integration/integration_test.go index 518bb94..13916d9 100644 --- a/backend/tests/integration/integration_test.go +++ b/backend/tests/integration/integration_test.go @@ -103,7 +103,7 @@ func TestOpenAI_CompleteFlow(t *testing.T) { var providers []domain.Provider json.Unmarshal(w.Body.Bytes(), &providers) assert.Len(t, providers, 1) - assert.Contains(t, providers[0].APIKey, "***") // 已掩码 + assert.Equal(t, "sk-test-key", providers[0].APIKey) // 4. 列出 Model w = httptest.NewRecorder() diff --git a/backend/tests/mocks/mock_provider_service.go b/backend/tests/mocks/mock_provider_service.go index 44646dc..242ed0b 100644 --- a/backend/tests/mocks/mock_provider_service.go +++ b/backend/tests/mocks/mock_provider_service.go @@ -69,18 +69,18 @@ func (mr *MockProviderServiceMockRecorder) Delete(id any) *gomock.Call { } // Get mocks base method. -func (m *MockProviderService) Get(id string, maskKey bool) (*domain.Provider, error) { +func (m *MockProviderService) Get(id string) (*domain.Provider, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", id, maskKey) + ret := m.ctrl.Call(m, "Get", id) ret0, _ := ret[0].(*domain.Provider) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. -func (mr *MockProviderServiceMockRecorder) Get(id, maskKey any) *gomock.Call { +func (mr *MockProviderServiceMockRecorder) Get(id any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockProviderService)(nil).Get), id, maskKey) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockProviderService)(nil).Get), id) } // GetModelByProviderAndName mocks base method. diff --git a/frontend/e2e/providers.spec.ts b/frontend/e2e/providers.spec.ts index f8bb50c..4b03d8b 100644 --- a/frontend/e2e/providers.spec.ts +++ b/frontend/e2e/providers.spec.ts @@ -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') }) }) diff --git a/frontend/e2e/validation.spec.ts b/frontend/e2e/validation.spec.ts index 15f4d7d..c8fa994 100644 --- a/frontend/e2e/validation.spec.ts +++ b/frontend/e2e/validation.spec.ts @@ -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() }) }) diff --git a/frontend/src/__tests__/components/ModelForm.test.tsx b/frontend/src/__tests__/components/ModelForm.test.tsx index 18ce390..353874e 100644 --- a/frontend/src/__tests__/components/ModelForm.test.tsx +++ b/frontend/src/__tests__/components/ModelForm.test.tsx @@ -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'); diff --git a/frontend/src/__tests__/components/ProviderForm.test.tsx b/frontend/src/__tests__/components/ProviderForm.test.tsx index 23806ae..6af758b 100644 --- a/frontend/src/__tests__/components/ProviderForm.test.tsx +++ b/frontend/src/__tests__/components/ProviderForm.test.tsx @@ -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(); 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 () => { diff --git a/frontend/src/__tests__/components/ProviderTable.test.tsx b/frontend/src/__tests__/components/ProviderTable.test.tsx index c748f5d..f7bf532 100644 --- a/frontend/src/__tests__/components/ProviderTable.test.tsx +++ b/frontend/src/__tests__/components/ProviderTable.test.tsx @@ -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(); 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(); - expect(screen.getByText('****')).toBeInTheDocument(); + expect(screen.getByText('ab')).toBeInTheDocument(); }); it('calls onAdd when clicking "添加供应商" button', async () => { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 806f900..dc9aed9 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import 'tdesign-react/es/style/index.css' +import 'tdesign-react/es/_util/react-19-adapter' import './index.scss' import App from './App' diff --git a/frontend/src/pages/Providers/ModelForm.tsx b/frontend/src/pages/Providers/ModelForm.tsx index 043fd1e..4bb2efd 100644 --- a/frontend/src/pages/Providers/ModelForm.tsx +++ b/frontend/src/pages/Providers/ModelForm.tsx @@ -14,7 +14,7 @@ interface ModelFormProps { model?: Model; providerId: string; providers: Provider[]; - onSave: (values: ModelFormValues) => void; + onSave: (values: ModelFormValues) => Promise | void; onCancel: () => void; loading: boolean; } @@ -63,6 +63,9 @@ export function ModelForm({ { form?.submit(); return false; }} onClose={onCancel} confirmLoading={loading} @@ -70,15 +73,6 @@ export function ModelForm({ cancelBtn="取消" > - {isEdit && model?.unifiedId && ( - - - - 格式:provider_id/model_name - - - )} - void; + onSave: (values: ProviderFormValues) => Promise | void; onCancel: () => void; loading: boolean; } @@ -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, @@ -59,6 +59,9 @@ export function ProviderForm({ { form?.submit(); return false; }} onClose={onCancel} confirmLoading={loading} @@ -75,11 +78,11 @@ export function ProviderForm({ - + 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 }) => ( - - {maskApiKey(row.apiKey)} - - ), }, { title: '状态', diff --git a/frontend/src/pages/Providers/index.tsx b/frontend/src/pages/Providers/index.tsx index fd73dd6..15ea951 100644 --- a/frontend/src/pages/Providers/index.tsx +++ b/frontend/src/pages/Providers/index.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import type { Provider, Model, UpdateProviderInput, UpdateModelInput } from '@/types'; import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders'; import { useCreateModel, useUpdateModel } from '@/hooks/useModels'; @@ -20,18 +20,6 @@ export function ProvidersPage() { const [editingModel, setEditingModel] = useState(); const [modelFormProviderId, setModelFormProviderId] = useState(''); - useEffect(() => { - if ((createProvider.isSuccess || updateProvider.isSuccess) && providerFormOpen) { - setProviderFormOpen(false); - } - }, [createProvider.isSuccess, updateProvider.isSuccess, providerFormOpen]); - - useEffect(() => { - if ((createModel.isSuccess || updateModel.isSuccess) && modelFormOpen) { - setModelFormOpen(false); - } - }, [createModel.isSuccess, updateModel.isSuccess, modelFormOpen]); - return ( { - if (editingProvider) { - const input: Partial = {}; - if (values.name !== editingProvider.name) input.name = values.name; - if (values.apiKey) input.apiKey = values.apiKey; - if (values.baseUrl !== editingProvider.baseUrl) input.baseUrl = values.baseUrl; - if (values.enabled !== editingProvider.enabled) input.enabled = values.enabled; - updateProvider.mutate({ id: editingProvider.id, input }); - } else { - createProvider.mutate(values); + onSave={async (values) => { + try { + if (editingProvider) { + const input: Partial = {}; + if (values.name !== editingProvider.name) input.name = values.name; + 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 }); + } else { + await createProvider.mutateAsync(values); + } + setProviderFormOpen(false); + } catch { + // 错误已由 hooks 的 onError 处理 } }} onCancel={() => setProviderFormOpen(false)} @@ -83,15 +76,20 @@ export function ProvidersPage() { providerId={modelFormProviderId} providers={providers} loading={createModel.isPending || updateModel.isPending} - onSave={(values) => { - if (editingModel) { - const input: Partial = {}; - if (values.providerId !== editingModel.providerId) input.providerId = values.providerId; - if (values.modelName !== editingModel.modelName) input.modelName = values.modelName; - if (values.enabled !== editingModel.enabled) input.enabled = values.enabled; - updateModel.mutate({ id: editingModel.id, input }); - } else { - createModel.mutate(values); + onSave={async (values) => { + try { + if (editingModel) { + const input: Partial = {}; + if (values.providerId !== editingModel.providerId) input.providerId = values.providerId; + if (values.modelName !== editingModel.modelName) input.modelName = values.modelName; + if (values.enabled !== editingModel.enabled) input.enabled = values.enabled; + await updateModel.mutateAsync({ id: editingModel.id, input }); + } else { + await createModel.mutateAsync(values); + } + setModelFormOpen(false); + } catch { + // 错误已由 hooks 的 onError 处理 } }} onCancel={() => setModelFormOpen(false)} diff --git a/openspec/config.yaml b/openspec/config.yaml index ab5f07f..b5e440c 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -8,7 +8,7 @@ context: | - 新增代码优先复用已有组件、工具、依赖库,不引入新依赖 - 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试 - backend是使用go开发的后端,阅读backend/README.md了解项目架构,优先使用公共组件实现功能逻辑(优先级:官方库>主流三方库>项目公共工具>自行实现) - - frontend是基于bun+vite+typescript开发的前端,严禁使用pnpm、npm + - frontend是基于bun+vite+typescript开发的前端,使用bun作为唯一包管理器,严禁使用pnpm、npm - Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明 - 禁止创建git操作task - 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行 diff --git a/openspec/specs/frontend/spec.md b/openspec/specs/frontend/spec.md index 9eb8423..0b60523 100644 --- a/openspec/specs/frontend/spec.md +++ b/openspec/specs/frontend/spec.md @@ -15,7 +15,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面 - **WHEN** 加载供应商管理页面 - **THEN** 前端 SHALL 使用 TDesign Table 显示所有已配置供应商 - **THEN** 每个供应商 SHALL 显示 name、base_url 和 enabled 状态(使用 Tag 组件) -- **THEN** API Key SHALL 被脱敏显示(掩码处理) +- **THEN** API Key SHALL 显示完整值(不进行掩码处理) - **THEN** 表格 SHALL 支持展开行以显示关联模型 #### Scenario: 表格列宽约束 @@ -23,7 +23,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面 - **WHEN** 渲染供应商表格 - **THEN** 名称列 SHALL 固定宽度 180px 并启用 ellipsis(超长文本显示省略号,hover 显示 Tooltip) - **THEN** Base URL 列 SHALL 不设固定宽度(浮动填充剩余空间)并启用 ellipsis + Tooltip -- **THEN** API Key 列 SHALL 固定宽度 120px 并启用 ellipsis +- **THEN** API Key 列 SHALL 不设固定宽度(浮动填充剩余空间)并启用 ellipsis + Tooltip - **THEN** 状态列 SHALL 固定宽度 80px - **THEN** 操作列 SHALL 固定宽度 160px @@ -37,18 +37,28 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面 - **WHEN** 用户点击"添加供应商"按钮 - **THEN** 前端 SHALL 使用 TDesign Dialog + Form 显示输入表单 - **THEN** 表单 SHALL 包含 id、name、api_key、base_url 字段,带校验规则 +- **THEN** Dialog SHALL 禁用蒙版点击关闭(closeOnOverlayClick={false}) +- **THEN** Dialog SHALL 禁用 ESC 键关闭(closeOnEscKeydown={false}) +- **THEN** Dialog SHALL 设置 lazy={false} 禁用懒加载 - **WHEN** 用户提交包含有效数据的表单 -- **THEN** 前端 SHALL 通过 useMutation 调用创建 API +- **THEN** 前端 SHALL 通过 mutateAsync 调用创建 API - **THEN** 成功后 SHALL 关闭 Dialog 并刷新供应商列表 -- **THEN** 失败 SHALL 显示错误提示 +- **THEN** 失败 SHALL 保持 Dialog 打开并显示错误提示(MessagePlugin.error) #### Scenario: 编辑现有供应商 - **WHEN** 用户点击供应商的"编辑"按钮 - **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 禁用 ESC 键关闭(closeOnEscKeydown={false}) +- **THEN** Dialog SHALL 设置 lazy={false} 禁用懒加载 - **WHEN** 用户提交包含更新数据的表单 -- **THEN** 前端 SHALL 通过 useMutation 调用更新 API +- **THEN** 前端 SHALL 通过 mutateAsync 调用更新 API - **THEN** 成功后 SHALL 关闭 Dialog 并刷新供应商列表 +- **THEN** 失败 SHALL 保持 Dialog 打开并显示错误提示(MessagePlugin.error) #### Scenario: 删除供应商 @@ -81,19 +91,25 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面 - **THEN** provider_id SHALL 自动关联当前供应商 - **THEN** 供应商选择 SHALL 使用 `options` 属性 - **THEN** 创建表单 SHALL NOT 包含 ID 输入框(后端自动生成 UUID) +- **THEN** Dialog SHALL 禁用蒙版点击关闭(closeOnOverlayClick={false}) +- **THEN** Dialog SHALL 禁用 ESC 键关闭(closeOnEscKeydown={false}) - **WHEN** 用户提交表单 -- **THEN** 前端 SHALL 通过 useMutation 调用创建 API -- **THEN** 成功后 SHALL 刷新模型列表 +- **THEN** 前端 SHALL 通过 mutateAsync 调用创建 API +- **THEN** 成功后 SHALL 关闭 Dialog 并刷新模型列表 +- **THEN** 失败 SHALL 保持 Dialog 打开并显示错误提示(MessagePlugin.error) #### Scenario: 编辑模型 - **WHEN** 用户点击模型的"编辑" - **THEN** 前端 SHALL 显示编辑表单 -- **THEN** 编辑表单 SHALL 显示统一模型 ID(只读) -- **THEN** ID 字段 SHALL 为禁用状态 +- **THEN** 编辑表单 SHALL NOT 包含统一模型 ID 字段 +- **THEN** Dialog SHALL 禁用蒙版点击关闭(closeOnOverlayClick={false}) +- **THEN** Dialog SHALL 禁用 ESC 键关闭(closeOnEscKeydown={false}) +- **THEN** Dialog SHALL 设置 lazy={false} 禁用懒加载 - **WHEN** 用户提交表单 -- **THEN** 前端 SHALL 通过 useMutation 调用更新 API -- **THEN** 成功后 SHALL 刷新模型列表 +- **THEN** 前端 SHALL 通过 mutateAsync 调用更新 API +- **THEN** 成功后 SHALL 关闭 Dialog 并刷新模型列表 +- **THEN** 失败 SHALL 保持 Dialog 打开并显示错误提示(MessagePlugin.error) #### Scenario: 删除模型 @@ -124,24 +140,17 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面 ### Requirement: 显示统一模型 ID -前端 SHALL 在所有显示模型的地方使用统一模型 ID。 + 前端 SHALL 在所有显示模型的地方使用统一模型 ID。 -#### Scenario: 模型表格显示统一 ID 列 + #### Scenario: 模型表格显示统一 ID 列 -- **WHEN** 渲染模型表格 -- **THEN** 表格 SHALL 包含统一模型 ID 列 -- **THEN** 统一模型 ID 列 SHALL 显示 `provider_id/model_name` 格式 -- **THEN** 统一模型 ID 列 SHALL 启用 ellipsis(超长文本显示省略号,hover 显示 Tooltip) -- **THEN** 统一模型 ID 列 SHALL 固定宽度 250px + - **WHEN** 渲染模型表格 + - **THEN** 表格 SHALL 包含统一模型 ID 列 + - **THEN** 统一模型 ID 列 SHALL 显示 `provider_id/model_name` 格式 + - **THEN** 统一模型 ID 列 SHALL 启用 ellipsis(超长文本显示省略号,hover 显示 Tooltip) + - **THEN** 统一模型 ID 列 SHALL 固定宽度 250px -#### Scenario: 编辑模型显示统一 ID - -- **WHEN** 编辑模型表单 -- **THEN** 表单 SHALL 显示统一模型 ID 字段 -- **THEN** 统一模型 ID 字段 SHALL 为只读(disabled) -- **THEN** 统一模型 ID 字段 SHALL 显示格式说明 "格式:provider_id/model_name" - -#### Scenario: 统一模型 ID 降级显示 + #### Scenario: 统一模型 ID 降级显示 - **WHEN** 后端未返回 unified_id 字段 - **THEN** 前端 SHALL 拼接 providerId 和 modelName 显示 @@ -333,6 +342,23 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面 - **WHEN** 用户使用浏览器后退按钮 - **THEN** 前端 SHALL 正确导航到上一个页面 +### Requirement: React 19 适配器 + +前端 SHALL 导入 TDesign react-19-adapter 以支持 React 19。 + +#### Scenario: 导入适配器 + +- **WHEN** 应用启动 +- **THEN** main.tsx SHALL 导入 'tdesign-react/es/_util/react-19-adapter' +- **THEN** MessagePlugin、DialogPlugin 等插件式调用 SHALL 正常工作 + +#### Scenario: 错误提示显示 + +- **WHEN** API 请求失败 +- **THEN** MessagePlugin.error SHALL 正确渲染错误提示 +- **THEN** 错误提示 SHALL 显示在页面顶部(placement: top) +- **THEN** 错误提示 SHALL 在 3 秒后自动消失 + ### Requirement: 使用 React 和 TypeScript 前端 SHALL 使用 React 和 TypeScript 实现,遵循 strict 模式。 diff --git a/openspec/specs/provider-management/spec.md b/openspec/specs/provider-management/spec.md index 7f59714..64ea31d 100644 --- a/openspec/specs/provider-management/spec.md +++ b/openspec/specs/provider-management/spec.md @@ -37,7 +37,7 @@ - **WHEN** 向 `/api/providers` 发送 POST 请求,携带有效的供应商数据(id, name, api_key, base_url, protocol) - **THEN** 网关 SHALL 在数据库中创建新的供应商记录 -- **THEN** 网关 SHALL 返回创建的供应商,状态码为 201 +- **THEN** 网关 SHALL 返回创建的供应商(api_key 为完整值),状态码为 201 - **THEN** 供应商 SHALL 默认启用 - **THEN** protocol 字段 SHALL 为必填项,值为 "openai" 或 "anthropic" @@ -99,8 +99,8 @@ - **WHEN** 向 `/api/providers` 发送 GET 请求 - **THEN** 网关 SHALL 返回所有供应商的列表 -- **THEN** 每个供应商 SHALL 包含 id, name, api_key(已掩码), base_url, protocol, enabled, created_at, updated_at -- **THEN** api_key SHALL 被掩码(仅显示最后 4 个字符) +- **THEN** 每个供应商 SHALL 包含 id, name, api_key(完整值), base_url, protocol, enabled, created_at, updated_at +- **THEN** api_key SHALL 返回完整值(不进行掩码处理) **变更说明:** 数据访问从 config 包迁移到 ProviderRepository。API 接口保持不变。 @@ -113,7 +113,7 @@ - **WHEN** 向 `/api/providers/:id` 发送 GET 请求,携带有效的供应商 ID - **THEN** 网关 SHALL 返回供应商详情 - **THEN** SHALL 包含 protocol 字段 -- **THEN** api_key SHALL 被掩码 +- **THEN** api_key SHALL 返回完整值(不进行掩码处理) #### Scenario: 获取不存在的供应商 @@ -130,7 +130,7 @@ - **WHEN** 向 `/api/providers/:id` 发送 PUT 请求,携带有效的供应商数据 - **THEN** 网关 SHALL 更新数据库中的供应商记录 -- **THEN** 网关 SHALL 返回更新后的供应商 +- **THEN** 网关 SHALL 返回更新后的供应商(api_key 为完整值) - **THEN** 更新 SHALL 支持修改 protocol 字段 **变更说明:** 通过 ProviderService 和 ProviderRepository 实现。API 接口保持不变。