Merge branch 'dev-frontend-optimization'
This commit is contained in:
@@ -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 = "***"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 = "***"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -40,7 +40,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) {
|
||||||
@@ -73,7 +73,7 @@ func TestProviderHandler_UpdateProvider(t *testing.T) {
|
|||||||
|
|
||||||
mockSvc := mocks.NewMockProviderService(ctrl)
|
mockSvc := mocks.NewMockProviderService(ctrl)
|
||||||
mockSvc.EXPECT().Update(gomock.Eq("p1"), gomock.Any()).Return(nil)
|
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)
|
h := NewProviderHandler(mockSvc)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{"name": "Updated"})
|
body, _ := json.Marshal(map[string]string{"name": "Updated"})
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ func init() {
|
|||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func TestProviderHandler_CreateProvider_MissingFields(t *testing.T) {
|
func TestProviderHandler_CreateProvider_MissingFields(t *testing.T) {
|
||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
defer ctrl.Finish()
|
defer ctrl.Finish()
|
||||||
@@ -69,7 +71,7 @@ func TestProviderHandler_GetProvider(t *testing.T) {
|
|||||||
defer ctrl.Finish()
|
defer ctrl.Finish()
|
||||||
|
|
||||||
mockSvc := mocks.NewMockProviderService(ctrl)
|
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)
|
h := NewProviderHandler(mockSvc)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ func init() {
|
|||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func setupProxyEngine(t *testing.T) *conversion.ConversionEngine {
|
func setupProxyEngine(t *testing.T) *conversion.ConversionEngine {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
registry := conversion.NewMemoryRegistry()
|
registry := conversion.NewMemoryRegistry()
|
||||||
|
|||||||
@@ -7,7 +7,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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,7 +255,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)
|
||||||
}
|
}
|
||||||
@@ -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)
|
db := setupServiceTestDB(t)
|
||||||
repo := repository.NewProviderRepository(db)
|
repo := repository.NewProviderRepository(db)
|
||||||
modelRepo := repository.NewModelRepository(db)
|
modelRepo := repository.NewModelRepository(db)
|
||||||
@@ -442,9 +442,13 @@ func TestProviderService_List_MaskAPIKey(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, providers, 2)
|
require.Len(t, providers, 2)
|
||||||
|
|
||||||
|
expectedKeys := map[string]string{
|
||||||
|
"openai": "sk-1234567890",
|
||||||
|
"anthropic": "sk-anthropic1234",
|
||||||
|
}
|
||||||
for _, p := range providers {
|
for _, p := range providers {
|
||||||
assert.Contains(t, p.APIKey, "***")
|
assert.NotContains(t, p.APIKey, "***")
|
||||||
assert.Len(t, p.APIKey, 7)
|
assert.Equal(t, expectedKeys[p.ID], p.APIKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -69,18 +69,18 @@ func (mr *MockProviderServiceMockRecorder) Delete(id any) *gomock.Call {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get mocks base method.
|
// 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()
|
m.ctrl.T.Helper()
|
||||||
ret := m.ctrl.Call(m, "Get", id, maskKey)
|
ret := m.ctrl.Call(m, "Get", id)
|
||||||
ret0, _ := ret[0].(*domain.Provider)
|
ret0, _ := ret[0].(*domain.Provider)
|
||||||
ret1, _ := ret[1].(error)
|
ret1, _ := ret[1].(error)
|
||||||
return ret0, ret1
|
return ret0, ret1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get indicates an expected call of Get.
|
// 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()
|
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.
|
// GetModelByProviderAndName mocks base method.
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import 'tdesign-react/es/style/index.css'
|
import 'tdesign-react/es/style/index.css'
|
||||||
|
import 'tdesign-react/es/_util/react-19-adapter'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface ModelFormProps {
|
|||||||
model?: Model;
|
model?: Model;
|
||||||
providerId: string;
|
providerId: string;
|
||||||
providers: Provider[];
|
providers: Provider[];
|
||||||
onSave: (values: ModelFormValues) => void;
|
onSave: (values: ModelFormValues) => Promise<void> | void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
@@ -63,6 +63,9 @@ export function ModelForm({
|
|||||||
<Dialog
|
<Dialog
|
||||||
header={isEdit ? '编辑模型' : '添加模型'}
|
header={isEdit ? '编辑模型' : '添加模型'}
|
||||||
visible={open}
|
visible={open}
|
||||||
|
closeOnOverlayClick={false}
|
||||||
|
closeOnEscKeydown={false}
|
||||||
|
lazy={false}
|
||||||
onConfirm={() => { form?.submit(); return false; }}
|
onConfirm={() => { form?.submit(); return false; }}
|
||||||
onClose={onCancel}
|
onClose={onCancel}
|
||||||
confirmLoading={loading}
|
confirmLoading={loading}
|
||||||
@@ -70,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"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface ProviderFormValues {
|
|||||||
interface ProviderFormProps {
|
interface ProviderFormProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
provider?: Provider;
|
provider?: Provider;
|
||||||
onSave: (values: ProviderFormValues) => void;
|
onSave: (values: ProviderFormValues) => Promise<void> | void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
@@ -59,6 +59,9 @@ export function ProviderForm({
|
|||||||
<Dialog
|
<Dialog
|
||||||
header={isEdit ? '编辑供应商' : '添加供应商'}
|
header={isEdit ? '编辑供应商' : '添加供应商'}
|
||||||
visible={open}
|
visible={open}
|
||||||
|
closeOnOverlayClick={false}
|
||||||
|
closeOnEscKeydown={false}
|
||||||
|
lazy={false}
|
||||||
onConfirm={() => { form?.submit(); return false; }}
|
onConfirm={() => { form?.submit(); return false; }}
|
||||||
onClose={onCancel}
|
onClose={onCancel}
|
||||||
confirmLoading={loading}
|
confirmLoading={loading}
|
||||||
@@ -75,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
|
||||||
|
|||||||
@@ -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: '状态',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Provider, Model, UpdateProviderInput, UpdateModelInput } from '@/types';
|
import type { Provider, Model, UpdateProviderInput, UpdateModelInput } from '@/types';
|
||||||
import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders';
|
import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders';
|
||||||
import { useCreateModel, useUpdateModel } from '@/hooks/useModels';
|
import { useCreateModel, useUpdateModel } from '@/hooks/useModels';
|
||||||
@@ -20,18 +20,6 @@ export function ProvidersPage() {
|
|||||||
const [editingModel, setEditingModel] = useState<Model | undefined>();
|
const [editingModel, setEditingModel] = useState<Model | undefined>();
|
||||||
const [modelFormProviderId, setModelFormProviderId] = 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ProviderTable
|
<ProviderTable
|
||||||
@@ -62,16 +50,21 @@ export function ProvidersPage() {
|
|||||||
open={providerFormOpen}
|
open={providerFormOpen}
|
||||||
provider={editingProvider}
|
provider={editingProvider}
|
||||||
loading={createProvider.isPending || updateProvider.isPending}
|
loading={createProvider.isPending || updateProvider.isPending}
|
||||||
onSave={(values) => {
|
onSave={async (values) => {
|
||||||
if (editingProvider) {
|
try {
|
||||||
const input: Partial<UpdateProviderInput> = {};
|
if (editingProvider) {
|
||||||
if (values.name !== editingProvider.name) input.name = values.name;
|
const input: Partial<UpdateProviderInput> = {};
|
||||||
if (values.apiKey) input.apiKey = values.apiKey;
|
if (values.name !== editingProvider.name) input.name = values.name;
|
||||||
if (values.baseUrl !== editingProvider.baseUrl) input.baseUrl = values.baseUrl;
|
if (values.apiKey !== editingProvider.apiKey) input.apiKey = values.apiKey;
|
||||||
if (values.enabled !== editingProvider.enabled) input.enabled = values.enabled;
|
if (values.baseUrl !== editingProvider.baseUrl) input.baseUrl = values.baseUrl;
|
||||||
updateProvider.mutate({ id: editingProvider.id, input });
|
if (values.enabled !== editingProvider.enabled) input.enabled = values.enabled;
|
||||||
} else {
|
await updateProvider.mutateAsync({ id: editingProvider.id, input });
|
||||||
createProvider.mutate(values);
|
} else {
|
||||||
|
await createProvider.mutateAsync(values);
|
||||||
|
}
|
||||||
|
setProviderFormOpen(false);
|
||||||
|
} catch {
|
||||||
|
// 错误已由 hooks 的 onError 处理
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onCancel={() => setProviderFormOpen(false)}
|
onCancel={() => setProviderFormOpen(false)}
|
||||||
@@ -83,15 +76,20 @@ export function ProvidersPage() {
|
|||||||
providerId={modelFormProviderId}
|
providerId={modelFormProviderId}
|
||||||
providers={providers}
|
providers={providers}
|
||||||
loading={createModel.isPending || updateModel.isPending}
|
loading={createModel.isPending || updateModel.isPending}
|
||||||
onSave={(values) => {
|
onSave={async (values) => {
|
||||||
if (editingModel) {
|
try {
|
||||||
const input: Partial<UpdateModelInput> = {};
|
if (editingModel) {
|
||||||
if (values.providerId !== editingModel.providerId) input.providerId = values.providerId;
|
const input: Partial<UpdateModelInput> = {};
|
||||||
if (values.modelName !== editingModel.modelName) input.modelName = values.modelName;
|
if (values.providerId !== editingModel.providerId) input.providerId = values.providerId;
|
||||||
if (values.enabled !== editingModel.enabled) input.enabled = values.enabled;
|
if (values.modelName !== editingModel.modelName) input.modelName = values.modelName;
|
||||||
updateModel.mutate({ id: editingModel.id, input });
|
if (values.enabled !== editingModel.enabled) input.enabled = values.enabled;
|
||||||
} else {
|
await updateModel.mutateAsync({ id: editingModel.id, input });
|
||||||
createModel.mutate(values);
|
} else {
|
||||||
|
await createModel.mutateAsync(values);
|
||||||
|
}
|
||||||
|
setModelFormOpen(false);
|
||||||
|
} catch {
|
||||||
|
// 错误已由 hooks 的 onError 处理
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onCancel={() => setModelFormOpen(false)}
|
onCancel={() => setModelFormOpen(false)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ context: |
|
|||||||
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
|
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
|
||||||
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
|
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
|
||||||
- backend是使用go开发的后端,阅读backend/README.md了解项目架构,优先使用公共组件实现功能逻辑(优先级:官方库>主流三方库>项目公共工具>自行实现)
|
- 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提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
|
||||||
- 禁止创建git操作task
|
- 禁止创建git操作task
|
||||||
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
|
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -37,18 +37,28 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
- **WHEN** 用户点击"添加供应商"按钮
|
- **WHEN** 用户点击"添加供应商"按钮
|
||||||
- **THEN** 前端 SHALL 使用 TDesign Dialog + Form 显示输入表单
|
- **THEN** 前端 SHALL 使用 TDesign Dialog + Form 显示输入表单
|
||||||
- **THEN** 表单 SHALL 包含 id、name、api_key、base_url 字段,带校验规则
|
- **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** 用户提交包含有效数据的表单
|
- **WHEN** 用户提交包含有效数据的表单
|
||||||
- **THEN** 前端 SHALL 通过 useMutation 调用创建 API
|
- **THEN** 前端 SHALL 通过 mutateAsync 调用创建 API
|
||||||
- **THEN** 成功后 SHALL 关闭 Dialog 并刷新供应商列表
|
- **THEN** 成功后 SHALL 关闭 Dialog 并刷新供应商列表
|
||||||
- **THEN** 失败 SHALL 显示错误提示
|
- **THEN** 失败 SHALL 保持 Dialog 打开并显示错误提示(MessagePlugin.error)
|
||||||
|
|
||||||
#### Scenario: 编辑现有供应商
|
#### Scenario: 编辑现有供应商
|
||||||
|
|
||||||
- **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 禁用 ESC 键关闭(closeOnEscKeydown={false})
|
||||||
|
- **THEN** Dialog SHALL 设置 lazy={false} 禁用懒加载
|
||||||
- **WHEN** 用户提交包含更新数据的表单
|
- **WHEN** 用户提交包含更新数据的表单
|
||||||
- **THEN** 前端 SHALL 通过 useMutation 调用更新 API
|
- **THEN** 前端 SHALL 通过 mutateAsync 调用更新 API
|
||||||
- **THEN** 成功后 SHALL 关闭 Dialog 并刷新供应商列表
|
- **THEN** 成功后 SHALL 关闭 Dialog 并刷新供应商列表
|
||||||
|
- **THEN** 失败 SHALL 保持 Dialog 打开并显示错误提示(MessagePlugin.error)
|
||||||
|
|
||||||
#### Scenario: 删除供应商
|
#### Scenario: 删除供应商
|
||||||
|
|
||||||
@@ -81,19 +91,25 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
- **THEN** provider_id SHALL 自动关联当前供应商
|
- **THEN** provider_id SHALL 自动关联当前供应商
|
||||||
- **THEN** 供应商选择 SHALL 使用 `options` 属性
|
- **THEN** 供应商选择 SHALL 使用 `options` 属性
|
||||||
- **THEN** 创建表单 SHALL NOT 包含 ID 输入框(后端自动生成 UUID)
|
- **THEN** 创建表单 SHALL NOT 包含 ID 输入框(后端自动生成 UUID)
|
||||||
|
- **THEN** Dialog SHALL 禁用蒙版点击关闭(closeOnOverlayClick={false})
|
||||||
|
- **THEN** Dialog SHALL 禁用 ESC 键关闭(closeOnEscKeydown={false})
|
||||||
- **WHEN** 用户提交表单
|
- **WHEN** 用户提交表单
|
||||||
- **THEN** 前端 SHALL 通过 useMutation 调用创建 API
|
- **THEN** 前端 SHALL 通过 mutateAsync 调用创建 API
|
||||||
- **THEN** 成功后 SHALL 刷新模型列表
|
- **THEN** 成功后 SHALL 关闭 Dialog 并刷新模型列表
|
||||||
|
- **THEN** 失败 SHALL 保持 Dialog 打开并显示错误提示(MessagePlugin.error)
|
||||||
|
|
||||||
#### Scenario: 编辑模型
|
#### Scenario: 编辑模型
|
||||||
|
|
||||||
- **WHEN** 用户点击模型的"编辑"
|
- **WHEN** 用户点击模型的"编辑"
|
||||||
- **THEN** 前端 SHALL 显示编辑表单
|
- **THEN** 前端 SHALL 显示编辑表单
|
||||||
- **THEN** 编辑表单 SHALL 显示统一模型 ID(只读)
|
- **THEN** 编辑表单 SHALL NOT 包含统一模型 ID 字段
|
||||||
- **THEN** ID 字段 SHALL 为禁用状态
|
- **THEN** Dialog SHALL 禁用蒙版点击关闭(closeOnOverlayClick={false})
|
||||||
|
- **THEN** Dialog SHALL 禁用 ESC 键关闭(closeOnEscKeydown={false})
|
||||||
|
- **THEN** Dialog SHALL 设置 lazy={false} 禁用懒加载
|
||||||
- **WHEN** 用户提交表单
|
- **WHEN** 用户提交表单
|
||||||
- **THEN** 前端 SHALL 通过 useMutation 调用更新 API
|
- **THEN** 前端 SHALL 通过 mutateAsync 调用更新 API
|
||||||
- **THEN** 成功后 SHALL 刷新模型列表
|
- **THEN** 成功后 SHALL 关闭 Dialog 并刷新模型列表
|
||||||
|
- **THEN** 失败 SHALL 保持 Dialog 打开并显示错误提示(MessagePlugin.error)
|
||||||
|
|
||||||
#### Scenario: 删除模型
|
#### Scenario: 删除模型
|
||||||
|
|
||||||
@@ -124,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 显示
|
||||||
@@ -333,6 +342,23 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
- **WHEN** 用户使用浏览器后退按钮
|
- **WHEN** 用户使用浏览器后退按钮
|
||||||
- **THEN** 前端 SHALL 正确导航到上一个页面
|
- **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
|
### Requirement: 使用 React 和 TypeScript
|
||||||
|
|
||||||
前端 SHALL 使用 React 和 TypeScript 实现,遵循 strict 模式。
|
前端 SHALL 使用 React 和 TypeScript 实现,遵循 strict 模式。
|
||||||
|
|||||||
@@ -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 接口保持不变。
|
||||||
|
|||||||
Reference in New Issue
Block a user