From 141f5f886f1a630b5602a933e2e8ef22d2e57c49 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 22 Apr 2026 11:36:16 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BE=9B=E5=BA=94?= =?UTF-8?q?=E5=95=86=E7=AE=A1=E7=90=86=E5=BC=B9=E7=AA=97=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 导入 TDesign react-19-adapter 修复 MessagePlugin 在 React 19 下的渲染错误 - Dialog 禁用蒙版点击和 ESC 键关闭,防止误操作丢失表单数据 - 重构弹窗关闭逻辑,使用 mutateAsync 替代 useEffect 监听 isSuccess - 成功后自动关闭弹窗,失败后保持弹窗打开并显示错误提示 --- frontend/src/main.tsx | 1 + frontend/src/pages/Providers/ModelForm.tsx | 4 +- frontend/src/pages/Providers/ProviderForm.tsx | 4 +- frontend/src/pages/Providers/index.tsx | 62 +++++++++---------- openspec/specs/frontend/spec.md | 42 ++++++++++--- 5 files changed, 72 insertions(+), 41 deletions(-) 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..c737f34 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,8 @@ export function ModelForm({ { form?.submit(); return false; }} onClose={onCancel} confirmLoading={loading} diff --git a/frontend/src/pages/Providers/ProviderForm.tsx b/frontend/src/pages/Providers/ProviderForm.tsx index f3f2f91..f434739 100644 --- a/frontend/src/pages/Providers/ProviderForm.tsx +++ b/frontend/src/pages/Providers/ProviderForm.tsx @@ -15,7 +15,7 @@ interface ProviderFormValues { interface ProviderFormProps { open: boolean; provider?: Provider; - onSave: (values: ProviderFormValues) => void; + onSave: (values: ProviderFormValues) => Promise | void; onCancel: () => void; loading: boolean; } @@ -59,6 +59,8 @@ export function ProviderForm({ { form?.submit(); return false; }} onClose={onCancel} confirmLoading={loading} diff --git a/frontend/src/pages/Providers/index.tsx b/frontend/src/pages/Providers/index.tsx index fd73dd6..ca88f94 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) 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/specs/frontend/spec.md b/openspec/specs/frontend/spec.md index 9eb8423..d05dd50 100644 --- a/openspec/specs/frontend/spec.md +++ b/openspec/specs/frontend/spec.md @@ -37,18 +37,23 @@ 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}) - **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** Dialog SHALL 禁用蒙版点击关闭(closeOnOverlayClick={false}) +- **THEN** Dialog SHALL 禁用 ESC 键关闭(closeOnEscKeydown={false}) - **WHEN** 用户提交包含更新数据的表单 -- **THEN** 前端 SHALL 通过 useMutation 调用更新 API +- **THEN** 前端 SHALL 通过 mutateAsync 调用更新 API - **THEN** 成功后 SHALL 关闭 Dialog 并刷新供应商列表 +- **THEN** 失败 SHALL 保持 Dialog 打开并显示错误提示(MessagePlugin.error) #### Scenario: 删除供应商 @@ -81,9 +86,12 @@ 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: 编辑模型 @@ -91,9 +99,12 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面 - **THEN** 前端 SHALL 显示编辑表单 - **THEN** 编辑表单 SHALL 显示统一模型 ID(只读) - **THEN** ID 字段 SHALL 为禁用状态 +- **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: 删除模型 @@ -333,6 +344,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 模式。 From 81dcecb7238bbdefc654706dd3ef3da6bd1ddee3 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 22 Apr 2026 11:37:05 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=20bun=20?= =?UTF-8?q?=E4=BD=9C=E4=B8=BA=E5=89=8D=E7=AB=AF=E5=94=AF=E4=B8=80=E5=8C=85?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=99=A8=E7=9A=84=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openspec/config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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精心设计并行任务,节省上下文空间,加速任务执行 From 5d58acf5a65e69f55eb8cdbdc6b0114e128d7129 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 22 Apr 2026 13:13:25 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BE=9B=E5=BA=94?= =?UTF-8?q?=E5=95=86=E7=AE=A1=E7=90=86=E5=BC=B9=E7=AA=97=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E5=B9=B6=E5=8E=BB=E6=8E=89=20API=20Key=20?= =?UTF-8?q?=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dialog 设置 lazy={false} 修复首次打开编辑弹窗表单为空 - API Key 改为普通字段(前端去掉 password 类型,后端去掉掩码逻辑) - 删除模型编辑弹窗中的统一模型 ID 字段 - 简化 ProviderService.Get 签名(去掉 maskKey 参数) - 删除 domain 和 config 层的 MaskAPIKey() 方法 - 更新前后端测试(107 单元测试 + 16 E2E 全部通过) - 同步 delta spec 到主 spec --- backend/internal/config/models.go | 8 ----- backend/internal/domain/provider.go | 8 ----- .../handler/handler_supplemental_test.go | 4 +-- backend/internal/handler/handler_test.go | 4 +-- backend/internal/handler/provider_handler.go | 5 ++- .../internal/handler/proxy_handler_test.go | 2 +- backend/internal/service/provider_service.go | 2 +- .../internal/service/provider_service_impl.go | 20 ++--------- .../service/service_supplemental_test.go | 2 +- backend/internal/service/service_test.go | 2 +- backend/tests/integration/conversion_test.go | 3 +- backend/tests/integration/integration_test.go | 2 +- frontend/e2e/providers.spec.ts | 12 ++----- frontend/e2e/validation.spec.ts | 18 +++++----- .../__tests__/components/ModelForm.test.tsx | 3 -- .../components/ProviderForm.test.tsx | 7 ++-- .../components/ProviderTable.test.tsx | 13 ++++--- frontend/src/pages/Providers/ModelForm.tsx | 10 +----- frontend/src/pages/Providers/ProviderForm.tsx | 9 ++--- .../src/pages/Providers/ProviderTable.tsx | 14 +------- frontend/src/pages/Providers/index.tsx | 2 +- openspec/specs/frontend/spec.md | 36 +++++++++---------- openspec/specs/provider-management/spec.md | 10 +++--- 23 files changed, 68 insertions(+), 128 deletions(-) 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 716c406..4938e2a 100644 --- a/backend/internal/handler/handler_supplemental_test.go +++ b/backend/internal/handler/handler_supplemental_test.go @@ -33,7 +33,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) { @@ -57,7 +57,7 @@ func TestProviderHandler_CreateProvider_WithProtocol(t *testing.T) { func TestProviderHandler_UpdateProvider(t *testing.T) { 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"}) diff --git a/backend/internal/handler/handler_test.go b/backend/internal/handler/handler_test.go index 74e0bb5..480e492 100644 --- a/backend/internal/handler/handler_test.go +++ b/backend/internal/handler/handler_test.go @@ -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) Get(id string, maskKey bool) (*domain.Provider, error) { +func (m *mockProviderService) Get(id string) (*domain.Provider, error) { return m.provider, 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) { h := NewProviderHandler(&mockProviderService{ - provider: &domain.Provider{ID: "p1", Name: "P1", APIKey: "***"}, + provider: &domain.Provider{ID: "p1", Name: "P1", APIKey: "sk-test"}, }) 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 d411306..6aaa8c5 100644 --- a/backend/internal/handler/proxy_handler_test.go +++ b/backend/internal/handler/proxy_handler_test.go @@ -80,7 +80,7 @@ func (m *mockProxyProviderService) GetModelByProviderAndName(providerID, modelNa } 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) Update(id string, updates map[string]interface{}) error { return nil } func (m *mockProxyProviderService) Delete(id string) error { return nil } diff --git a/backend/internal/service/provider_service.go b/backend/internal/service/provider_service.go index 2ed3f76..c23df26 100644 --- a/backend/internal/service/provider_service.go +++ b/backend/internal/service/provider_service.go @@ -5,7 +5,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 5ba9e02..f29100f 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 224db39..88cef98 100644 --- a/backend/internal/service/service_test.go +++ b/backend/internal/service/service_test.go @@ -268,7 +268,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) } 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/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/pages/Providers/ModelForm.tsx b/frontend/src/pages/Providers/ModelForm.tsx index c737f34..4bb2efd 100644 --- a/frontend/src/pages/Providers/ModelForm.tsx +++ b/frontend/src/pages/Providers/ModelForm.tsx @@ -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="取消" >
- {isEdit && model?.unifiedId && ( - - -
- 格式:provider_id/model_name -
-
- )} - { form?.submit(); return false; }} onClose={onCancel} confirmLoading={loading} @@ -77,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 ca88f94..15ea951 100644 --- a/frontend/src/pages/Providers/index.tsx +++ b/frontend/src/pages/Providers/index.tsx @@ -55,7 +55,7 @@ export function ProvidersPage() { if (editingProvider) { const input: Partial = {}; 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 }); diff --git a/openspec/specs/frontend/spec.md b/openspec/specs/frontend/spec.md index d05dd50..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 @@ -39,6 +39,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面 - **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 通过 mutateAsync 调用创建 API - **THEN** 成功后 SHALL 关闭 Dialog 并刷新供应商列表 @@ -48,8 +49,12 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面 - **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 通过 mutateAsync 调用更新 API - **THEN** 成功后 SHALL 关闭 Dialog 并刷新供应商列表 @@ -97,10 +102,10 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面 - **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 通过 mutateAsync 调用更新 API - **THEN** 成功后 SHALL 关闭 Dialog 并刷新模型列表 @@ -135,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 显示 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 接口保持不变。