From adcb7bf00efba91ac6ff7ef0a72c681eb3d9c6a3 Mon Sep 17 00:00:00 2001 From: ianshaw Date: Wed, 24 Dec 2025 08:48:49 -0800 Subject: [PATCH 1/8] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=20.gitignore=20?= =?UTF-8?q?=E5=BF=BD=E7=95=A5=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=E5=B9=B6?= =?UTF-8?q?=E8=BF=98=E5=8E=9F=20Makefile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 backend/config.yaml 到 .gitignore(包含敏感信息) - 添加 deploy/config.yaml 到 .gitignore(包含敏感信息) - 添加 backend/.installed 到 .gitignore - 还原 Makefile 到原始版本 --- .gitignore | 7 +++++++ backend/config.yaml | 38 -------------------------------------- 2 files changed, 7 insertions(+), 38 deletions(-) delete mode 100644 backend/config.yaml diff --git a/.gitignore b/.gitignore index 8083c681..d64676dd 100644 --- a/.gitignore +++ b/.gitignore @@ -92,6 +92,13 @@ backend/internal/web/dist/* # 后端运行时缓存数据 backend/data/ +# =================== +# 本地配置文件(包含敏感信息) +# =================== +backend/config.yaml +deploy/config.yaml +backend/.installed + # =================== # 其他 # =================== diff --git a/backend/config.yaml b/backend/config.yaml deleted file mode 100644 index ff2a8920..00000000 --- a/backend/config.yaml +++ /dev/null @@ -1,38 +0,0 @@ -server: - host: "0.0.0.0" - port: 8080 - mode: "debug" # debug/release - -database: - host: "127.0.0.1" - port: 5432 - user: "postgres" - password: "XZeRr7nkjHWhm8fw" - dbname: "sub2api" - sslmode: "disable" - -redis: - host: "127.0.0.1" - port: 6379 - password: "" - db: 0 - -jwt: - secret: "your-secret-key-change-in-production" - expire_hour: 24 - -default: - admin_email: "admin@sub2api.com" - admin_password: "admin123" - user_concurrency: 5 - user_balance: 0 - api_key_prefix: "sk-" - rate_multiplier: 1.0 - -# Timezone configuration (similar to PHP's date_default_timezone_set) -# This affects ALL time operations: -# - Database timestamps -# - Usage statistics "today" boundary -# - Subscription expiry times -# Common values: Asia/Shanghai, America/New_York, Europe/London, UTC -timezone: "Asia/Shanghai" From 655382800899185ba0905cda4186aa81c3781fb0 Mon Sep 17 00:00:00 2001 From: ianshaw Date: Wed, 24 Dec 2025 08:48:58 -0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(account):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BB=8E=20CRS=20=E5=90=8C=E6=AD=A5=E8=B4=A6=E6=88=B7=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加账户同步 API 接口 (account_handler.go) - 实现 CRS 同步服务 (crs_sync_service.go) - 添加前端同步对话框组件 (SyncFromCrsModal.vue) - 更新账户管理界面支持同步操作 - 添加账户仓库批量创建方法 - 添加中英文国际化翻译 - 更新依赖注入配置 --- backend/cmd/server/wire_gen.go | 3 +- .../internal/handler/admin/account_handler.go | 49 +- backend/internal/repository/account_repo.go | 17 + backend/internal/server/router.go | 1 + backend/internal/service/crs_sync_service.go | 808 ++++++++++++++++++ backend/internal/service/ports/account.go | 3 + backend/internal/service/wire.go | 1 + frontend/src/api/admin/accounts.ts | 23 + .../components/account/SyncFromCrsModal.vue | 165 ++++ frontend/src/components/account/index.ts | 1 + frontend/src/i18n/locales/en.ts | 19 + frontend/src/i18n/locales/zh.ts | 17 + frontend/src/views/admin/AccountsView.vue | 23 +- 13 files changed, 1127 insertions(+), 3 deletions(-) create mode 100644 backend/internal/service/crs_sync_service.go create mode 100644 frontend/src/components/account/SyncFromCrsModal.vue diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 10256311..d4e3eb03 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -86,7 +86,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream) concurrencyCache := repository.NewConcurrencyCache(client) concurrencyService := service.NewConcurrencyService(concurrencyCache) - accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService) + crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository) + accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService) oAuthHandler := admin.NewOAuthHandler(oAuthService) openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService) proxyHandler := admin.NewProxyHandler(adminService) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 662fdd60..89855616 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -34,10 +34,20 @@ type AccountHandler struct { accountUsageService *service.AccountUsageService accountTestService *service.AccountTestService concurrencyService *service.ConcurrencyService + crsSyncService *service.CRSSyncService } // NewAccountHandler creates a new admin account handler -func NewAccountHandler(adminService service.AdminService, oauthService *service.OAuthService, openaiOAuthService *service.OpenAIOAuthService, rateLimitService *service.RateLimitService, accountUsageService *service.AccountUsageService, accountTestService *service.AccountTestService, concurrencyService *service.ConcurrencyService) *AccountHandler { +func NewAccountHandler( + adminService service.AdminService, + oauthService *service.OAuthService, + openaiOAuthService *service.OpenAIOAuthService, + rateLimitService *service.RateLimitService, + accountUsageService *service.AccountUsageService, + accountTestService *service.AccountTestService, + concurrencyService *service.ConcurrencyService, + crsSyncService *service.CRSSyncService, +) *AccountHandler { return &AccountHandler{ adminService: adminService, oauthService: oauthService, @@ -46,6 +56,7 @@ func NewAccountHandler(adminService service.AdminService, oauthService *service. accountUsageService: accountUsageService, accountTestService: accountTestService, concurrencyService: concurrencyService, + crsSyncService: crsSyncService, } } @@ -224,6 +235,13 @@ type TestAccountRequest struct { ModelID string `json:"model_id"` } +type SyncFromCRSRequest struct { + BaseURL string `json:"base_url" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + SyncProxies *bool `json:"sync_proxies"` +} + // Test handles testing account connectivity with SSE streaming // POST /api/v1/admin/accounts/:id/test func (h *AccountHandler) Test(c *gin.Context) { @@ -244,6 +262,35 @@ func (h *AccountHandler) Test(c *gin.Context) { } } +// SyncFromCRS handles syncing accounts from claude-relay-service (CRS) +// POST /api/v1/admin/accounts/sync/crs +func (h *AccountHandler) SyncFromCRS(c *gin.Context) { + var req SyncFromCRSRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + // Default to syncing proxies (can be disabled by explicitly setting false) + syncProxies := true + if req.SyncProxies != nil { + syncProxies = *req.SyncProxies + } + + result, err := h.crsSyncService.SyncFromCRS(c.Request.Context(), service.SyncFromCRSInput{ + BaseURL: req.BaseURL, + Username: req.Username, + Password: req.Password, + SyncProxies: syncProxies, + }) + if err != nil { + response.BadRequest(c, "Sync failed: "+err.Error()) + return + } + + response.Success(c, result) +} + // Refresh handles refreshing account credentials // POST /api/v1/admin/accounts/:id/refresh func (h *AccountHandler) Refresh(c *gin.Context) { diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index dd9b42b1..26deaf7b 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "github.com/Wei-Shaw/sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "time" @@ -39,6 +40,22 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int64) (*model.Accou return &account, nil } +func (r *AccountRepository) GetByCRSAccountID(ctx context.Context, crsAccountID string) (*model.Account, error) { + if crsAccountID == "" { + return nil, nil + } + + var account model.Account + err := r.db.WithContext(ctx).Where("extra->>'crs_account_id' = ?", crsAccountID).First(&account).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &account, nil +} + func (r *AccountRepository) Update(ctx context.Context, account *model.Account) error { return r.db.WithContext(ctx).Save(account).Error } diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index a3df5f06..128ab36a 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -180,6 +180,7 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep accounts.GET("", h.Admin.Account.List) accounts.GET("/:id", h.Admin.Account.GetByID) accounts.POST("", h.Admin.Account.Create) + accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS) accounts.PUT("/:id", h.Admin.Account.Update) accounts.DELETE("/:id", h.Admin.Account.Delete) accounts.POST("/:id/test", h.Admin.Account.Test) diff --git a/backend/internal/service/crs_sync_service.go b/backend/internal/service/crs_sync_service.go new file mode 100644 index 00000000..590a28d8 --- /dev/null +++ b/backend/internal/service/crs_sync_service.go @@ -0,0 +1,808 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/model" + "github.com/Wei-Shaw/sub2api/internal/service/ports" +) + +type CRSSyncService struct { + accountRepo ports.AccountRepository + proxyRepo ports.ProxyRepository +} + +func NewCRSSyncService(accountRepo ports.AccountRepository, proxyRepo ports.ProxyRepository) *CRSSyncService { + return &CRSSyncService{ + accountRepo: accountRepo, + proxyRepo: proxyRepo, + } +} + +type SyncFromCRSInput struct { + BaseURL string + Username string + Password string + SyncProxies bool +} + +type SyncFromCRSItemResult struct { + CRSAccountID string `json:"crs_account_id"` + Kind string `json:"kind"` + Name string `json:"name"` + Action string `json:"action"` // created/updated/failed/skipped + Error string `json:"error,omitempty"` +} + +type SyncFromCRSResult struct { + Created int `json:"created"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + Items []SyncFromCRSItemResult `json:"items"` +} + +type crsLoginResponse struct { + Success bool `json:"success"` + Token string `json:"token"` + Message string `json:"message"` + Error string `json:"error"` + Username string `json:"username"` +} + +type crsExportResponse struct { + Success bool `json:"success"` + Error string `json:"error"` + Message string `json:"message"` + Data struct { + ExportedAt string `json:"exportedAt"` + ClaudeAccounts []crsClaudeAccount `json:"claudeAccounts"` + ClaudeConsoleAccounts []crsConsoleAccount `json:"claudeConsoleAccounts"` + OpenAIOAuthAccounts []crsOpenAIOAuthAccount `json:"openaiOAuthAccounts"` + OpenAIResponsesAccounts []crsOpenAIResponsesAccount `json:"openaiResponsesAccounts"` + } `json:"data"` +} + +type crsProxy struct { + Protocol string `json:"protocol"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` +} + +type crsClaudeAccount struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Platform string `json:"platform"` + AuthType string `json:"authType"` // oauth/setup-token + IsActive bool `json:"isActive"` + Schedulable bool `json:"schedulable"` + Priority int `json:"priority"` + Status string `json:"status"` + Proxy *crsProxy `json:"proxy"` + Credentials map[string]any `json:"credentials"` +} + +type crsConsoleAccount struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Platform string `json:"platform"` + IsActive bool `json:"isActive"` + Schedulable bool `json:"schedulable"` + Priority int `json:"priority"` + Status string `json:"status"` + MaxConcurrentTasks int `json:"maxConcurrentTasks"` + Proxy *crsProxy `json:"proxy"` + Credentials map[string]any `json:"credentials"` +} + +type crsOpenAIResponsesAccount struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Platform string `json:"platform"` + IsActive bool `json:"isActive"` + Schedulable bool `json:"schedulable"` + Priority int `json:"priority"` + Status string `json:"status"` + Proxy *crsProxy `json:"proxy"` + Credentials map[string]any `json:"credentials"` +} + +type crsOpenAIOAuthAccount struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Platform string `json:"platform"` + AuthType string `json:"authType"` // oauth + IsActive bool `json:"isActive"` + Schedulable bool `json:"schedulable"` + Priority int `json:"priority"` + Status string `json:"status"` + Proxy *crsProxy `json:"proxy"` + Credentials map[string]any `json:"credentials"` +} + +func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput) (*SyncFromCRSResult, error) { + baseURL, err := normalizeBaseURL(input.BaseURL) + if err != nil { + return nil, err + } + if strings.TrimSpace(input.Username) == "" || strings.TrimSpace(input.Password) == "" { + return nil, errors.New("username and password are required") + } + + client := &http.Client{Timeout: 20 * time.Second} + + adminToken, err := crsLogin(ctx, client, baseURL, input.Username, input.Password) + if err != nil { + return nil, err + } + + exported, err := crsExportAccounts(ctx, client, baseURL, adminToken) + if err != nil { + return nil, err + } + + now := time.Now().UTC().Format(time.RFC3339) + + result := &SyncFromCRSResult{ + Items: make( + []SyncFromCRSItemResult, + 0, + len(exported.Data.ClaudeAccounts)+len(exported.Data.ClaudeConsoleAccounts)+len(exported.Data.OpenAIOAuthAccounts)+len(exported.Data.OpenAIResponsesAccounts), + ), + } + + var proxies []model.Proxy + if input.SyncProxies { + proxies, _ = s.proxyRepo.ListActive(ctx) + } + + // Claude OAuth / Setup Token -> sub2api anthropic oauth/setup-token + for _, src := range exported.Data.ClaudeAccounts { + item := SyncFromCRSItemResult{ + CRSAccountID: src.ID, + Kind: src.Kind, + Name: src.Name, + } + + targetType := strings.TrimSpace(src.AuthType) + if targetType == "" { + targetType = "oauth" + } + if targetType != model.AccountTypeOAuth && targetType != model.AccountTypeSetupToken { + item.Action = "skipped" + item.Error = "unsupported authType: " + targetType + result.Skipped++ + result.Items = append(result.Items, item) + continue + } + + accessToken, _ := src.Credentials["access_token"].(string) + if strings.TrimSpace(accessToken) == "" { + item.Action = "failed" + item.Error = "missing access_token" + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + proxyID, err := s.mapOrCreateProxy(ctx, input.SyncProxies, &proxies, src.Proxy, fmt.Sprintf("crs-%s", src.Name)) + if err != nil { + item.Action = "failed" + item.Error = "proxy sync failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + credentials := sanitizeCredentialsMap(src.Credentials) + priority := clampPriority(src.Priority) + concurrency := 3 + status := mapCRSStatus(src.IsActive, src.Status) + + extra := map[string]any{ + "crs_account_id": src.ID, + "crs_kind": src.Kind, + "crs_synced_at": now, + } + + existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID) + if err != nil { + item.Action = "failed" + item.Error = "db lookup failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + if existing == nil { + account := &model.Account{ + Name: defaultName(src.Name, src.ID), + Platform: model.PlatformAnthropic, + Type: targetType, + Credentials: model.JSONB(credentials), + Extra: model.JSONB(extra), + ProxyID: proxyID, + Concurrency: concurrency, + Priority: priority, + Status: status, + Schedulable: src.Schedulable, + } + if err := s.accountRepo.Create(ctx, account); err != nil { + item.Action = "failed" + item.Error = "create failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + item.Action = "created" + result.Created++ + result.Items = append(result.Items, item) + continue + } + + // Update existing + if existing.Extra == nil { + existing.Extra = make(model.JSONB) + } + for k, v := range extra { + existing.Extra[k] = v + } + existing.Name = defaultName(src.Name, src.ID) + existing.Platform = model.PlatformAnthropic + existing.Type = targetType + existing.Credentials = model.JSONB(credentials) + existing.ProxyID = proxyID + existing.Concurrency = concurrency + existing.Priority = priority + existing.Status = status + existing.Schedulable = src.Schedulable + + if err := s.accountRepo.Update(ctx, existing); err != nil { + item.Action = "failed" + item.Error = "update failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + item.Action = "updated" + result.Updated++ + result.Items = append(result.Items, item) + } + + // Claude Console API Key -> sub2api anthropic apikey + for _, src := range exported.Data.ClaudeConsoleAccounts { + item := SyncFromCRSItemResult{ + CRSAccountID: src.ID, + Kind: src.Kind, + Name: src.Name, + } + + apiKey, _ := src.Credentials["api_key"].(string) + if strings.TrimSpace(apiKey) == "" { + item.Action = "failed" + item.Error = "missing api_key" + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + proxyID, err := s.mapOrCreateProxy(ctx, input.SyncProxies, &proxies, src.Proxy, fmt.Sprintf("crs-%s", src.Name)) + if err != nil { + item.Action = "failed" + item.Error = "proxy sync failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + credentials := sanitizeCredentialsMap(src.Credentials) + priority := clampPriority(src.Priority) + concurrency := 3 + if src.MaxConcurrentTasks > 0 { + concurrency = src.MaxConcurrentTasks + } + status := mapCRSStatus(src.IsActive, src.Status) + + extra := map[string]any{ + "crs_account_id": src.ID, + "crs_kind": src.Kind, + "crs_synced_at": now, + } + + existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID) + if err != nil { + item.Action = "failed" + item.Error = "db lookup failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + if existing == nil { + account := &model.Account{ + Name: defaultName(src.Name, src.ID), + Platform: model.PlatformAnthropic, + Type: model.AccountTypeApiKey, + Credentials: model.JSONB(credentials), + Extra: model.JSONB(extra), + ProxyID: proxyID, + Concurrency: concurrency, + Priority: priority, + Status: status, + Schedulable: src.Schedulable, + } + if err := s.accountRepo.Create(ctx, account); err != nil { + item.Action = "failed" + item.Error = "create failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + item.Action = "created" + result.Created++ + result.Items = append(result.Items, item) + continue + } + + if existing.Extra == nil { + existing.Extra = make(model.JSONB) + } + for k, v := range extra { + existing.Extra[k] = v + } + existing.Name = defaultName(src.Name, src.ID) + existing.Platform = model.PlatformAnthropic + existing.Type = model.AccountTypeApiKey + existing.Credentials = model.JSONB(credentials) + existing.ProxyID = proxyID + existing.Concurrency = concurrency + existing.Priority = priority + existing.Status = status + existing.Schedulable = src.Schedulable + + if err := s.accountRepo.Update(ctx, existing); err != nil { + item.Action = "failed" + item.Error = "update failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + item.Action = "updated" + result.Updated++ + result.Items = append(result.Items, item) + } + + // OpenAI OAuth -> sub2api openai oauth + for _, src := range exported.Data.OpenAIOAuthAccounts { + item := SyncFromCRSItemResult{ + CRSAccountID: src.ID, + Kind: src.Kind, + Name: src.Name, + } + + accessToken, _ := src.Credentials["access_token"].(string) + if strings.TrimSpace(accessToken) == "" { + item.Action = "failed" + item.Error = "missing access_token" + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + proxyID, err := s.mapOrCreateProxy( + ctx, + input.SyncProxies, + &proxies, + src.Proxy, + fmt.Sprintf("crs-%s", src.Name), + ) + if err != nil { + item.Action = "failed" + item.Error = "proxy sync failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + credentials := sanitizeCredentialsMap(src.Credentials) + // Normalize token_type + if v, ok := credentials["token_type"].(string); !ok || strings.TrimSpace(v) == "" { + credentials["token_type"] = "Bearer" + } + priority := clampPriority(src.Priority) + concurrency := 3 + status := mapCRSStatus(src.IsActive, src.Status) + + extra := map[string]any{ + "crs_account_id": src.ID, + "crs_kind": src.Kind, + "crs_synced_at": now, + } + + existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID) + if err != nil { + item.Action = "failed" + item.Error = "db lookup failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + if existing == nil { + account := &model.Account{ + Name: defaultName(src.Name, src.ID), + Platform: model.PlatformOpenAI, + Type: model.AccountTypeOAuth, + Credentials: model.JSONB(credentials), + Extra: model.JSONB(extra), + ProxyID: proxyID, + Concurrency: concurrency, + Priority: priority, + Status: status, + Schedulable: src.Schedulable, + } + if err := s.accountRepo.Create(ctx, account); err != nil { + item.Action = "failed" + item.Error = "create failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + item.Action = "created" + result.Created++ + result.Items = append(result.Items, item) + continue + } + + if existing.Extra == nil { + existing.Extra = make(model.JSONB) + } + for k, v := range extra { + existing.Extra[k] = v + } + existing.Name = defaultName(src.Name, src.ID) + existing.Platform = model.PlatformOpenAI + existing.Type = model.AccountTypeOAuth + existing.Credentials = model.JSONB(credentials) + existing.ProxyID = proxyID + existing.Concurrency = concurrency + existing.Priority = priority + existing.Status = status + existing.Schedulable = src.Schedulable + + if err := s.accountRepo.Update(ctx, existing); err != nil { + item.Action = "failed" + item.Error = "update failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + item.Action = "updated" + result.Updated++ + result.Items = append(result.Items, item) + } + + // OpenAI Responses API Key -> sub2api openai apikey + for _, src := range exported.Data.OpenAIResponsesAccounts { + item := SyncFromCRSItemResult{ + CRSAccountID: src.ID, + Kind: src.Kind, + Name: src.Name, + } + + apiKey, _ := src.Credentials["api_key"].(string) + if strings.TrimSpace(apiKey) == "" { + item.Action = "failed" + item.Error = "missing api_key" + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + if baseURL, ok := src.Credentials["base_url"].(string); !ok || strings.TrimSpace(baseURL) == "" { + src.Credentials["base_url"] = "https://api.openai.com" + } + + proxyID, err := s.mapOrCreateProxy( + ctx, + input.SyncProxies, + &proxies, + src.Proxy, + fmt.Sprintf("crs-%s", src.Name), + ) + if err != nil { + item.Action = "failed" + item.Error = "proxy sync failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + credentials := sanitizeCredentialsMap(src.Credentials) + priority := clampPriority(src.Priority) + concurrency := 3 + status := mapCRSStatus(src.IsActive, src.Status) + + extra := map[string]any{ + "crs_account_id": src.ID, + "crs_kind": src.Kind, + "crs_synced_at": now, + } + + existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID) + if err != nil { + item.Action = "failed" + item.Error = "db lookup failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + if existing == nil { + account := &model.Account{ + Name: defaultName(src.Name, src.ID), + Platform: model.PlatformOpenAI, + Type: model.AccountTypeApiKey, + Credentials: model.JSONB(credentials), + Extra: model.JSONB(extra), + ProxyID: proxyID, + Concurrency: concurrency, + Priority: priority, + Status: status, + Schedulable: src.Schedulable, + } + if err := s.accountRepo.Create(ctx, account); err != nil { + item.Action = "failed" + item.Error = "create failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + item.Action = "created" + result.Created++ + result.Items = append(result.Items, item) + continue + } + + if existing.Extra == nil { + existing.Extra = make(model.JSONB) + } + for k, v := range extra { + existing.Extra[k] = v + } + existing.Name = defaultName(src.Name, src.ID) + existing.Platform = model.PlatformOpenAI + existing.Type = model.AccountTypeApiKey + existing.Credentials = model.JSONB(credentials) + existing.ProxyID = proxyID + existing.Concurrency = concurrency + existing.Priority = priority + existing.Status = status + existing.Schedulable = src.Schedulable + + if err := s.accountRepo.Update(ctx, existing); err != nil { + item.Action = "failed" + item.Error = "update failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + item.Action = "updated" + result.Updated++ + result.Items = append(result.Items, item) + } + + return result, nil +} + +func (s *CRSSyncService) mapOrCreateProxy(ctx context.Context, enabled bool, cached *[]model.Proxy, src *crsProxy, defaultName string) (*int64, error) { + if !enabled || src == nil { + return nil, nil + } + protocol := strings.ToLower(strings.TrimSpace(src.Protocol)) + switch protocol { + case "socks": + protocol = "socks5" + case "socks5h": + protocol = "socks5" + } + host := strings.TrimSpace(src.Host) + port := src.Port + username := strings.TrimSpace(src.Username) + password := strings.TrimSpace(src.Password) + + if protocol == "" || host == "" || port <= 0 { + return nil, nil + } + if protocol != "http" && protocol != "https" && protocol != "socks5" { + return nil, nil + } + + // Find existing proxy (active only). + for _, p := range *cached { + if strings.EqualFold(p.Protocol, protocol) && + p.Host == host && + p.Port == port && + p.Username == username && + p.Password == password { + id := p.ID + return &id, nil + } + } + + // Create new proxy + proxy := &model.Proxy{ + Name: defaultProxyName(defaultName, protocol, host, port), + Protocol: protocol, + Host: host, + Port: port, + Username: username, + Password: password, + Status: model.StatusActive, + } + if err := s.proxyRepo.Create(ctx, proxy); err != nil { + return nil, err + } + + *cached = append(*cached, *proxy) + id := proxy.ID + return &id, nil +} + +func defaultProxyName(base, protocol, host string, port int) string { + base = strings.TrimSpace(base) + if base == "" { + base = "crs" + } + return fmt.Sprintf("%s (%s://%s:%d)", base, protocol, host, port) +} + +func defaultName(name, id string) string { + if strings.TrimSpace(name) != "" { + return strings.TrimSpace(name) + } + return "CRS " + id +} + +func clampPriority(priority int) int { + if priority < 1 || priority > 100 { + return 50 + } + return priority +} + +func sanitizeCredentialsMap(input map[string]any) map[string]any { + if input == nil { + return map[string]any{} + } + out := make(map[string]any, len(input)) + for k, v := range input { + // Avoid nil values to keep JSONB cleaner + if v != nil { + out[k] = v + } + } + return out +} + +func mapCRSStatus(isActive bool, status string) string { + if !isActive { + return "inactive" + } + if strings.EqualFold(strings.TrimSpace(status), "error") { + return "error" + } + return "active" +} + +func normalizeBaseURL(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", errors.New("base_url is required") + } + u, err := url.Parse(trimmed) + if err != nil || u.Scheme == "" || u.Host == "" { + return "", fmt.Errorf("invalid base_url: %s", trimmed) + } + u.Path = strings.TrimRight(u.Path, "/") + return strings.TrimRight(u.String(), "/"), nil +} + +func crsLogin(ctx context.Context, client *http.Client, baseURL, username, password string) (string, error) { + payload := map[string]any{ + "username": username, + "password": password, + } + body, _ := json.Marshal(payload) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/web/auth/login", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("crs login failed: status=%d body=%s", resp.StatusCode, string(raw)) + } + + var parsed crsLoginResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return "", fmt.Errorf("crs login parse failed: %w", err) + } + if !parsed.Success || strings.TrimSpace(parsed.Token) == "" { + msg := parsed.Message + if msg == "" { + msg = parsed.Error + } + if msg == "" { + msg = "unknown error" + } + return "", errors.New("crs login failed: " + msg) + } + return parsed.Token, nil +} + +func crsExportAccounts(ctx context.Context, client *http.Client, baseURL, adminToken string) (*crsExportResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/admin/sync/export-accounts?include_secrets=true", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+adminToken) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 5<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("crs export failed: status=%d body=%s", resp.StatusCode, string(raw)) + } + + var parsed crsExportResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("crs export parse failed: %w", err) + } + if !parsed.Success { + msg := parsed.Message + if msg == "" { + msg = parsed.Error + } + if msg == "" { + msg = "unknown error" + } + return nil, errors.New("crs export failed: " + msg) + } + return &parsed, nil +} diff --git a/backend/internal/service/ports/account.go b/backend/internal/service/ports/account.go index 2cc1b9fd..73be0005 100644 --- a/backend/internal/service/ports/account.go +++ b/backend/internal/service/ports/account.go @@ -11,6 +11,9 @@ import ( type AccountRepository interface { Create(ctx context.Context, account *model.Account) error GetByID(ctx context.Context, id int64) (*model.Account, error) + // GetByCRSAccountID finds an account previously synced from CRS. + // Returns (nil, nil) if not found. + GetByCRSAccountID(ctx context.Context, crsAccountID string) (*model.Account, error) Update(ctx context.Context, account *model.Account) error Delete(ctx context.Context, id int64) error diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index f1c18c04..998eaee6 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -75,6 +75,7 @@ var ProviderSet = wire.NewSet( NewSubscriptionService, NewConcurrencyService, NewIdentityService, + NewCRSSyncService, ProvideUpdateService, ProvideTokenRefreshService, diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 92bb7cd8..6f5dd8f3 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -244,6 +244,28 @@ export async function getAvailableModels(id: number): Promise { return data; } +export async function syncFromCrs(params: { + base_url: string; + username: string; + password: string; + sync_proxies?: boolean; +}): Promise<{ + created: number; + updated: number; + skipped: number; + failed: number; + items: Array<{ + crs_account_id: string; + kind: string; + name: string; + action: string; + error?: string; + }>; +}> { + const { data } = await apiClient.post('/admin/accounts/sync/crs', params); + return data; +} + export const accountsAPI = { list, getById, @@ -263,6 +285,7 @@ export const accountsAPI = { generateAuthUrl, exchangeCode, batchCreate, + syncFromCrs, }; export default accountsAPI; diff --git a/frontend/src/components/account/SyncFromCrsModal.vue b/frontend/src/components/account/SyncFromCrsModal.vue new file mode 100644 index 00000000..5cb9b00b --- /dev/null +++ b/frontend/src/components/account/SyncFromCrsModal.vue @@ -0,0 +1,165 @@ + + + + diff --git a/frontend/src/components/account/index.ts b/frontend/src/components/account/index.ts index 254c6720..e0463d02 100644 --- a/frontend/src/components/account/index.ts +++ b/frontend/src/components/account/index.ts @@ -8,3 +8,4 @@ export { default as UsageProgressBar } from './UsageProgressBar.vue' export { default as AccountStatsModal } from './AccountStatsModal.vue' export { default as AccountTestModal } from './AccountTestModal.vue' export { default as AccountTodayStatsCell } from './AccountTodayStatsCell.vue' +export { default as SyncFromCrsModal } from './SyncFromCrsModal.vue' diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 203868e9..03427645 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -682,6 +682,25 @@ export default { title: 'Account Management', description: 'Manage AI platform accounts and credentials', createAccount: 'Create Account', + syncFromCrs: 'Sync from CRS', + syncFromCrsTitle: 'Sync Accounts from CRS', + syncFromCrsDesc: + 'Sync accounts from claude-relay-service (CRS) into this system (CRS is called server-to-server).', + crsBaseUrl: 'CRS Base URL', + crsBaseUrlPlaceholder: 'e.g. http://127.0.0.1:3000', + crsUsername: 'Username', + crsPassword: 'Password', + syncProxies: 'Also sync proxies (match by host/port/auth or create)', + syncNow: 'Sync Now', + syncing: 'Syncing...', + syncMissingFields: 'Please fill base URL, username and password', + syncResult: 'Sync Result', + syncResultSummary: 'Created {created}, updated {updated}, skipped {skipped}, failed {failed}', + syncErrors: 'Errors / Skipped Details', + syncCompleted: 'Sync completed: created {created}, updated {updated}', + syncCompletedWithErrors: + 'Sync completed with errors: failed {failed} (created {created}, updated {updated})', + syncFailed: 'Sync failed', editAccount: 'Edit Account', deleteAccount: 'Delete Account', searchAccounts: 'Search accounts...', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 4c688365..b406f709 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -780,6 +780,23 @@ export default { title: '账号管理', description: '管理 AI 平台账号和 Cookie', createAccount: '添加账号', + syncFromCrs: '从 CRS 同步', + syncFromCrsTitle: '从 CRS 同步账号', + syncFromCrsDesc: '将 claude-relay-service(CRS)中的账号同步到当前系统(不会在浏览器侧直接请求 CRS)。', + crsBaseUrl: 'CRS 服务地址', + crsBaseUrlPlaceholder: '例如:http://127.0.0.1:3000', + crsUsername: '用户名', + crsPassword: '密码', + syncProxies: '同时同步代理(按 host/port/账号匹配或自动创建)', + syncNow: '开始同步', + syncing: '同步中...', + syncMissingFields: '请填写服务地址、用户名和密码', + syncResult: '同步结果', + syncResultSummary: '创建 {created},更新 {updated},跳过 {skipped},失败 {failed}', + syncErrors: '错误/跳过详情', + syncCompleted: '同步完成:创建 {created},更新 {updated}', + syncCompletedWithErrors: '同步完成但有错误:失败 {failed}(创建 {created},更新 {updated})', + syncFailed: '同步失败', editAccount: '编辑账号', deleteAccount: '删除账号', deleteConfirmMessage: "确定要删除账号 '{name}' 吗?", diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index b9903a38..5980b379 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -16,6 +16,15 @@ + + + + +
+ + +
+ + +
+ + @@ -324,12 +378,32 @@ @confirm="confirmDelete" @cancel="showDeleteDialog = false" /> + + + + @@ -346,7 +420,7 @@ import Pagination from '@/components/common/Pagination.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import EmptyState from '@/components/common/EmptyState.vue' import Select from '@/components/common/Select.vue' -import { CreateAccountModal, EditAccountModal, ReAuthAccountModal, AccountStatsModal, SyncFromCrsModal } from '@/components/account' +import { CreateAccountModal, EditAccountModal, BulkEditAccountModal, ReAuthAccountModal, AccountStatsModal, SyncFromCrsModal } from '@/components/account' import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue' import AccountUsageCell from '@/components/account/AccountUsageCell.vue' import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue' @@ -360,6 +434,7 @@ const appStore = useAppStore() // Table columns const columns = computed(() => [ + { key: 'select', label: '', sortable: false }, { key: 'name', label: t('admin.accounts.columns.name'), sortable: true }, { key: 'platform_type', label: t('admin.accounts.columns.platformType'), sortable: false }, { key: 'concurrency', label: t('admin.accounts.columns.concurrencyStatus'), sortable: false }, @@ -417,15 +492,26 @@ const showCreateModal = ref(false) const showEditModal = ref(false) const showReAuthModal = ref(false) const showDeleteDialog = ref(false) +const showBulkDeleteDialog = ref(false) const showTestModal = ref(false) const showStatsModal = ref(false) const showCrsSyncModal = ref(false) +const showBulkEditModal = ref(false) const editingAccount = ref(null) const reAuthAccount = ref(null) const deletingAccount = ref(null) const testingAccount = ref(null) const statsAccount = ref(null) const togglingSchedulable = ref(null) +const bulkDeleting = ref(false) + +// Bulk selection +const selectedAccountIds = ref([]) +const selectCurrentPageAccounts = () => { + const pageIds = accounts.value.map(account => account.id) + const merged = new Set([...selectedAccountIds.value, ...pageIds]) + selectedAccountIds.value = Array.from(merged) +} // Rate limit / Overload helpers const isRateLimited = (account: Account): boolean => { @@ -556,6 +642,38 @@ const confirmDelete = async () => { } } +const handleBulkDelete = () => { + if (selectedAccountIds.value.length === 0) return + showBulkDeleteDialog.value = true +} + +const confirmBulkDelete = async () => { + if (bulkDeleting.value || selectedAccountIds.value.length === 0) return + + bulkDeleting.value = true + const ids = [...selectedAccountIds.value] + try { + const results = await Promise.allSettled(ids.map(id => adminAPI.accounts.delete(id))) + const success = results.filter(result => result.status === 'fulfilled').length + const failed = results.length - success + + if (failed === 0) { + appStore.showSuccess(t('admin.accounts.bulkDeleteSuccess', { count: success })) + } else { + appStore.showError(t('admin.accounts.bulkDeletePartial', { success, failed })) + } + + showBulkDeleteDialog.value = false + selectedAccountIds.value = [] + loadAccounts() + } catch (error: any) { + appStore.showError(error.response?.data?.detail || t('admin.accounts.bulkDeleteFailed')) + console.error('Error deleting accounts:', error) + } finally { + bulkDeleting.value = false + } +} + // Clear rate limit const handleClearRateLimit = async (account: Account) => { try { @@ -629,6 +747,23 @@ const closeStatsModal = () => { statsAccount.value = null } +// Bulk selection toggle +const toggleAccountSelection = (accountId: number) => { + const index = selectedAccountIds.value.indexOf(accountId) + if (index === -1) { + selectedAccountIds.value.push(accountId) + } else { + selectedAccountIds.value.splice(index, 1) + } +} + +// Bulk update handler +const handleBulkUpdated = () => { + showBulkEditModal.value = false + selectedAccountIds.value = [] + loadAccounts() +} + // Initialize onMounted(() => { loadAccounts() From 62ed5422dd9b06465ac59343bc32505144b2f160 Mon Sep 17 00:00:00 2001 From: ianshaw Date: Wed, 24 Dec 2025 17:16:19 -0800 Subject: [PATCH 4/8] =?UTF-8?q?feat(account):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E6=9B=B4=E6=96=B0=E5=AE=9E=E7=8E=B0=EF=BC=8C?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=BB=9F=E4=B8=80=20SQL=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=20JSONB=20=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 BulkUpdate 仓储方法,使用单条 SQL 更新所有账户 - credentials/extra 使用 COALESCE(...) || ? 合并,只更新传入的 key - name/proxy_id/concurrency/priority/status 只在提供时更新 - 分组绑定仍逐账号处理(需要独立操作) - 前端优化:Base URL 留空则不修改,按勾选字段更新 - 完善 i18n 文案:说明留空不修改、批量更新行为 --- .../internal/handler/admin/account_handler.go | 55 ++++++++ backend/internal/repository/account_repo.go | 46 +++++++ backend/internal/server/router.go | 1 + backend/internal/service/admin_service.go | 87 ++++++++++++ backend/internal/service/ports/account.go | 13 ++ frontend/src/api/admin/accounts.ts | 2 +- .../account/BulkEditAccountModal.vue | 125 +++++++----------- frontend/src/i18n/locales/en.ts | 2 +- frontend/src/i18n/locales/zh.ts | 2 +- 9 files changed, 253 insertions(+), 80 deletions(-) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 34c795e7..25f69588 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -87,6 +87,19 @@ type UpdateAccountRequest struct { GroupIDs *[]int64 `json:"group_ids"` } +// BulkUpdateAccountsRequest represents the payload for bulk editing accounts +type BulkUpdateAccountsRequest struct { + AccountIDs []int64 `json:"account_ids" binding:"required,min=1"` + Name string `json:"name"` + ProxyID *int64 `json:"proxy_id"` + Concurrency *int `json:"concurrency"` + Priority *int `json:"priority"` + Status string `json:"status" binding:"omitempty,oneof=active inactive error"` + GroupIDs *[]int64 `json:"group_ids"` + Credentials map[string]any `json:"credentials"` + Extra map[string]any `json:"extra"` +} + // AccountWithConcurrency extends Account with real-time concurrency info type AccountWithConcurrency struct { *model.Account @@ -522,6 +535,48 @@ func (h *AccountHandler) BatchUpdateCredentials(c *gin.Context) { }) } +// BulkUpdate handles bulk updating accounts with selected fields/credentials. +// POST /api/v1/admin/accounts/bulk-update +func (h *AccountHandler) BulkUpdate(c *gin.Context) { + var req BulkUpdateAccountsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + hasUpdates := req.Name != "" || + req.ProxyID != nil || + req.Concurrency != nil || + req.Priority != nil || + req.Status != "" || + req.GroupIDs != nil || + len(req.Credentials) > 0 || + len(req.Extra) > 0 + + if !hasUpdates { + response.BadRequest(c, "No updates provided") + return + } + + result, err := h.adminService.BulkUpdateAccounts(c.Request.Context(), &service.BulkUpdateAccountsInput{ + AccountIDs: req.AccountIDs, + Name: req.Name, + ProxyID: req.ProxyID, + Concurrency: req.Concurrency, + Priority: req.Priority, + Status: req.Status, + GroupIDs: req.GroupIDs, + Credentials: req.Credentials, + Extra: req.Extra, + }) + if err != nil { + response.InternalError(c, "Failed to bulk update accounts: "+err.Error()) + return + } + + response.Success(c, result) +} + // ========== OAuth Handlers ========== // GenerateAuthURLRequest represents the request for generating auth URL diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 26deaf7b..424dcfd5 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -5,9 +5,11 @@ import ( "errors" "github.com/Wei-Shaw/sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/service/ports" "time" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type AccountRepository struct { @@ -352,3 +354,47 @@ func (r *AccountRepository) UpdateExtra(ctx context.Context, id int64, updates m return r.db.WithContext(ctx).Model(&model.Account{}).Where("id = ?", id). Update("extra", account.Extra).Error } + +// BulkUpdate updates multiple accounts with the provided fields. +// It merges credentials/extra JSONB fields instead of overwriting them. +func (r *AccountRepository) BulkUpdate(ctx context.Context, ids []int64, updates ports.AccountBulkUpdate) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + + updateMap := map[string]any{} + + if updates.Name != nil { + updateMap["name"] = *updates.Name + } + if updates.ProxyID != nil { + updateMap["proxy_id"] = updates.ProxyID + } + if updates.Concurrency != nil { + updateMap["concurrency"] = *updates.Concurrency + } + if updates.Priority != nil { + updateMap["priority"] = *updates.Priority + } + if updates.Status != nil { + updateMap["status"] = *updates.Status + } + if len(updates.Credentials) > 0 { + updateMap["credentials"] = gorm.Expr("COALESCE(credentials,'{}') || ?", updates.Credentials) + } + if len(updates.Extra) > 0 { + updateMap["extra"] = gorm.Expr("COALESCE(extra,'{}') || ?", updates.Extra) + } + + if len(updateMap) == 0 { + return 0, nil + } + + result := r.db.WithContext(ctx). + Model(&model.Account{}). + Where("id IN ?", ids). + Clauses(clause.Returning{}). + Updates(updateMap) + + return result.RowsAffected, result.Error +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index dfeadd11..61fc4146 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -194,6 +194,7 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels) accounts.POST("/batch", h.Admin.Account.BatchCreate) accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials) + accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate) // Claude OAuth routes accounts.POST("/generate-auth-url", h.Admin.OAuth.GenerateAuthURL) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index ec150425..737c6a50 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -45,6 +45,7 @@ type AdminService interface { RefreshAccountCredentials(ctx context.Context, id int64) (*model.Account, error) ClearAccountError(ctx context.Context, id int64) (*model.Account, error) SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*model.Account, error) + BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) // Proxy management ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string) ([]model.Proxy, int64, error) @@ -140,6 +141,33 @@ type UpdateAccountInput struct { GroupIDs *[]int64 } +// BulkUpdateAccountsInput describes the payload for bulk updating accounts. +type BulkUpdateAccountsInput struct { + AccountIDs []int64 + Name string + ProxyID *int64 + Concurrency *int + Priority *int + Status string + GroupIDs *[]int64 + Credentials map[string]any + Extra map[string]any +} + +// BulkUpdateAccountResult captures the result for a single account update. +type BulkUpdateAccountResult struct { + AccountID int64 `json:"account_id"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// BulkUpdateAccountsResult is the aggregated response for bulk updates. +type BulkUpdateAccountsResult struct { + Success int `json:"success"` + Failed int `json:"failed"` + Results []BulkUpdateAccountResult `json:"results"` +} + type CreateProxyInput struct { Name string Protocol string @@ -694,6 +722,65 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U return account, nil } +// BulkUpdateAccounts updates multiple accounts in one request. +// It merges credentials/extra keys instead of overwriting the whole object. +func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) { + result := &BulkUpdateAccountsResult{ + Results: make([]BulkUpdateAccountResult, 0, len(input.AccountIDs)), + } + + if len(input.AccountIDs) == 0 { + return result, nil + } + + // Prepare bulk updates for columns and JSONB fields. + repoUpdates := ports.AccountBulkUpdate{ + Credentials: input.Credentials, + Extra: input.Extra, + } + if input.Name != "" { + repoUpdates.Name = &input.Name + } + if input.ProxyID != nil { + repoUpdates.ProxyID = input.ProxyID + } + if input.Concurrency != nil { + repoUpdates.Concurrency = input.Concurrency + } + if input.Priority != nil { + repoUpdates.Priority = input.Priority + } + if input.Status != "" { + repoUpdates.Status = &input.Status + } + + // Run bulk update for column/jsonb fields first. + if _, err := s.accountRepo.BulkUpdate(ctx, input.AccountIDs, repoUpdates); err != nil { + return nil, err + } + + // Handle group bindings per account (requires individual operations). + for _, accountID := range input.AccountIDs { + entry := BulkUpdateAccountResult{AccountID: accountID} + + if input.GroupIDs != nil { + if err := s.accountRepo.BindGroups(ctx, accountID, *input.GroupIDs); err != nil { + entry.Success = false + entry.Error = err.Error() + result.Failed++ + result.Results = append(result.Results, entry) + continue + } + } + + entry.Success = true + result.Success++ + result.Results = append(result.Results, entry) + } + + return result, nil +} + func (s *adminServiceImpl) DeleteAccount(ctx context.Context, id int64) error { return s.accountRepo.Delete(ctx, id) } diff --git a/backend/internal/service/ports/account.go b/backend/internal/service/ports/account.go index 73be0005..2d0e979d 100644 --- a/backend/internal/service/ports/account.go +++ b/backend/internal/service/ports/account.go @@ -38,4 +38,17 @@ type AccountRepository interface { ClearRateLimit(ctx context.Context, id int64) error UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error UpdateExtra(ctx context.Context, id int64, updates map[string]any) error + BulkUpdate(ctx context.Context, ids []int64, updates AccountBulkUpdate) (int64, error) +} + +// AccountBulkUpdate describes the fields that can be updated in a bulk operation. +// Nil pointers mean "do not change". +type AccountBulkUpdate struct { + Name *string + ProxyID *int64 + Concurrency *int + Priority *int + Status *string + Credentials map[string]any + Extra map[string]any } diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 60de2492..329fe74a 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -255,7 +255,7 @@ export async function bulkUpdate( results: Array<{ account_id: number; success: boolean; error?: string }>; }>('/admin/accounts/bulk-update', { account_ids: accountIds, - updates + ...updates }); return data; } diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index e24bd04f..fe85204a 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -443,7 +443,7 @@ import { ref, watch, computed } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { adminAPI } from '@/api/admin' -import type { Proxy, Group, Account } from '@/types' +import type { Proxy, Group } from '@/types' import Modal from '@/components/common/Modal.vue' import Select from '@/components/common/Select.vue' import ProxySelector from '@/components/common/ProxySelector.vue' @@ -496,7 +496,6 @@ const concurrency = ref(1) const priority = ref(1) const status = ref<'active' | 'inactive'>('active') const groupIds = ref([]) -const accountCache = ref>({}) // All models list (combined Anthropic + OpenAI) const allModels = [ @@ -613,22 +612,10 @@ const buildModelMappingObject = (): Record | null => { return Object.keys(mapping).length > 0 ? mapping : null } -const getDefaultBaseUrl = (platform: string) => { - return platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com' -} - -const getAccountDetails = async (accountId: number): Promise => { - if (accountCache.value[accountId]) return accountCache.value[accountId] - const account = await adminAPI.accounts.getById(accountId) - accountCache.value[accountId] = account - return account -} - -const buildUpdatePayload = (account: Account): Record | null => { +const buildUpdatePayload = (): Record | null => { const updates: Record = {} - let credentials: Record | null = null + const credentials: Record = {} let credentialsChanged = false - const isAnthropic = account.platform === 'anthropic' if (enableProxy.value) { updates.proxy_id = proxyId.value @@ -650,47 +637,34 @@ const buildUpdatePayload = (account: Account): Record | null => updates.group_ids = groupIds.value } - if (account.type === 'apikey') { - const baseCredentials = (account.credentials || {}) as Record - credentials = { ...baseCredentials } - - if (enableBaseUrl.value) { - credentials.base_url = baseUrl.value.trim() || getDefaultBaseUrl(account.platform) + if (enableBaseUrl.value) { + const baseUrlValue = baseUrl.value.trim() + if (baseUrlValue) { + credentials.base_url = baseUrlValue credentialsChanged = true } + } - if (enableModelRestriction.value) { - const modelMapping = buildModelMappingObject() - if (modelMapping) { - credentials.model_mapping = modelMapping - } else { - delete credentials.model_mapping - } + if (enableModelRestriction.value) { + const modelMapping = buildModelMappingObject() + if (modelMapping) { + credentials.model_mapping = modelMapping credentialsChanged = true } + } - if (enableCustomErrorCodes.value) { - credentials.custom_error_codes_enabled = true - credentials.custom_error_codes = [...selectedErrorCodes.value] - credentialsChanged = true - } - - if (enableInterceptWarmup.value && isAnthropic) { - credentials.intercept_warmup_requests = interceptWarmupRequests.value - credentialsChanged = true - } - } else if (enableInterceptWarmup.value && isAnthropic) { - const baseCredentials = (account.credentials || {}) as Record - credentials = { ...baseCredentials } - if (interceptWarmupRequests.value) { - credentials.intercept_warmup_requests = true - } else { - delete credentials.intercept_warmup_requests - } + if (enableCustomErrorCodes.value) { + credentials.custom_error_codes_enabled = true + credentials.custom_error_codes = [...selectedErrorCodes.value] credentialsChanged = true } - if (credentials && credentialsChanged) { + if (enableInterceptWarmup.value) { + credentials.intercept_warmup_requests = interceptWarmupRequests.value + credentialsChanged = true + } + + if (credentialsChanged) { updates.credentials = credentials } @@ -722,39 +696,37 @@ const handleSubmit = async () => { return } + const updates = buildUpdatePayload() + if (!updates) { + appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected')) + return + } + submitting.value = true - let success = 0 - let failed = 0 - for (const accountId of props.accountIds) { - try { - const account = await getAccountDetails(accountId) - const updates = buildUpdatePayload(account) - if (!updates) { - continue - } - await adminAPI.accounts.update(accountId, updates) - success++ - } catch (error: any) { - failed++ - console.error(`Error bulk updating account ${accountId}:`, error) + try { + const res = await adminAPI.accounts.bulkUpdate(props.accountIds, updates) + const success = res.success || 0 + const failed = res.failed || 0 + + if (success > 0 && failed === 0) { + appStore.showSuccess(t('admin.accounts.bulkEdit.success', { count: success })) + } else if (success > 0) { + appStore.showError(t('admin.accounts.bulkEdit.partialSuccess', { success, failed })) + } else { + appStore.showError(t('admin.accounts.bulkEdit.failed')) } - } - if (success > 0 && failed === 0) { - appStore.showSuccess(t('admin.accounts.bulkEdit.success', { count: success })) - } else if (success > 0) { - appStore.showError(t('admin.accounts.bulkEdit.partialSuccess', { success, failed })) - } else { - appStore.showError(t('admin.accounts.bulkEdit.failed')) + if (success > 0) { + emit('updated') + handleClose() + } + } catch (error: any) { + appStore.showError(error.response?.data?.detail || t('admin.accounts.bulkEdit.failed')) + console.error('Error bulk updating accounts:', error) + } finally { + submitting.value = false } - - if (success > 0) { - emit('updated') - handleClose() - } - - submitting.value = false } // Reset form when modal closes @@ -784,7 +756,6 @@ watch(() => props.show, (newShow) => { priority.value = 1 status.value = 'active' groupIds.value = [] - accountCache.value = {} } }) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index a408733a..c4f6af71 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -756,7 +756,7 @@ export default { title: 'Bulk Edit Accounts', selectionInfo: '{count} account(s) selected. Only checked or filled fields will be updated; others stay unchanged.', baseUrlPlaceholder: 'https://api.anthropic.com or https://api.openai.com', - baseUrlNotice: 'Applies to API Key accounts only; leave empty to use the platform default', + baseUrlNotice: 'Applies to API Key accounts only; leave empty to keep existing value', submit: 'Update Accounts', updating: 'Updating...', success: 'Updated {count} account(s)', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index bf128856..04e9446e 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -887,7 +887,7 @@ export default { title: '批量编辑账号', selectionInfo: '已选择 {count} 个账号。只更新您勾选或填写的字段,未勾选的字段保持不变。', baseUrlPlaceholder: 'https://api.anthropic.com 或 https://api.openai.com', - baseUrlNotice: '仅适用于 API Key 账号,留空使用对应平台默认地址', + baseUrlNotice: '仅适用于 API Key 账号,留空则不修改', submit: '批量更新', updating: '更新中...', success: '成功更新 {count} 个账号', From d23810dc5303c4e608cb2b50a20d1c4ea338ca90 Mon Sep 17 00:00:00 2001 From: ianshaw Date: Wed, 24 Dec 2025 17:54:43 -0800 Subject: [PATCH 5/8] chore: trigger CI workflow From 8b163ca49b23d0320b9db0be6e0c6b712a418920 Mon Sep 17 00:00:00 2001 From: ianshaw Date: Wed, 24 Dec 2025 17:56:55 -0800 Subject: [PATCH 6/8] chore: trigger CI after enabling Actions From 372a01290b3e11db5e58ec69fe749f1b93da2874 Mon Sep 17 00:00:00 2001 From: ianshaw Date: Wed, 24 Dec 2025 17:58:47 -0800 Subject: [PATCH 7/8] fix(backend): handle defer Close() errors in crs_sync_service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 golangci-lint 错误检查问题 - 使用匿名函数包装 defer Close() 并忽略错误 - 符合 Go 最佳实践 --- backend/internal/service/crs_sync_service.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/crs_sync_service.go b/backend/internal/service/crs_sync_service.go index 427fea16..8d1b7e68 100644 --- a/backend/internal/service/crs_sync_service.go +++ b/backend/internal/service/crs_sync_service.go @@ -783,7 +783,7 @@ func crsLogin(ctx context.Context, client *http.Client, baseURL, username, passw if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { @@ -818,7 +818,7 @@ func crsExportAccounts(ctx context.Context, client *http.Client, baseURL, adminT if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 5<<20)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { From 938ffb002e213a719d0b74d280fd46f0fd0e7fa5 Mon Sep 17 00:00:00 2001 From: ianshaw Date: Wed, 24 Dec 2025 18:07:58 -0800 Subject: [PATCH 8/8] style(frontend): format code with prettier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 格式化前端业务代码,符合代码规范 - 统一代码风格 - 修复 ESLint 警告 --- frontend/src/api/admin/accounts.ts | 193 ++++----- .../account/BulkEditAccountModal.vue | 397 ++++++++++++------ frontend/src/i18n/locales/en.ts | 99 +++-- frontend/src/i18n/locales/zh.ts | 24 +- 4 files changed, 443 insertions(+), 270 deletions(-) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 329fe74a..99146bf2 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -3,7 +3,7 @@ * Handles AI platform account management for administrators */ -import { apiClient } from '../client'; +import { apiClient } from '../client' import type { Account, CreateAccountRequest, @@ -13,7 +13,7 @@ import type { WindowStats, ClaudeModel, AccountUsageStatsResponse, -} from '@/types'; +} from '@/types' /** * List all accounts with pagination @@ -26,10 +26,10 @@ export async function list( page: number = 1, pageSize: number = 20, filters?: { - platform?: string; - type?: string; - status?: string; - search?: string; + platform?: string + type?: string + status?: string + search?: string } ): Promise> { const { data } = await apiClient.get>('/admin/accounts', { @@ -38,8 +38,8 @@ export async function list( page_size: pageSize, ...filters, }, - }); - return data; + }) + return data } /** @@ -48,8 +48,8 @@ export async function list( * @returns Account details */ export async function getById(id: number): Promise { - const { data } = await apiClient.get(`/admin/accounts/${id}`); - return data; + const { data } = await apiClient.get(`/admin/accounts/${id}`) + return data } /** @@ -58,8 +58,8 @@ export async function getById(id: number): Promise { * @returns Created account */ export async function create(accountData: CreateAccountRequest): Promise { - const { data } = await apiClient.post('/admin/accounts', accountData); - return data; + const { data } = await apiClient.post('/admin/accounts', accountData) + return data } /** @@ -69,8 +69,8 @@ export async function create(accountData: CreateAccountRequest): Promise { - const { data } = await apiClient.put(`/admin/accounts/${id}`, updates); - return data; + const { data } = await apiClient.put(`/admin/accounts/${id}`, updates) + return data } /** @@ -79,8 +79,8 @@ export async function update(id: number, updates: UpdateAccountRequest): Promise * @returns Success confirmation */ export async function deleteAccount(id: number): Promise<{ message: string }> { - const { data } = await apiClient.delete<{ message: string }>(`/admin/accounts/${id}`); - return data; + const { data } = await apiClient.delete<{ message: string }>(`/admin/accounts/${id}`) + return data } /** @@ -89,11 +89,8 @@ export async function deleteAccount(id: number): Promise<{ message: string }> { * @param status - New status * @returns Updated account */ -export async function toggleStatus( - id: number, - status: 'active' | 'inactive' -): Promise { - return update(id, { status }); +export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise { + return update(id, { status }) } /** @@ -102,16 +99,16 @@ export async function toggleStatus( * @returns Test result */ export async function testAccount(id: number): Promise<{ - success: boolean; - message: string; - latency_ms?: number; + success: boolean + message: string + latency_ms?: number }> { const { data } = await apiClient.post<{ - success: boolean; - message: string; - latency_ms?: number; - }>(`/admin/accounts/${id}/test`); - return data; + success: boolean + message: string + latency_ms?: number + }>(`/admin/accounts/${id}/test`) + return data } /** @@ -120,8 +117,8 @@ export async function testAccount(id: number): Promise<{ * @returns Updated account */ export async function refreshCredentials(id: number): Promise { - const { data } = await apiClient.post(`/admin/accounts/${id}/refresh`); - return data; + const { data } = await apiClient.post(`/admin/accounts/${id}/refresh`) + return data } /** @@ -133,8 +130,8 @@ export async function refreshCredentials(id: number): Promise { export async function getStats(id: number, days: number = 30): Promise { const { data } = await apiClient.get(`/admin/accounts/${id}/stats`, { params: { days }, - }); - return data; + }) + return data } /** @@ -143,8 +140,8 @@ export async function getStats(id: number, days: number = 30): Promise { - const { data } = await apiClient.post(`/admin/accounts/${id}/clear-error`); - return data; + const { data } = await apiClient.post(`/admin/accounts/${id}/clear-error`) + return data } /** @@ -153,8 +150,8 @@ export async function clearError(id: number): Promise { * @returns Account usage info */ export async function getUsage(id: number): Promise { - const { data } = await apiClient.get(`/admin/accounts/${id}/usage`); - return data; + const { data } = await apiClient.get(`/admin/accounts/${id}/usage`) + return data } /** @@ -163,8 +160,10 @@ export async function getUsage(id: number): Promise { * @returns Success confirmation */ export async function clearRateLimit(id: number): Promise<{ message: string }> { - const { data } = await apiClient.post<{ message: string }>(`/admin/accounts/${id}/clear-rate-limit`); - return data; + const { data } = await apiClient.post<{ message: string }>( + `/admin/accounts/${id}/clear-rate-limit` + ) + return data } /** @@ -177,8 +176,8 @@ export async function generateAuthUrl( endpoint: string, config: { proxy_id?: number } ): Promise<{ auth_url: string; session_id: string }> { - const { data } = await apiClient.post<{ auth_url: string; session_id: string }>(endpoint, config); - return data; + const { data } = await apiClient.post<{ auth_url: string; session_id: string }>(endpoint, config) + return data } /** @@ -191,8 +190,8 @@ export async function exchangeCode( endpoint: string, exchangeData: { session_id: string; code: string; proxy_id?: number } ): Promise> { - const { data } = await apiClient.post>(endpoint, exchangeData); - return data; + const { data } = await apiClient.post>(endpoint, exchangeData) + return data } /** @@ -201,16 +200,16 @@ export async function exchangeCode( * @returns Results of batch creation */ export async function batchCreate(accounts: CreateAccountRequest[]): Promise<{ - success: number; - failed: number; - results: Array<{ success: boolean; account?: Account; error?: string }>; + success: number + failed: number + results: Array<{ success: boolean; account?: Account; error?: string }> }> { const { data } = await apiClient.post<{ - success: number; - failed: number; - results: Array<{ success: boolean; account?: Account; error?: string }>; - }>('/admin/accounts/batch', { accounts }); - return data; + success: number + failed: number + results: Array<{ success: boolean; account?: Account; error?: string }> + }>('/admin/accounts/batch', { accounts }) + return data } /** @@ -219,20 +218,20 @@ export async function batchCreate(accounts: CreateAccountRequest[]): Promise<{ * @returns Results of batch update */ export async function batchUpdateCredentials(request: { - account_ids: number[]; - field: string; - value: any; + account_ids: number[] + field: string + value: any }): Promise<{ - success: number; - failed: number; - results: Array<{ account_id: number; success: boolean; error?: string }>; + success: number + failed: number + results: Array<{ account_id: number; success: boolean; error?: string }> }> { - const { data} = await apiClient.post<{ - success: number; - failed: number; - results: Array<{ account_id: number; success: boolean; error?: string }>; - }>('/admin/accounts/batch-update-credentials', request); - return data; + const { data } = await apiClient.post<{ + success: number + failed: number + results: Array<{ account_id: number; success: boolean; error?: string }> + }>('/admin/accounts/batch-update-credentials', request) + return data } /** @@ -245,19 +244,19 @@ export async function bulkUpdate( accountIds: number[], updates: Record ): Promise<{ - success: number; - failed: number; - results: Array<{ account_id: number; success: boolean; error?: string }>; + success: number + failed: number + results: Array<{ account_id: number; success: boolean; error?: string }> }> { const { data } = await apiClient.post<{ - success: number; - failed: number; - results: Array<{ account_id: number; success: boolean; error?: string }>; + success: number + failed: number + results: Array<{ account_id: number; success: boolean; error?: string }> }>('/admin/accounts/bulk-update', { account_ids: accountIds, - ...updates - }); - return data; + ...updates, + }) + return data } /** @@ -266,8 +265,8 @@ export async function bulkUpdate( * @returns Today's stats (requests, tokens, cost) */ export async function getTodayStats(id: number): Promise { - const { data } = await apiClient.get(`/admin/accounts/${id}/today-stats`); - return data; + const { data } = await apiClient.get(`/admin/accounts/${id}/today-stats`) + return data } /** @@ -277,8 +276,10 @@ export async function getTodayStats(id: number): Promise { * @returns Updated account */ export async function setSchedulable(id: number, schedulable: boolean): Promise { - const { data } = await apiClient.post(`/admin/accounts/${id}/schedulable`, { schedulable }); - return data; + const { data } = await apiClient.post(`/admin/accounts/${id}/schedulable`, { + schedulable, + }) + return data } /** @@ -287,30 +288,30 @@ export async function setSchedulable(id: number, schedulable: boolean): Promise< * @returns List of available models for this account */ export async function getAvailableModels(id: number): Promise { - const { data } = await apiClient.get(`/admin/accounts/${id}/models`); - return data; + const { data } = await apiClient.get(`/admin/accounts/${id}/models`) + return data } export async function syncFromCrs(params: { - base_url: string; - username: string; - password: string; - sync_proxies?: boolean; + base_url: string + username: string + password: string + sync_proxies?: boolean }): Promise<{ - created: number; - updated: number; - skipped: number; - failed: number; + created: number + updated: number + skipped: number + failed: number items: Array<{ - crs_account_id: string; - kind: string; - name: string; - action: string; - error?: string; - }>; + crs_account_id: string + kind: string + name: string + action: string + error?: string + }> }> { - const { data } = await apiClient.post('/admin/accounts/sync/crs', params); - return data; + const { data } = await apiClient.post('/admin/accounts/sync/crs', params) + return data } export const accountsAPI = { @@ -335,6 +336,6 @@ export const accountsAPI = { batchUpdateCredentials, bulkUpdate, syncFromCrs, -}; +} -export default accountsAPI; +export default accountsAPI diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index fe85204a..e12e0b8c 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -1,16 +1,16 @@