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/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/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" diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 662fdd60..25f69588 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, } } @@ -76,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 @@ -224,6 +248,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 +275,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) { @@ -387,6 +447,136 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) { }) } +// BatchUpdateCredentialsRequest represents batch credentials update request +type BatchUpdateCredentialsRequest struct { + AccountIDs []int64 `json:"account_ids" binding:"required,min=1"` + Field string `json:"field" binding:"required,oneof=account_uuid org_uuid intercept_warmup_requests"` + Value any `json:"value"` +} + +// BatchUpdateCredentials handles batch updating credentials fields +// POST /api/v1/admin/accounts/batch-update-credentials +func (h *AccountHandler) BatchUpdateCredentials(c *gin.Context) { + var req BatchUpdateCredentialsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + // Validate value type based on field + if req.Field == "intercept_warmup_requests" { + // Must be boolean + if _, ok := req.Value.(bool); !ok { + response.BadRequest(c, "intercept_warmup_requests must be boolean") + return + } + } else { + // account_uuid and org_uuid can be string or null + if req.Value != nil { + if _, ok := req.Value.(string); !ok { + response.BadRequest(c, req.Field+" must be string or null") + return + } + } + } + + ctx := c.Request.Context() + success := 0 + failed := 0 + results := []gin.H{} + + for _, accountID := range req.AccountIDs { + // Get account + account, err := h.adminService.GetAccount(ctx, accountID) + if err != nil { + failed++ + results = append(results, gin.H{ + "account_id": accountID, + "success": false, + "error": "Account not found", + }) + continue + } + + // Update credentials field + if account.Credentials == nil { + account.Credentials = make(map[string]any) + } + + account.Credentials[req.Field] = req.Value + + // Update account + updateInput := &service.UpdateAccountInput{ + Credentials: account.Credentials, + } + + _, err = h.adminService.UpdateAccount(ctx, accountID, updateInput) + if err != nil { + failed++ + results = append(results, gin.H{ + "account_id": accountID, + "success": false, + "error": err.Error(), + }) + continue + } + + success++ + results = append(results, gin.H{ + "account_id": accountID, + "success": true, + }) + } + + response.Success(c, gin.H{ + "success": success, + "failed": failed, + "results": results, + }) +} + +// 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 dd9b42b1..424dcfd5 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -2,11 +2,14 @@ package repository import ( "context" + "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 { @@ -39,6 +42,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 } @@ -335,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 a3df5f06..61fc4146 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) @@ -192,6 +193,8 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable) 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/crs_sync_service.go b/backend/internal/service/crs_sync_service.go new file mode 100644 index 00000000..8d1b7e68 --- /dev/null +++ b/backend/internal/service/crs_sync_service.go @@ -0,0 +1,843 @@ +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"` + Extra map[string]any `json:"extra"` +} + +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"` + Extra map[string]any `json:"extra"` +} + +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) + // 🔧 Remove /v1 suffix from base_url for Claude accounts + cleanBaseURL(credentials, "/v1") + // 🔧 Convert expires_at from ISO string to Unix timestamp + if expiresAtStr, ok := credentials["expires_at"].(string); ok && expiresAtStr != "" { + if t, err := time.Parse(time.RFC3339, expiresAtStr); err == nil { + credentials["expires_at"] = t.Unix() + } + } + // 🔧 Add intercept_warmup_requests if not present (defaults to false) + if _, exists := credentials["intercept_warmup_requests"]; !exists { + credentials["intercept_warmup_requests"] = false + } + priority := clampPriority(src.Priority) + concurrency := 3 + status := mapCRSStatus(src.IsActive, src.Status) + + // 🔧 Use CRS extra data directly, add sync metadata + extra := src.Extra + if extra == nil { + extra = make(map[string]any) + } + extra["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 + existing.Extra = mergeJSONB(existing.Extra, extra) + existing.Name = defaultName(src.Name, src.ID) + existing.Platform = model.PlatformAnthropic + existing.Type = targetType + existing.Credentials = mergeJSONB(existing.Credentials, credentials) + if proxyID != nil { + 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 + } + + existing.Extra = mergeJSONB(existing.Extra, extra) + existing.Name = defaultName(src.Name, src.ID) + existing.Platform = model.PlatformAnthropic + existing.Type = model.AccountTypeApiKey + existing.Credentials = mergeJSONB(existing.Credentials, credentials) + if proxyID != nil { + 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" + } + // 🔧 Convert expires_at from ISO string to Unix timestamp + if expiresAtStr, ok := credentials["expires_at"].(string); ok && expiresAtStr != "" { + if t, err := time.Parse(time.RFC3339, expiresAtStr); err == nil { + credentials["expires_at"] = t.Unix() + } + } + priority := clampPriority(src.Priority) + concurrency := 3 + status := mapCRSStatus(src.IsActive, src.Status) + + // 🔧 Use CRS extra data directly, add sync metadata + extra := src.Extra + if extra == nil { + extra = make(map[string]any) + } + extra["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 + } + + existing.Extra = mergeJSONB(existing.Extra, extra) + existing.Name = defaultName(src.Name, src.ID) + existing.Platform = model.PlatformOpenAI + existing.Type = model.AccountTypeOAuth + existing.Credentials = mergeJSONB(existing.Credentials, credentials) + if proxyID != nil { + 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" + } + // 🔧 Remove /v1 suffix from base_url for OpenAI accounts + cleanBaseURL(src.Credentials, "/v1") + + 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 + } + + existing.Extra = mergeJSONB(existing.Extra, extra) + existing.Name = defaultName(src.Name, src.ID) + existing.Platform = model.PlatformOpenAI + existing.Type = model.AccountTypeApiKey + existing.Credentials = mergeJSONB(existing.Credentials, credentials) + if proxyID != nil { + 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 +} + +// mergeJSONB merges two JSONB maps without removing keys that are absent in updates. +func mergeJSONB(existing model.JSONB, updates map[string]any) model.JSONB { + out := make(model.JSONB) + for k, v := range existing { + out[k] = v + } + for k, v := range updates { + out[k] = v + } + return out +} + +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 +} + +// cleanBaseURL removes trailing suffix from base_url in credentials +// Used for both Claude and OpenAI accounts to remove /v1 +func cleanBaseURL(credentials map[string]any, suffixToRemove string) { + if baseURL, ok := credentials["base_url"].(string); ok && baseURL != "" { + trimmed := strings.TrimSpace(baseURL) + if strings.HasSuffix(trimmed, suffixToRemove) { + credentials["base_url"] = strings.TrimSuffix(trimmed, suffixToRemove) + } + } +} + +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 func() { _ = 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 func() { _ = 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..2d0e979d 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 @@ -35,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/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/deploy/docker-compose.yml b/deploy/docker-compose.yml index fa8e8823..59a91969 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -16,7 +16,7 @@ services: # Sub2API Application # =========================================================================== sub2api: - image: weishaw/sub2api:latest + image: sub2api:latest container_name: sub2api restart: unless-stopped ports: @@ -114,6 +114,8 @@ services: timeout: 5s retries: 5 start_period: 10s + ports: + - 5433:5432 # =========================================================================== # Redis Cache diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 92bb7cd8..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,63 @@ 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 +} + +/** + * Batch update credentials fields for multiple accounts + * @param request - Batch update request containing account IDs, field name, and value + * @returns Results of batch update + */ +export async function batchUpdateCredentials(request: { + account_ids: number[] + field: string + value: any +}): Promise<{ + 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 +} + +/** + * Bulk update multiple accounts + * @param accountIds - Array of account IDs + * @param updates - Fields to update + * @returns Success confirmation + */ +export async function bulkUpdate( + accountIds: number[], + updates: Record +): Promise<{ + 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/bulk-update', { + account_ids: accountIds, + ...updates, + }) + return data } /** @@ -219,8 +265,8 @@ export async function batchCreate(accounts: CreateAccountRequest[]): Promise<{ * @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 } /** @@ -230,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 } /** @@ -240,8 +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 +}): 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 = { @@ -263,6 +333,9 @@ export const accountsAPI = { generateAuthUrl, exchangeCode, batchCreate, -}; + 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 new file mode 100644 index 00000000..e12e0b8c --- /dev/null +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -0,0 +1,892 @@ + + + diff --git a/frontend/src/components/account/SyncFromCrsModal.vue b/frontend/src/components/account/SyncFromCrsModal.vue new file mode 100644 index 00000000..d1f75fc7 --- /dev/null +++ b/frontend/src/components/account/SyncFromCrsModal.vue @@ -0,0 +1,167 @@ + + + diff --git a/frontend/src/components/account/index.ts b/frontend/src/components/account/index.ts index 254c6720..d2e0493a 100644 --- a/frontend/src/components/account/index.ts +++ b/frontend/src/components/account/index.ts @@ -1,5 +1,6 @@ export { default as CreateAccountModal } from './CreateAccountModal.vue' export { default as EditAccountModal } from './EditAccountModal.vue' +export { default as BulkEditAccountModal } from './BulkEditAccountModal.vue' export { default as ReAuthAccountModal } from './ReAuthAccountModal.vue' export { default as OAuthAuthorizationFlow } from './OAuthAuthorizationFlow.vue' export { default as AccountStatusIndicator } from './AccountStatusIndicator.vue' @@ -8,3 +9,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..884b5fcc 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -17,11 +17,14 @@ export default { }, features: { unifiedGateway: 'Unified API Gateway', - unifiedGatewayDesc: 'Convert Claude subscriptions to API endpoints. Access AI capabilities through standard /v1/messages interface.', + unifiedGatewayDesc: + 'Convert Claude subscriptions to API endpoints. Access AI capabilities through standard /v1/messages interface.', multiAccount: 'Multi-Account Pool', - multiAccountDesc: 'Manage multiple upstream accounts with smart load balancing. Support OAuth and API Key authentication.', + multiAccountDesc: + 'Manage multiple upstream accounts with smart load balancing. Support OAuth and API Key authentication.', balanceQuota: 'Balance & Quota', - balanceQuotaDesc: 'Token-based billing with precise usage tracking. Manage quotas and recharge with redeem codes.', + balanceQuotaDesc: + 'Token-based billing with precise usage tracking. Manage quotas and recharge with redeem codes.', }, providers: { title: 'Supported Providers', @@ -235,7 +238,8 @@ export default { useKey: 'Use Key', useKeyModal: { title: 'Use API Key', - description: 'Add the following environment variables to your terminal profile or run directly in terminal to configure API access.', + description: + 'Add the following environment variables to your terminal profile or run directly in terminal to configure API access.', copy: 'Copy', copied: 'Copied', note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.', @@ -517,7 +521,8 @@ export default { failedToLoadApiKeys: 'Failed to load user API keys', deleteConfirm: "Are you sure you want to delete '{email}'? This action cannot be undone.", setAllowedGroups: 'Set Allowed Groups', - allowedGroupsHint: 'Select which standard groups this user can use. Subscription groups are managed separately.', + allowedGroupsHint: + 'Select which standard groups this user can use. Subscription groups are managed separately.', noStandardGroups: 'No standard groups available', allowAllGroups: 'Allow All Groups', allowAllGroupsHint: 'User can use any non-exclusive group', @@ -529,8 +534,10 @@ export default { depositAmount: 'Deposit Amount', withdrawAmount: 'Withdraw Amount', currentBalance: 'Current Balance', - depositNotesPlaceholder: 'e.g., New user registration bonus, promotional credit, compensation, etc.', - withdrawNotesPlaceholder: 'e.g., Service issue refund, incorrect charge reversal, account closure refund, etc.', + depositNotesPlaceholder: + 'e.g., New user registration bonus, promotional credit, compensation, etc.', + withdrawNotesPlaceholder: + 'e.g., Service issue refund, incorrect charge reversal, account closure refund, etc.', notesOptional: 'Notes are optional but helpful for record keeping', amountHint: 'Please enter a positive amount', newBalance: 'New Balance', @@ -597,12 +604,15 @@ export default { failedToCreate: 'Failed to create group', failedToUpdate: 'Failed to update group', failedToDelete: 'Failed to delete group', - deleteConfirm: "Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.", - deleteConfirmSubscription: "Are you sure you want to delete subscription group '{name}'? This will invalidate all API keys bound to this subscription and delete all related subscription records. This action cannot be undone.", + deleteConfirm: + "Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.", + deleteConfirmSubscription: + "Are you sure you want to delete subscription group '{name}'? This will invalidate all API keys bound to this subscription and delete all related subscription records. This action cannot be undone.", subscription: { title: 'Subscription Settings', type: 'Billing Type', - typeHint: 'Standard billing deducts from user balance. Subscription mode uses quota limits instead.', + typeHint: + 'Standard billing deducts from user balance. Subscription mode uses quota limits instead.', typeNotEditable: 'Billing type cannot be changed after group creation.', standard: 'Standard (Balance)', subscription: 'Subscription (Quota)', @@ -674,7 +684,8 @@ export default { failedToAssign: 'Failed to assign subscription', failedToExtend: 'Failed to extend subscription', failedToRevoke: 'Failed to revoke subscription', - revokeConfirm: "Are you sure you want to revoke the subscription for '{user}'? This action cannot be undone.", + revokeConfirm: + "Are you sure you want to revoke the subscription for '{user}'? This action cannot be undone.", }, // Accounts @@ -682,6 +693,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...', @@ -726,6 +756,32 @@ export default { tokenRefreshed: 'Token refreshed successfully', accountDeleted: 'Account deleted successfully', rateLimitCleared: 'Rate limit cleared successfully', + bulkActions: { + selected: '{count} account(s) selected', + selectCurrentPage: 'Select this page', + clear: 'Clear selection', + edit: 'Bulk Edit', + delete: 'Bulk Delete', + }, + bulkEdit: { + 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 keep existing value', + submit: 'Update Accounts', + updating: 'Updating...', + success: 'Updated {count} account(s)', + partialSuccess: 'Partially updated: {success} succeeded, {failed} failed', + failed: 'Bulk update failed', + noSelection: 'Please select accounts to edit', + noFieldsSelected: 'Select at least one field to update', + }, + bulkDeleteTitle: 'Bulk Delete Accounts', + bulkDeleteConfirm: 'Delete the selected {count} account(s)? This action cannot be undone.', + bulkDeleteSuccess: 'Deleted {count} account(s)', + bulkDeletePartial: 'Partially deleted: {success} succeeded, {failed} failed', + bulkDeleteFailed: 'Bulk delete failed', resetStatus: 'Reset Status', statusReset: 'Account status reset successfully', failedToResetStatus: 'Failed to reset account status', @@ -753,7 +809,8 @@ export default { modelWhitelist: 'Model Whitelist', modelMapping: 'Model Mapping', selectAllowedModels: 'Select allowed models. Leave empty to support all models.', - mapRequestModels: 'Map request models to actual models. Left is the requested model, right is the actual model sent to API.', + mapRequestModels: + 'Map request models to actual models. Left is the requested model, right is the actual model sent to API.', selectedModels: 'Selected {count} model(s)', supportsAllModels: '(supports all models)', requestModel: 'Request model', @@ -762,14 +819,16 @@ export default { mappingExists: 'Mapping for {model} already exists', customErrorCodes: 'Custom Error Codes', customErrorCodesHint: 'Only stop scheduling for selected error codes', - customErrorCodesWarning: 'Only selected error codes will stop scheduling. Other errors will return 500.', + customErrorCodesWarning: + 'Only selected error codes will stop scheduling. Other errors will return 500.', selectedErrorCodes: 'Selected', noneSelectedUsesDefault: 'None selected (uses default policy)', enterErrorCode: 'Enter error code (100-599)', invalidErrorCode: 'Please enter a valid HTTP error code (100-599)', errorCodeExists: 'This error code is already selected', interceptWarmupRequests: 'Intercept Warmup Requests', - interceptWarmupRequestsDesc: 'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens', + interceptWarmupRequestsDesc: + 'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens', proxy: 'Proxy', noProxy: 'No Proxy', concurrency: 'Concurrency', @@ -792,11 +851,13 @@ export default { authMethod: 'Authorization Method', manualAuth: 'Manual Authorization', cookieAutoAuth: 'Cookie Auto-Auth', - cookieAutoAuthDesc: 'Use claude.ai sessionKey to automatically complete OAuth authorization without manually opening browser.', + cookieAutoAuthDesc: + 'Use claude.ai sessionKey to automatically complete OAuth authorization without manually opening browser.', sessionKey: 'sessionKey', keysCount: '{count} keys', batchCreateAccounts: 'Will batch create {count} accounts', - sessionKeyPlaceholder: 'One sessionKey per line, e.g.:\nsk-ant-sid01-xxxxx...\nsk-ant-sid01-yyyyy...', + sessionKeyPlaceholder: + 'One sessionKey per line, e.g.:\nsk-ant-sid01-xxxxx...\nsk-ant-sid01-yyyyy...', sessionKeyPlaceholderSingle: 'sk-ant-sid01-xxxxx...', howToGetSessionKey: 'How to get sessionKey', step1: 'Login to claude.ai in your browser', @@ -814,10 +875,13 @@ export default { generating: 'Generating...', regenerate: 'Regenerate', step2OpenUrl: 'Open the URL in your browser and complete authorization', - openUrlDesc: 'Open the authorization URL in a new tab, log in to your Claude account and authorize.', - proxyWarning: 'Note: If you configured a proxy, make sure your browser uses the same proxy to access the authorization page.', + openUrlDesc: + 'Open the authorization URL in a new tab, log in to your Claude account and authorize.', + proxyWarning: + 'Note: If you configured a proxy, make sure your browser uses the same proxy to access the authorization page.', step3EnterCode: 'Enter the Authorization Code', - authCodeDesc: 'After authorization is complete, the page will display an Authorization Code. Copy and paste it below:', + authCodeDesc: + 'After authorization is complete, the page will display an Authorization Code. Copy and paste it below:', authCode: 'Authorization Code', authCodePlaceholder: 'Paste the Authorization Code from Claude page...', authCodeHint: 'Paste the Authorization Code copied from the Claude page', @@ -835,13 +899,18 @@ export default { step1GenerateUrl: 'Click the button below to generate the authorization URL', generateAuthUrl: 'Generate Auth URL', step2OpenUrl: 'Open the URL in your browser and complete authorization', - openUrlDesc: 'Open the authorization URL in a new tab, log in to your OpenAI account and authorize.', - importantNotice: 'Important: The page may take a while to load after authorization. Please wait patiently. When the browser address bar changes to http://localhost..., the authorization is complete.', + openUrlDesc: + 'Open the authorization URL in a new tab, log in to your OpenAI account and authorize.', + importantNotice: + 'Important: The page may take a while to load after authorization. Please wait patiently. When the browser address bar changes to http://localhost..., the authorization is complete.', step3EnterCode: 'Enter Authorization URL or Code', - authCodeDesc: 'After authorization is complete, when the page URL becomes http://localhost:xxx/auth/callback?code=...:', + authCodeDesc: + 'After authorization is complete, when the page URL becomes http://localhost:xxx/auth/callback?code=...:', authCode: 'Authorization URL or Code', - authCodePlaceholder: 'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value', - authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect', + authCodePlaceholder: + 'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value', + authCodeHint: + 'You can copy the entire URL or just the code parameter value, the system will auto-detect', }, }, // Re-Auth Modal @@ -941,8 +1010,10 @@ export default { standardAdd: 'Standard Add', batchAdd: 'Quick Add', batchInput: 'Proxy List', - batchInputPlaceholder: "Enter one proxy per line in the following formats:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443", - batchInputHint: "Supports http, https, socks5 protocols. Format: protocol://[user:pass{'@'}]host:port", + batchInputPlaceholder: + "Enter one proxy per line in the following formats:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443", + batchInputHint: + "Supports http, https, socks5 protocols. Format: protocol://[user:pass{'@'}]host:port", parsedCount: '{count} valid', invalidCount: '{count} invalid', duplicateCount: '{count} duplicate', @@ -965,7 +1036,8 @@ export default { failedToUpdate: 'Failed to update proxy', failedToDelete: 'Failed to delete proxy', failedToTest: 'Failed to test proxy', - deleteConfirm: "Are you sure you want to delete '{name}'? Accounts using this proxy will have their proxy removed.", + deleteConfirm: + "Are you sure you want to delete '{name}'? Accounts using this proxy will have their proxy removed.", }, // Redeem Codes @@ -994,8 +1066,10 @@ export default { exportCsv: 'Export CSV', deleteAllUnused: 'Delete All Unused Codes', deleteCode: 'Delete Redeem Code', - deleteCodeConfirm: 'Are you sure you want to delete this redeem code? This action cannot be undone.', - deleteAllUnusedConfirm: 'Are you sure you want to delete all unused (active) redeem codes? This action cannot be undone.', + deleteCodeConfirm: + 'Are you sure you want to delete this redeem code? This action cannot be undone.', + deleteAllUnusedConfirm: + 'Are you sure you want to delete all unused (active) redeem codes? This action cannot be undone.', deleteAll: 'Delete All', generateCodesTitle: 'Generate Redeem Codes', generatedSuccessfully: 'Generated Successfully', @@ -1075,7 +1149,8 @@ export default { siteSubtitle: 'Site Subtitle', siteSubtitleHint: 'Displayed on login and register pages', apiBaseUrl: 'API Base URL', - apiBaseUrlHint: 'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.', + apiBaseUrlHint: + 'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.', contactInfo: 'Contact Info', contactInfoPlaceholder: 'e.g., QQ: 123456789', contactInfoHint: 'Customer support contact info, displayed on redeem page, profile, etc.', @@ -1125,7 +1200,8 @@ export default { create: 'Create Key', creating: 'Creating...', regenerateConfirm: 'Are you sure? The current key will be immediately invalidated.', - deleteConfirm: 'Are you sure you want to delete the admin API key? External integrations will stop working.', + deleteConfirm: + 'Are you sure you want to delete the admin API key? External integrations will stop working.', keyGenerated: 'New admin API key generated', keyDeleted: 'Admin API key deleted', copyKey: 'Copy Key', @@ -1191,7 +1267,8 @@ export default { title: 'My Subscriptions', description: 'View your subscription plans and usage', noActiveSubscriptions: 'No Active Subscriptions', - noActiveSubscriptionsDesc: 'You don\'t have any active subscriptions. Contact administrator to get one.', + noActiveSubscriptionsDesc: + "You don't have any active subscriptions. Contact administrator to get one.", status: { active: 'Active', expired: 'Expired', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 4c688365..239a0661 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -620,7 +620,8 @@ export default { editGroup: '编辑分组', deleteGroup: '删除分组', deleteConfirm: "确定要删除分组 '{name}' 吗?所有关联的 API 密钥将不再属于任何分组。", - deleteConfirmSubscription: "确定要删除订阅分组 '{name}' 吗?此操作会让所有绑定此订阅的用户的 API Key 失效,并删除所有相关的订阅记录。此操作无法撤销。", + deleteConfirmSubscription: + "确定要删除订阅分组 '{name}' 吗?此操作会让所有绑定此订阅的用户的 API Key 失效,并删除所有相关的订阅记录。此操作无法撤销。", columns: { name: '名称', platform: '平台', @@ -780,6 +781,24 @@ 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}' 吗?", @@ -859,6 +878,31 @@ export default { accountCreatedSuccess: '账号添加成功', accountUpdatedSuccess: '账号更新成功', accountDeletedSuccess: '账号删除成功', + bulkActions: { + selected: '已选择 {count} 个账号', + selectCurrentPage: '本页全选', + clear: '清除选择', + edit: '批量编辑账号', + delete: '批量删除', + }, + bulkEdit: { + title: '批量编辑账号', + selectionInfo: '已选择 {count} 个账号。只更新您勾选或填写的字段,未勾选的字段保持不变。', + baseUrlPlaceholder: 'https://api.anthropic.com 或 https://api.openai.com', + baseUrlNotice: '仅适用于 API Key 账号,留空则不修改', + submit: '批量更新', + updating: '更新中...', + success: '成功更新 {count} 个账号', + partialSuccess: '部分更新成功:成功 {success} 个,失败 {failed} 个', + failed: '批量更新失败', + noSelection: '请选择要编辑的账号', + noFieldsSelected: '请至少选择一个要更新的字段', + }, + bulkDeleteTitle: '批量删除账号', + bulkDeleteConfirm: '确定要删除选中的 {count} 个账号吗?此操作无法撤销。', + bulkDeleteSuccess: '成功删除 {count} 个账号', + bulkDeletePartial: '部分删除成功:成功 {success} 个,失败 {failed} 个', + bulkDeleteFailed: '批量删除失败', resetStatus: '重置状态', statusReset: '账号状态已重置', failedToResetStatus: '重置账号状态失败', @@ -931,7 +975,8 @@ export default { sessionKey: 'sessionKey', keysCount: '{count} 个密钥', batchCreateAccounts: '将批量创建 {count} 个账号', - sessionKeyPlaceholder: '每行一个 sessionKey,例如:\nsk-ant-sid01-xxxxx...\nsk-ant-sid01-yyyyy...', + sessionKeyPlaceholder: + '每行一个 sessionKey,例如:\nsk-ant-sid01-xxxxx...\nsk-ant-sid01-yyyyy...', sessionKeyPlaceholderSingle: 'sk-ant-sid01-xxxxx...', howToGetSessionKey: '如何获取 sessionKey', step1: '在浏览器中登录 claude.ai', @@ -950,7 +995,8 @@ export default { regenerate: '重新生成', step2OpenUrl: '在浏览器中打开 URL 并完成授权', openUrlDesc: '在新标签页中打开授权 URL,登录您的 Claude 账号并授权。', - proxyWarning: '注意:如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。', + proxyWarning: + '注意:如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。', step3EnterCode: '输入授权码', authCodeDesc: '授权完成后,页面会显示一个 授权码。复制并粘贴到下方:', authCode: '授权码', @@ -971,11 +1017,14 @@ export default { generateAuthUrl: '生成授权链接', step2OpenUrl: '在浏览器中打开链接并完成授权', openUrlDesc: '请在新标签页中打开授权链接,登录您的 OpenAI 账户并授权。', - importantNotice: '重要提示:授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 http://localhost... 开头时,表示授权已完成。', + importantNotice: + '重要提示:授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 http://localhost... 开头时,表示授权已完成。', step3EnterCode: '输入授权链接或 Code', - authCodeDesc: '授权完成后,当页面地址变为 http://localhost:xxx/auth/callback?code=... 时:', + authCodeDesc: + '授权完成后,当页面地址变为 http://localhost:xxx/auth/callback?code=... 时:', authCode: '授权链接或 Code', - authCodePlaceholder: '方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值', + authCodePlaceholder: + '方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值', authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别', }, }, @@ -1111,7 +1160,8 @@ export default { standardAdd: '标准添加', batchAdd: '快捷添加', batchInput: '代理列表', - batchInputPlaceholder: "每行输入一个代理,支持以下格式:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443", + batchInputPlaceholder: + "每行输入一个代理,支持以下格式:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443", batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码{'@'}]主机:端口", parsedCount: '有效 {count} 个', invalidCount: '无效 {count} 个', diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index b9903a38..664e0c15 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -16,6 +16,15 @@ + + + + +
+ + +
+ + +
+ + @@ -315,6 +378,32 @@ @confirm="confirmDelete" @cancel="showDeleteDialog = false" /> + + + + + + @@ -331,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 } 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' @@ -345,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 }, @@ -402,14 +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 => { @@ -480,6 +582,11 @@ const handlePageChange = (page: number) => { loadAccounts() } +const handleCrsSynced = () => { + showCrsSyncModal.value = false + loadAccounts() +} + // Edit modal const handleEdit = (account: Account) => { editingAccount.value = account @@ -535,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 { @@ -608,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()