Merge PR #24: feat: 添加账户同步与批量编辑功能
- 添加从 CRS 同步账户功能 (Claude OAuth/API Key, OpenAI OAuth/Responses) - 添加批量编辑账户功能,支持 JSONB 字段智能合并 - 新增 CRSSyncService、BulkUpdate 仓储方法 - 前端新增 SyncFromCrsModal 和 BulkEditAccountModal 组件
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -92,6 +92,13 @@ backend/internal/web/dist/*
|
|||||||
# 后端运行时缓存数据
|
# 后端运行时缓存数据
|
||||||
backend/data/
|
backend/data/
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# 本地配置文件(包含敏感信息)
|
||||||
|
# ===================
|
||||||
|
backend/config.yaml
|
||||||
|
deploy/config.yaml
|
||||||
|
backend/.installed
|
||||||
|
|
||||||
# ===================
|
# ===================
|
||||||
# 其他
|
# 其他
|
||||||
# ===================
|
# ===================
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream)
|
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream)
|
||||||
concurrencyCache := repository.NewConcurrencyCache(client)
|
concurrencyCache := repository.NewConcurrencyCache(client)
|
||||||
concurrencyService := service.NewConcurrencyService(concurrencyCache)
|
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)
|
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
||||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
|
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
|
||||||
proxyHandler := admin.NewProxyHandler(adminService)
|
proxyHandler := admin.NewProxyHandler(adminService)
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -34,10 +34,20 @@ type AccountHandler struct {
|
|||||||
accountUsageService *service.AccountUsageService
|
accountUsageService *service.AccountUsageService
|
||||||
accountTestService *service.AccountTestService
|
accountTestService *service.AccountTestService
|
||||||
concurrencyService *service.ConcurrencyService
|
concurrencyService *service.ConcurrencyService
|
||||||
|
crsSyncService *service.CRSSyncService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAccountHandler creates a new admin account handler
|
// 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{
|
return &AccountHandler{
|
||||||
adminService: adminService,
|
adminService: adminService,
|
||||||
oauthService: oauthService,
|
oauthService: oauthService,
|
||||||
@@ -46,6 +56,7 @@ func NewAccountHandler(adminService service.AdminService, oauthService *service.
|
|||||||
accountUsageService: accountUsageService,
|
accountUsageService: accountUsageService,
|
||||||
accountTestService: accountTestService,
|
accountTestService: accountTestService,
|
||||||
concurrencyService: concurrencyService,
|
concurrencyService: concurrencyService,
|
||||||
|
crsSyncService: crsSyncService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +87,19 @@ type UpdateAccountRequest struct {
|
|||||||
GroupIDs *[]int64 `json:"group_ids"`
|
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
|
// AccountWithConcurrency extends Account with real-time concurrency info
|
||||||
type AccountWithConcurrency struct {
|
type AccountWithConcurrency struct {
|
||||||
*model.Account
|
*model.Account
|
||||||
@@ -224,6 +248,13 @@ type TestAccountRequest struct {
|
|||||||
ModelID string `json:"model_id"`
|
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
|
// Test handles testing account connectivity with SSE streaming
|
||||||
// POST /api/v1/admin/accounts/:id/test
|
// POST /api/v1/admin/accounts/:id/test
|
||||||
func (h *AccountHandler) Test(c *gin.Context) {
|
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
|
// Refresh handles refreshing account credentials
|
||||||
// POST /api/v1/admin/accounts/:id/refresh
|
// POST /api/v1/admin/accounts/:id/refresh
|
||||||
func (h *AccountHandler) Refresh(c *gin.Context) {
|
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 ==========
|
// ========== OAuth Handlers ==========
|
||||||
|
|
||||||
// GenerateAuthURLRequest represents the request for generating auth URL
|
// GenerateAuthURLRequest represents the request for generating auth URL
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AccountRepository struct {
|
type AccountRepository struct {
|
||||||
@@ -39,6 +42,22 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int64) (*model.Accou
|
|||||||
return &account, nil
|
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 {
|
func (r *AccountRepository) Update(ctx context.Context, account *model.Account) error {
|
||||||
return r.db.WithContext(ctx).Save(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).
|
return r.db.WithContext(ctx).Model(&model.Account{}).Where("id = ?", id).
|
||||||
Update("extra", account.Extra).Error
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep
|
|||||||
accounts.GET("", h.Admin.Account.List)
|
accounts.GET("", h.Admin.Account.List)
|
||||||
accounts.GET("/:id", h.Admin.Account.GetByID)
|
accounts.GET("/:id", h.Admin.Account.GetByID)
|
||||||
accounts.POST("", h.Admin.Account.Create)
|
accounts.POST("", h.Admin.Account.Create)
|
||||||
|
accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS)
|
||||||
accounts.PUT("/:id", h.Admin.Account.Update)
|
accounts.PUT("/:id", h.Admin.Account.Update)
|
||||||
accounts.DELETE("/:id", h.Admin.Account.Delete)
|
accounts.DELETE("/:id", h.Admin.Account.Delete)
|
||||||
accounts.POST("/:id/test", h.Admin.Account.Test)
|
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.POST("/:id/schedulable", h.Admin.Account.SetSchedulable)
|
||||||
accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels)
|
accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels)
|
||||||
accounts.POST("/batch", h.Admin.Account.BatchCreate)
|
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
|
// Claude OAuth routes
|
||||||
accounts.POST("/generate-auth-url", h.Admin.OAuth.GenerateAuthURL)
|
accounts.POST("/generate-auth-url", h.Admin.OAuth.GenerateAuthURL)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type AdminService interface {
|
|||||||
RefreshAccountCredentials(ctx context.Context, id int64) (*model.Account, error)
|
RefreshAccountCredentials(ctx context.Context, id int64) (*model.Account, error)
|
||||||
ClearAccountError(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)
|
SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*model.Account, error)
|
||||||
|
BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error)
|
||||||
|
|
||||||
// Proxy management
|
// Proxy management
|
||||||
ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string) ([]model.Proxy, int64, error)
|
ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string) ([]model.Proxy, int64, error)
|
||||||
@@ -140,6 +141,33 @@ type UpdateAccountInput struct {
|
|||||||
GroupIDs *[]int64
|
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 {
|
type CreateProxyInput struct {
|
||||||
Name string
|
Name string
|
||||||
Protocol string
|
Protocol string
|
||||||
@@ -694,6 +722,65 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
|||||||
return account, nil
|
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 {
|
func (s *adminServiceImpl) DeleteAccount(ctx context.Context, id int64) error {
|
||||||
return s.accountRepo.Delete(ctx, id)
|
return s.accountRepo.Delete(ctx, id)
|
||||||
}
|
}
|
||||||
|
|||||||
843
backend/internal/service/crs_sync_service.go
Normal file
843
backend/internal/service/crs_sync_service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ import (
|
|||||||
type AccountRepository interface {
|
type AccountRepository interface {
|
||||||
Create(ctx context.Context, account *model.Account) error
|
Create(ctx context.Context, account *model.Account) error
|
||||||
GetByID(ctx context.Context, id int64) (*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
|
Update(ctx context.Context, account *model.Account) error
|
||||||
Delete(ctx context.Context, id int64) error
|
Delete(ctx context.Context, id int64) error
|
||||||
|
|
||||||
@@ -35,4 +38,17 @@ type AccountRepository interface {
|
|||||||
ClearRateLimit(ctx context.Context, id int64) error
|
ClearRateLimit(ctx context.Context, id int64) error
|
||||||
UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) 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
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
NewSubscriptionService,
|
NewSubscriptionService,
|
||||||
NewConcurrencyService,
|
NewConcurrencyService,
|
||||||
NewIdentityService,
|
NewIdentityService,
|
||||||
|
NewCRSSyncService,
|
||||||
ProvideUpdateService,
|
ProvideUpdateService,
|
||||||
ProvideTokenRefreshService,
|
ProvideTokenRefreshService,
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ services:
|
|||||||
# Sub2API Application
|
# Sub2API Application
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
sub2api:
|
sub2api:
|
||||||
image: weishaw/sub2api:latest
|
image: sub2api:latest
|
||||||
container_name: sub2api
|
container_name: sub2api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -114,6 +114,8 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
ports:
|
||||||
|
- 5433:5432
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Redis Cache
|
# Redis Cache
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Handles AI platform account management for administrators
|
* Handles AI platform account management for administrators
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiClient } from '../client';
|
import { apiClient } from '../client'
|
||||||
import type {
|
import type {
|
||||||
Account,
|
Account,
|
||||||
CreateAccountRequest,
|
CreateAccountRequest,
|
||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
WindowStats,
|
WindowStats,
|
||||||
ClaudeModel,
|
ClaudeModel,
|
||||||
AccountUsageStatsResponse,
|
AccountUsageStatsResponse,
|
||||||
} from '@/types';
|
} from '@/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all accounts with pagination
|
* List all accounts with pagination
|
||||||
@@ -26,10 +26,10 @@ export async function list(
|
|||||||
page: number = 1,
|
page: number = 1,
|
||||||
pageSize: number = 20,
|
pageSize: number = 20,
|
||||||
filters?: {
|
filters?: {
|
||||||
platform?: string;
|
platform?: string
|
||||||
type?: string;
|
type?: string
|
||||||
status?: string;
|
status?: string
|
||||||
search?: string;
|
search?: string
|
||||||
}
|
}
|
||||||
): Promise<PaginatedResponse<Account>> {
|
): Promise<PaginatedResponse<Account>> {
|
||||||
const { data } = await apiClient.get<PaginatedResponse<Account>>('/admin/accounts', {
|
const { data } = await apiClient.get<PaginatedResponse<Account>>('/admin/accounts', {
|
||||||
@@ -38,8 +38,8 @@ export async function list(
|
|||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
...filters,
|
...filters,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
return data;
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,8 +48,8 @@ export async function list(
|
|||||||
* @returns Account details
|
* @returns Account details
|
||||||
*/
|
*/
|
||||||
export async function getById(id: number): Promise<Account> {
|
export async function getById(id: number): Promise<Account> {
|
||||||
const { data } = await apiClient.get<Account>(`/admin/accounts/${id}`);
|
const { data } = await apiClient.get<Account>(`/admin/accounts/${id}`)
|
||||||
return data;
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,8 +58,8 @@ export async function getById(id: number): Promise<Account> {
|
|||||||
* @returns Created account
|
* @returns Created account
|
||||||
*/
|
*/
|
||||||
export async function create(accountData: CreateAccountRequest): Promise<Account> {
|
export async function create(accountData: CreateAccountRequest): Promise<Account> {
|
||||||
const { data } = await apiClient.post<Account>('/admin/accounts', accountData);
|
const { data } = await apiClient.post<Account>('/admin/accounts', accountData)
|
||||||
return data;
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,8 +69,8 @@ export async function create(accountData: CreateAccountRequest): Promise<Account
|
|||||||
* @returns Updated account
|
* @returns Updated account
|
||||||
*/
|
*/
|
||||||
export async function update(id: number, updates: UpdateAccountRequest): Promise<Account> {
|
export async function update(id: number, updates: UpdateAccountRequest): Promise<Account> {
|
||||||
const { data } = await apiClient.put<Account>(`/admin/accounts/${id}`, updates);
|
const { data } = await apiClient.put<Account>(`/admin/accounts/${id}`, updates)
|
||||||
return data;
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,8 +79,8 @@ export async function update(id: number, updates: UpdateAccountRequest): Promise
|
|||||||
* @returns Success confirmation
|
* @returns Success confirmation
|
||||||
*/
|
*/
|
||||||
export async function deleteAccount(id: number): Promise<{ message: string }> {
|
export async function deleteAccount(id: number): Promise<{ message: string }> {
|
||||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/accounts/${id}`);
|
const { data } = await apiClient.delete<{ message: string }>(`/admin/accounts/${id}`)
|
||||||
return data;
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,11 +89,8 @@ export async function deleteAccount(id: number): Promise<{ message: string }> {
|
|||||||
* @param status - New status
|
* @param status - New status
|
||||||
* @returns Updated account
|
* @returns Updated account
|
||||||
*/
|
*/
|
||||||
export async function toggleStatus(
|
export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise<Account> {
|
||||||
id: number,
|
return update(id, { status })
|
||||||
status: 'active' | 'inactive'
|
|
||||||
): Promise<Account> {
|
|
||||||
return update(id, { status });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,16 +99,16 @@ export async function toggleStatus(
|
|||||||
* @returns Test result
|
* @returns Test result
|
||||||
*/
|
*/
|
||||||
export async function testAccount(id: number): Promise<{
|
export async function testAccount(id: number): Promise<{
|
||||||
success: boolean;
|
success: boolean
|
||||||
message: string;
|
message: string
|
||||||
latency_ms?: number;
|
latency_ms?: number
|
||||||
}> {
|
}> {
|
||||||
const { data } = await apiClient.post<{
|
const { data } = await apiClient.post<{
|
||||||
success: boolean;
|
success: boolean
|
||||||
message: string;
|
message: string
|
||||||
latency_ms?: number;
|
latency_ms?: number
|
||||||
}>(`/admin/accounts/${id}/test`);
|
}>(`/admin/accounts/${id}/test`)
|
||||||
return data;
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,8 +117,8 @@ export async function testAccount(id: number): Promise<{
|
|||||||
* @returns Updated account
|
* @returns Updated account
|
||||||
*/
|
*/
|
||||||
export async function refreshCredentials(id: number): Promise<Account> {
|
export async function refreshCredentials(id: number): Promise<Account> {
|
||||||
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/refresh`);
|
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/refresh`)
|
||||||
return data;
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,8 +130,8 @@ export async function refreshCredentials(id: number): Promise<Account> {
|
|||||||
export async function getStats(id: number, days: number = 30): Promise<AccountUsageStatsResponse> {
|
export async function getStats(id: number, days: number = 30): Promise<AccountUsageStatsResponse> {
|
||||||
const { data } = await apiClient.get<AccountUsageStatsResponse>(`/admin/accounts/${id}/stats`, {
|
const { data } = await apiClient.get<AccountUsageStatsResponse>(`/admin/accounts/${id}/stats`, {
|
||||||
params: { days },
|
params: { days },
|
||||||
});
|
})
|
||||||
return data;
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,8 +140,8 @@ export async function getStats(id: number, days: number = 30): Promise<AccountUs
|
|||||||
* @returns Updated account
|
* @returns Updated account
|
||||||
*/
|
*/
|
||||||
export async function clearError(id: number): Promise<Account> {
|
export async function clearError(id: number): Promise<Account> {
|
||||||
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/clear-error`);
|
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/clear-error`)
|
||||||
return data;
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,8 +150,8 @@ export async function clearError(id: number): Promise<Account> {
|
|||||||
* @returns Account usage info
|
* @returns Account usage info
|
||||||
*/
|
*/
|
||||||
export async function getUsage(id: number): Promise<AccountUsageInfo> {
|
export async function getUsage(id: number): Promise<AccountUsageInfo> {
|
||||||
const { data } = await apiClient.get<AccountUsageInfo>(`/admin/accounts/${id}/usage`);
|
const { data } = await apiClient.get<AccountUsageInfo>(`/admin/accounts/${id}/usage`)
|
||||||
return data;
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,8 +160,10 @@ export async function getUsage(id: number): Promise<AccountUsageInfo> {
|
|||||||
* @returns Success confirmation
|
* @returns Success confirmation
|
||||||
*/
|
*/
|
||||||
export async function clearRateLimit(id: number): Promise<{ message: string }> {
|
export async function clearRateLimit(id: number): Promise<{ message: string }> {
|
||||||
const { data } = await apiClient.post<{ message: string }>(`/admin/accounts/${id}/clear-rate-limit`);
|
const { data } = await apiClient.post<{ message: string }>(
|
||||||
return data;
|
`/admin/accounts/${id}/clear-rate-limit`
|
||||||
|
)
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -177,8 +176,8 @@ export async function generateAuthUrl(
|
|||||||
endpoint: string,
|
endpoint: string,
|
||||||
config: { proxy_id?: number }
|
config: { proxy_id?: number }
|
||||||
): Promise<{ auth_url: string; session_id: string }> {
|
): Promise<{ auth_url: string; session_id: string }> {
|
||||||
const { data } = await apiClient.post<{ auth_url: string; session_id: string }>(endpoint, config);
|
const { data } = await apiClient.post<{ auth_url: string; session_id: string }>(endpoint, config)
|
||||||
return data;
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,8 +190,8 @@ export async function exchangeCode(
|
|||||||
endpoint: string,
|
endpoint: string,
|
||||||
exchangeData: { session_id: string; code: string; proxy_id?: number }
|
exchangeData: { session_id: string; code: string; proxy_id?: number }
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, exchangeData);
|
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, exchangeData)
|
||||||
return data;
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -201,16 +200,63 @@ export async function exchangeCode(
|
|||||||
* @returns Results of batch creation
|
* @returns Results of batch creation
|
||||||
*/
|
*/
|
||||||
export async function batchCreate(accounts: CreateAccountRequest[]): Promise<{
|
export async function batchCreate(accounts: CreateAccountRequest[]): Promise<{
|
||||||
success: number;
|
success: number
|
||||||
failed: number;
|
failed: number
|
||||||
results: Array<{ success: boolean; account?: Account; error?: string }>;
|
results: Array<{ success: boolean; account?: Account; error?: string }>
|
||||||
}> {
|
}> {
|
||||||
const { data } = await apiClient.post<{
|
const { data } = await apiClient.post<{
|
||||||
success: number;
|
success: number
|
||||||
failed: number;
|
failed: number
|
||||||
results: Array<{ success: boolean; account?: Account; error?: string }>;
|
results: Array<{ success: boolean; account?: Account; error?: string }>
|
||||||
}>('/admin/accounts/batch', { accounts });
|
}>('/admin/accounts/batch', { accounts })
|
||||||
return data;
|
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<string, unknown>
|
||||||
|
): 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)
|
* @returns Today's stats (requests, tokens, cost)
|
||||||
*/
|
*/
|
||||||
export async function getTodayStats(id: number): Promise<WindowStats> {
|
export async function getTodayStats(id: number): Promise<WindowStats> {
|
||||||
const { data } = await apiClient.get<WindowStats>(`/admin/accounts/${id}/today-stats`);
|
const { data } = await apiClient.get<WindowStats>(`/admin/accounts/${id}/today-stats`)
|
||||||
return data;
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -230,8 +276,10 @@ export async function getTodayStats(id: number): Promise<WindowStats> {
|
|||||||
* @returns Updated account
|
* @returns Updated account
|
||||||
*/
|
*/
|
||||||
export async function setSchedulable(id: number, schedulable: boolean): Promise<Account> {
|
export async function setSchedulable(id: number, schedulable: boolean): Promise<Account> {
|
||||||
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/schedulable`, { schedulable });
|
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/schedulable`, {
|
||||||
return data;
|
schedulable,
|
||||||
|
})
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -240,8 +288,30 @@ export async function setSchedulable(id: number, schedulable: boolean): Promise<
|
|||||||
* @returns List of available models for this account
|
* @returns List of available models for this account
|
||||||
*/
|
*/
|
||||||
export async function getAvailableModels(id: number): Promise<ClaudeModel[]> {
|
export async function getAvailableModels(id: number): Promise<ClaudeModel[]> {
|
||||||
const { data } = await apiClient.get<ClaudeModel[]>(`/admin/accounts/${id}/models`);
|
const { data } = await apiClient.get<ClaudeModel[]>(`/admin/accounts/${id}/models`)
|
||||||
return data;
|
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 = {
|
export const accountsAPI = {
|
||||||
@@ -263,6 +333,9 @@ export const accountsAPI = {
|
|||||||
generateAuthUrl,
|
generateAuthUrl,
|
||||||
exchangeCode,
|
exchangeCode,
|
||||||
batchCreate,
|
batchCreate,
|
||||||
};
|
batchUpdateCredentials,
|
||||||
|
bulkUpdate,
|
||||||
|
syncFromCrs,
|
||||||
|
}
|
||||||
|
|
||||||
export default accountsAPI;
|
export default accountsAPI
|
||||||
|
|||||||
892
frontend/src/components/account/BulkEditAccountModal.vue
Normal file
892
frontend/src/components/account/BulkEditAccountModal.vue
Normal file
@@ -0,0 +1,892 @@
|
|||||||
|
<template>
|
||||||
|
<Modal :show="show" :title="t('admin.accounts.bulkEdit.title')" size="lg" @close="handleClose">
|
||||||
|
<form class="space-y-5" @submit.prevent="handleSubmit">
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4">
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||||
|
<svg class="w-5 h-5 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.bulkEdit.selectionInfo', { count: accountIds.length }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Base URL (API Key only) -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.baseUrl') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="enableBaseUrl"
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="baseUrl"
|
||||||
|
type="text"
|
||||||
|
:disabled="!enableBaseUrl"
|
||||||
|
class="input"
|
||||||
|
:class="!enableBaseUrl && 'opacity-50 cursor-not-allowed'"
|
||||||
|
:placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')"
|
||||||
|
/>
|
||||||
|
<p class="input-hint">
|
||||||
|
{{ t('admin.accounts.bulkEdit.baseUrlNotice') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model restriction -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="enableModelRestriction"
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="!enableModelRestriction && 'opacity-50 pointer-events-none'">
|
||||||
|
<!-- Mode Toggle -->
|
||||||
|
<div class="flex gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||||
|
modelRestrictionMode === 'whitelist'
|
||||||
|
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500',
|
||||||
|
]"
|
||||||
|
@click="modelRestrictionMode = 'whitelist'"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 inline mr-1.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.modelWhitelist') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||||
|
modelRestrictionMode === 'mapping'
|
||||||
|
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500',
|
||||||
|
]"
|
||||||
|
@click="modelRestrictionMode = 'mapping'"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 inline mr-1.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.modelMapping') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Whitelist Mode -->
|
||||||
|
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||||
|
<div class="mb-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-3">
|
||||||
|
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 inline mr-1"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.selectAllowedModels') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model Checkbox List -->
|
||||||
|
<div class="grid grid-cols-2 gap-2 mb-3">
|
||||||
|
<label
|
||||||
|
v-for="model in allModels"
|
||||||
|
:key="model.value"
|
||||||
|
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
|
||||||
|
:class="
|
||||||
|
allowedModels.includes(model.value)
|
||||||
|
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||||
|
: 'border-gray-200'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="allowedModels"
|
||||||
|
type="checkbox"
|
||||||
|
:value="model.value"
|
||||||
|
class="mr-2 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ model.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||||
|
<span v-if="allowedModels.length === 0">{{
|
||||||
|
t('admin.accounts.supportsAllModels')
|
||||||
|
}}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mapping Mode -->
|
||||||
|
<div v-else>
|
||||||
|
<div class="mb-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 p-3">
|
||||||
|
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 inline mr-1"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.mapRequestModels') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model Mapping List -->
|
||||||
|
<div v-if="modelMappings.length > 0" class="space-y-2 mb-3">
|
||||||
|
<div
|
||||||
|
v-for="(mapping, index) in modelMappings"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="mapping.from"
|
||||||
|
type="text"
|
||||||
|
class="input flex-1"
|
||||||
|
:placeholder="t('admin.accounts.requestModel')"
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 text-gray-400 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M14 5l7 7m0 0l-7 7m7-7H3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
v-model="mapping.to"
|
||||||
|
type="text"
|
||||||
|
class="input flex-1"
|
||||||
|
:placeholder="t('admin.accounts.actualModel')"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||||
|
@click="removeModelMapping(index)"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded-lg border-2 border-dashed border-gray-300 dark:border-dark-500 px-4 py-2 text-gray-600 dark:text-gray-400 transition-colors hover:border-gray-400 hover:text-gray-700 dark:hover:border-dark-400 dark:hover:text-gray-300 mb-3"
|
||||||
|
@click="addModelMapping"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 inline mr-1"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.addMapping') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Quick Add Buttons -->
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="preset in presetMappings"
|
||||||
|
:key="preset.label"
|
||||||
|
type="button"
|
||||||
|
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||||||
|
@click="addPresetMapping(preset.from, preset.to)"
|
||||||
|
>
|
||||||
|
+ {{ preset.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom error codes -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{{ t('admin.accounts.customErrorCodesHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="enableCustomErrorCodes"
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="enableCustomErrorCodes" class="space-y-3">
|
||||||
|
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/20 p-3">
|
||||||
|
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 inline mr-1"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.customErrorCodesWarning') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Code Buttons -->
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="code in commonErrorCodes"
|
||||||
|
:key="code.value"
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
|
||||||
|
selectedErrorCodes.includes(code.value)
|
||||||
|
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 ring-1 ring-red-500'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500',
|
||||||
|
]"
|
||||||
|
@click="toggleErrorCode(code.value)"
|
||||||
|
>
|
||||||
|
{{ code.value }} {{ code.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual input -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="customErrorCodeInput"
|
||||||
|
type="number"
|
||||||
|
min="100"
|
||||||
|
max="599"
|
||||||
|
class="input flex-1"
|
||||||
|
:placeholder="t('admin.accounts.enterErrorCode')"
|
||||||
|
@keyup.enter="addCustomErrorCode"
|
||||||
|
/>
|
||||||
|
<button type="button" class="btn btn-secondary px-3" @click="addCustomErrorCode">
|
||||||
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected codes summary -->
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
|
||||||
|
:key="code"
|
||||||
|
class="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/30 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:text-red-400"
|
||||||
|
>
|
||||||
|
{{ code }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hover:text-red-900 dark:hover:text-red-300"
|
||||||
|
@click="removeErrorCode(code)"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400">
|
||||||
|
{{ t('admin.accounts.noneSelectedUsesDefault') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Intercept warmup requests (Anthropic only) -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1 pr-4">
|
||||||
|
<label class="input-label mb-0">{{
|
||||||
|
t('admin.accounts.interceptWarmupRequests')
|
||||||
|
}}</label>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{{ t('admin.accounts.interceptWarmupRequestsDesc') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="enableInterceptWarmup"
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="enableInterceptWarmup" class="mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||||
|
interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600',
|
||||||
|
]"
|
||||||
|
@click="interceptWarmupRequests = !interceptWarmupRequests"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||||
|
interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Proxy -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.proxy') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="enableProxy"
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div :class="!enableProxy && 'opacity-50 pointer-events-none'">
|
||||||
|
<ProxySelector v-model="proxyId" :proxies="proxies" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Concurrency & Priority -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.concurrency') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="enableConcurrency"
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model.number="concurrency"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:disabled="!enableConcurrency"
|
||||||
|
class="input"
|
||||||
|
:class="!enableConcurrency && 'opacity-50 cursor-not-allowed'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.priority') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="enablePriority"
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model.number="priority"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:disabled="!enablePriority"
|
||||||
|
class="input"
|
||||||
|
:class="!enablePriority && 'opacity-50 cursor-not-allowed'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<label class="input-label mb-0">{{ t('common.status') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="enableStatus"
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div :class="!enableStatus && 'opacity-50 pointer-events-none'">
|
||||||
|
<Select v-model="status" :options="statusOptions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Groups -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<label class="input-label mb-0">{{ t('nav.groups') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="enableGroups"
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div :class="!enableGroups && 'opacity-50 pointer-events-none'">
|
||||||
|
<GroupSelector v-model="groupIds" :groups="groups" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="handleClose">
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button type="submit" :disabled="submitting" class="btn btn-primary">
|
||||||
|
<svg
|
||||||
|
v-if="submitting"
|
||||||
|
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{
|
||||||
|
submitting ? t('admin.accounts.bulkEdit.updating') : t('admin.accounts.bulkEdit.submit')
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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 } from '@/types'
|
||||||
|
import Modal from '@/components/common/Modal.vue'
|
||||||
|
import Select from '@/components/common/Select.vue'
|
||||||
|
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||||
|
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
show: boolean
|
||||||
|
accountIds: number[]
|
||||||
|
proxies: Proxy[]
|
||||||
|
groups: Group[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
updated: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
// Model mapping type
|
||||||
|
interface ModelMapping {
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// State - field enable flags
|
||||||
|
const enableBaseUrl = ref(false)
|
||||||
|
const enableModelRestriction = ref(false)
|
||||||
|
const enableCustomErrorCodes = ref(false)
|
||||||
|
const enableInterceptWarmup = ref(false)
|
||||||
|
const enableProxy = ref(false)
|
||||||
|
const enableConcurrency = ref(false)
|
||||||
|
const enablePriority = ref(false)
|
||||||
|
const enableStatus = ref(false)
|
||||||
|
const enableGroups = ref(false)
|
||||||
|
|
||||||
|
// State - field values
|
||||||
|
const submitting = ref(false)
|
||||||
|
const baseUrl = ref('')
|
||||||
|
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||||
|
const allowedModels = ref<string[]>([])
|
||||||
|
const modelMappings = ref<ModelMapping[]>([])
|
||||||
|
const selectedErrorCodes = ref<number[]>([])
|
||||||
|
const customErrorCodeInput = ref<number | null>(null)
|
||||||
|
const interceptWarmupRequests = ref(false)
|
||||||
|
const proxyId = ref<number | null>(null)
|
||||||
|
const concurrency = ref(1)
|
||||||
|
const priority = ref(1)
|
||||||
|
const status = ref<'active' | 'inactive'>('active')
|
||||||
|
const groupIds = ref<number[]>([])
|
||||||
|
|
||||||
|
// All models list (combined Anthropic + OpenAI)
|
||||||
|
const allModels = [
|
||||||
|
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
|
||||||
|
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
|
||||||
|
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
|
||||||
|
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' },
|
||||||
|
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
|
||||||
|
{ value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
|
||||||
|
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
|
||||||
|
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' },
|
||||||
|
{ value: 'gpt-5.2-2025-12-11', label: 'GPT-5.2' },
|
||||||
|
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
||||||
|
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||||
|
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||||
|
{ value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' },
|
||||||
|
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
|
||||||
|
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Preset mappings (combined Anthropic + OpenAI)
|
||||||
|
const presetMappings = [
|
||||||
|
{
|
||||||
|
label: 'Sonnet 4',
|
||||||
|
from: 'claude-sonnet-4-20250514',
|
||||||
|
to: 'claude-sonnet-4-20250514',
|
||||||
|
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sonnet 4.5',
|
||||||
|
from: 'claude-sonnet-4-5-20250929',
|
||||||
|
to: 'claude-sonnet-4-5-20250929',
|
||||||
|
color:
|
||||||
|
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Opus 4.5',
|
||||||
|
from: 'claude-opus-4-5-20251101',
|
||||||
|
to: 'claude-opus-4-5-20251101',
|
||||||
|
color:
|
||||||
|
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Opus->Sonnet',
|
||||||
|
from: 'claude-opus-4-5-20251101',
|
||||||
|
to: 'claude-sonnet-4-5-20250929',
|
||||||
|
color:
|
||||||
|
'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'GPT-5.2',
|
||||||
|
from: 'gpt-5.2-2025-12-11',
|
||||||
|
to: 'gpt-5.2-2025-12-11',
|
||||||
|
color:
|
||||||
|
'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'GPT-5.2 Codex',
|
||||||
|
from: 'gpt-5.2-codex',
|
||||||
|
to: 'gpt-5.2-codex',
|
||||||
|
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Max->Codex',
|
||||||
|
from: 'gpt-5.1-codex-max',
|
||||||
|
to: 'gpt-5.1-codex',
|
||||||
|
color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Common HTTP error codes
|
||||||
|
const commonErrorCodes = [
|
||||||
|
{ value: 401, label: 'Unauthorized' },
|
||||||
|
{ value: 403, label: 'Forbidden' },
|
||||||
|
{ value: 429, label: 'Rate Limit' },
|
||||||
|
{ value: 500, label: 'Server Error' },
|
||||||
|
{ value: 502, label: 'Bad Gateway' },
|
||||||
|
{ value: 503, label: 'Unavailable' },
|
||||||
|
{ value: 529, label: 'Overloaded' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusOptions = computed(() => [
|
||||||
|
{ value: 'active', label: t('common.active') },
|
||||||
|
{ value: 'inactive', label: t('common.inactive') },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Model mapping helpers
|
||||||
|
const addModelMapping = () => {
|
||||||
|
modelMappings.value.push({ from: '', to: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeModelMapping = (index: number) => {
|
||||||
|
modelMappings.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addPresetMapping = (from: string, to: string) => {
|
||||||
|
const exists = modelMappings.value.some((m) => m.from === from)
|
||||||
|
if (exists) {
|
||||||
|
appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelMappings.value.push({ from, to })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error code helpers
|
||||||
|
const toggleErrorCode = (code: number) => {
|
||||||
|
const index = selectedErrorCodes.value.indexOf(code)
|
||||||
|
if (index === -1) {
|
||||||
|
selectedErrorCodes.value.push(code)
|
||||||
|
} else {
|
||||||
|
selectedErrorCodes.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomErrorCode = () => {
|
||||||
|
const code = customErrorCodeInput.value
|
||||||
|
if (code === null || code < 100 || code > 599) {
|
||||||
|
appStore.showError(t('admin.accounts.invalidErrorCode'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedErrorCodes.value.includes(code)) {
|
||||||
|
appStore.showInfo(t('admin.accounts.errorCodeExists'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedErrorCodes.value.push(code)
|
||||||
|
customErrorCodeInput.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeErrorCode = (code: number) => {
|
||||||
|
const index = selectedErrorCodes.value.indexOf(code)
|
||||||
|
if (index !== -1) {
|
||||||
|
selectedErrorCodes.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildModelMappingObject = (): Record<string, string> | null => {
|
||||||
|
const mapping: Record<string, string> = {}
|
||||||
|
|
||||||
|
if (modelRestrictionMode.value === 'whitelist') {
|
||||||
|
for (const model of allowedModels.value) {
|
||||||
|
mapping[model] = model
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const m of modelMappings.value) {
|
||||||
|
const from = m.from.trim()
|
||||||
|
const to = m.to.trim()
|
||||||
|
if (from && to) {
|
||||||
|
mapping[from] = to
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(mapping).length > 0 ? mapping : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||||
|
const updates: Record<string, unknown> = {}
|
||||||
|
const credentials: Record<string, unknown> = {}
|
||||||
|
let credentialsChanged = false
|
||||||
|
|
||||||
|
if (enableProxy.value) {
|
||||||
|
updates.proxy_id = proxyId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableConcurrency.value) {
|
||||||
|
updates.concurrency = concurrency.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enablePriority.value) {
|
||||||
|
updates.priority = priority.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableStatus.value) {
|
||||||
|
updates.status = status.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableGroups.value) {
|
||||||
|
updates.group_ids = groupIds.value
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
credentialsChanged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableCustomErrorCodes.value) {
|
||||||
|
credentials.custom_error_codes_enabled = true
|
||||||
|
credentials.custom_error_codes = [...selectedErrorCodes.value]
|
||||||
|
credentialsChanged = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableInterceptWarmup.value) {
|
||||||
|
credentials.intercept_warmup_requests = interceptWarmupRequests.value
|
||||||
|
credentialsChanged = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentialsChanged) {
|
||||||
|
updates.credentials = credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(updates).length > 0 ? updates : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (props.accountIds.length === 0) {
|
||||||
|
appStore.showError(t('admin.accounts.bulkEdit.noSelection'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAnyFieldEnabled =
|
||||||
|
enableBaseUrl.value ||
|
||||||
|
enableModelRestriction.value ||
|
||||||
|
enableCustomErrorCodes.value ||
|
||||||
|
enableInterceptWarmup.value ||
|
||||||
|
enableProxy.value ||
|
||||||
|
enableConcurrency.value ||
|
||||||
|
enablePriority.value ||
|
||||||
|
enableStatus.value ||
|
||||||
|
enableGroups.value
|
||||||
|
|
||||||
|
if (!hasAnyFieldEnabled) {
|
||||||
|
appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = buildUpdatePayload()
|
||||||
|
if (!updates) {
|
||||||
|
appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form when modal closes
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(newShow) => {
|
||||||
|
if (!newShow) {
|
||||||
|
// Reset all enable flags
|
||||||
|
enableBaseUrl.value = false
|
||||||
|
enableModelRestriction.value = false
|
||||||
|
enableCustomErrorCodes.value = false
|
||||||
|
enableInterceptWarmup.value = false
|
||||||
|
enableProxy.value = false
|
||||||
|
enableConcurrency.value = false
|
||||||
|
enablePriority.value = false
|
||||||
|
enableStatus.value = false
|
||||||
|
enableGroups.value = false
|
||||||
|
|
||||||
|
// Reset all values
|
||||||
|
baseUrl.value = ''
|
||||||
|
modelRestrictionMode.value = 'whitelist'
|
||||||
|
allowedModels.value = []
|
||||||
|
modelMappings.value = []
|
||||||
|
selectedErrorCodes.value = []
|
||||||
|
customErrorCodeInput.value = null
|
||||||
|
interceptWarmupRequests.value = false
|
||||||
|
proxyId.value = null
|
||||||
|
concurrency.value = 1
|
||||||
|
priority.value = 1
|
||||||
|
status.value = 'active'
|
||||||
|
groupIds.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
167
frontend/src/components/account/SyncFromCrsModal.vue
Normal file
167
frontend/src/components/account/SyncFromCrsModal.vue
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:show="show"
|
||||||
|
:title="t('admin.accounts.syncFromCrsTitle')"
|
||||||
|
size="lg"
|
||||||
|
close-on-click-outside
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-dark-300">
|
||||||
|
{{ t('admin.accounts.syncFromCrsDesc') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-dark-400 bg-gray-50 dark:bg-dark-700/60 rounded-lg p-3">
|
||||||
|
已有账号仅同步 CRS 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选“同步代理”时保留原有代理。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.accounts.crsBaseUrl') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="form.base_url"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
:placeholder="t('admin.accounts.crsBaseUrlPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="form.username"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
autocomplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
class="input"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-dark-300">
|
||||||
|
<input v-model="form.sync_proxies" type="checkbox" class="rounded border-gray-300 dark:border-dark-600" />
|
||||||
|
{{ t('admin.accounts.syncProxies') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="result" class="rounded-xl border border-gray-200 dark:border-dark-700 p-4 space-y-2">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ t('admin.accounts.syncResult') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-700 dark:text-dark-300">
|
||||||
|
{{ t('admin.accounts.syncResultSummary', result) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorItems.length" class="mt-2">
|
||||||
|
<div class="text-sm font-medium text-red-600 dark:text-red-400">
|
||||||
|
{{ t('admin.accounts.syncErrors') }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 dark:bg-dark-800 p-3 text-xs font-mono">
|
||||||
|
<div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap">
|
||||||
|
{{ item.kind }} {{ item.crs_account_id }} — {{ item.action }}{{ item.error ? `: ${item.error}` : '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button class="btn btn-secondary" :disabled="syncing" @click="handleClose">
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" :disabled="syncing" @click="handleSync">
|
||||||
|
{{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import Modal from '@/components/common/Modal.vue'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { adminAPI } from '@/api/admin'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
show: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'close'): void
|
||||||
|
(e: 'synced'): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
const syncing = ref(false)
|
||||||
|
const result = ref<Awaited<ReturnType<typeof adminAPI.accounts.syncFromCrs>> | null>(null)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
base_url: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
sync_proxies: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const errorItems = computed(() => {
|
||||||
|
if (!result.value?.items) return []
|
||||||
|
return result.value.items.filter((i) => i.action === 'failed' || i.action === 'skipped')
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
result.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) {
|
||||||
|
appStore.showError(t('admin.accounts.syncMissingFields'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncing.value = true
|
||||||
|
try {
|
||||||
|
const res = await adminAPI.accounts.syncFromCrs({
|
||||||
|
base_url: form.base_url.trim(),
|
||||||
|
username: form.username.trim(),
|
||||||
|
password: form.password,
|
||||||
|
sync_proxies: form.sync_proxies
|
||||||
|
})
|
||||||
|
result.value = res
|
||||||
|
|
||||||
|
if (res.failed > 0) {
|
||||||
|
appStore.showError(t('admin.accounts.syncCompletedWithErrors', res))
|
||||||
|
} else {
|
||||||
|
appStore.showSuccess(t('admin.accounts.syncCompleted', res))
|
||||||
|
emit('synced')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
appStore.showError(error?.message || t('admin.accounts.syncFailed'))
|
||||||
|
} finally {
|
||||||
|
syncing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export { default as CreateAccountModal } from './CreateAccountModal.vue'
|
export { default as CreateAccountModal } from './CreateAccountModal.vue'
|
||||||
export { default as EditAccountModal } from './EditAccountModal.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 ReAuthAccountModal } from './ReAuthAccountModal.vue'
|
||||||
export { default as OAuthAuthorizationFlow } from './OAuthAuthorizationFlow.vue'
|
export { default as OAuthAuthorizationFlow } from './OAuthAuthorizationFlow.vue'
|
||||||
export { default as AccountStatusIndicator } from './AccountStatusIndicator.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 AccountStatsModal } from './AccountStatsModal.vue'
|
||||||
export { default as AccountTestModal } from './AccountTestModal.vue'
|
export { default as AccountTestModal } from './AccountTestModal.vue'
|
||||||
export { default as AccountTodayStatsCell } from './AccountTodayStatsCell.vue'
|
export { default as AccountTodayStatsCell } from './AccountTodayStatsCell.vue'
|
||||||
|
export { default as SyncFromCrsModal } from './SyncFromCrsModal.vue'
|
||||||
|
|||||||
@@ -17,11 +17,14 @@ export default {
|
|||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
unifiedGateway: 'Unified API Gateway',
|
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',
|
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',
|
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: {
|
providers: {
|
||||||
title: 'Supported Providers',
|
title: 'Supported Providers',
|
||||||
@@ -235,7 +238,8 @@ export default {
|
|||||||
useKey: 'Use Key',
|
useKey: 'Use Key',
|
||||||
useKeyModal: {
|
useKeyModal: {
|
||||||
title: 'Use API Key',
|
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',
|
copy: 'Copy',
|
||||||
copied: 'Copied',
|
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.',
|
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',
|
failedToLoadApiKeys: 'Failed to load user API keys',
|
||||||
deleteConfirm: "Are you sure you want to delete '{email}'? This action cannot be undone.",
|
deleteConfirm: "Are you sure you want to delete '{email}'? This action cannot be undone.",
|
||||||
setAllowedGroups: 'Set Allowed Groups',
|
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',
|
noStandardGroups: 'No standard groups available',
|
||||||
allowAllGroups: 'Allow All Groups',
|
allowAllGroups: 'Allow All Groups',
|
||||||
allowAllGroupsHint: 'User can use any non-exclusive group',
|
allowAllGroupsHint: 'User can use any non-exclusive group',
|
||||||
@@ -529,8 +534,10 @@ export default {
|
|||||||
depositAmount: 'Deposit Amount',
|
depositAmount: 'Deposit Amount',
|
||||||
withdrawAmount: 'Withdraw Amount',
|
withdrawAmount: 'Withdraw Amount',
|
||||||
currentBalance: 'Current Balance',
|
currentBalance: 'Current Balance',
|
||||||
depositNotesPlaceholder: 'e.g., New user registration bonus, promotional credit, compensation, etc.',
|
depositNotesPlaceholder:
|
||||||
withdrawNotesPlaceholder: 'e.g., Service issue refund, incorrect charge reversal, account closure refund, etc.',
|
'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',
|
notesOptional: 'Notes are optional but helpful for record keeping',
|
||||||
amountHint: 'Please enter a positive amount',
|
amountHint: 'Please enter a positive amount',
|
||||||
newBalance: 'New Balance',
|
newBalance: 'New Balance',
|
||||||
@@ -597,12 +604,15 @@ export default {
|
|||||||
failedToCreate: 'Failed to create group',
|
failedToCreate: 'Failed to create group',
|
||||||
failedToUpdate: 'Failed to update group',
|
failedToUpdate: 'Failed to update group',
|
||||||
failedToDelete: 'Failed to delete 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.",
|
deleteConfirm:
|
||||||
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.",
|
"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: {
|
subscription: {
|
||||||
title: 'Subscription Settings',
|
title: 'Subscription Settings',
|
||||||
type: 'Billing Type',
|
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.',
|
typeNotEditable: 'Billing type cannot be changed after group creation.',
|
||||||
standard: 'Standard (Balance)',
|
standard: 'Standard (Balance)',
|
||||||
subscription: 'Subscription (Quota)',
|
subscription: 'Subscription (Quota)',
|
||||||
@@ -674,7 +684,8 @@ export default {
|
|||||||
failedToAssign: 'Failed to assign subscription',
|
failedToAssign: 'Failed to assign subscription',
|
||||||
failedToExtend: 'Failed to extend subscription',
|
failedToExtend: 'Failed to extend subscription',
|
||||||
failedToRevoke: 'Failed to revoke 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
|
// Accounts
|
||||||
@@ -682,6 +693,25 @@ export default {
|
|||||||
title: 'Account Management',
|
title: 'Account Management',
|
||||||
description: 'Manage AI platform accounts and credentials',
|
description: 'Manage AI platform accounts and credentials',
|
||||||
createAccount: 'Create Account',
|
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',
|
editAccount: 'Edit Account',
|
||||||
deleteAccount: 'Delete Account',
|
deleteAccount: 'Delete Account',
|
||||||
searchAccounts: 'Search accounts...',
|
searchAccounts: 'Search accounts...',
|
||||||
@@ -726,6 +756,32 @@ export default {
|
|||||||
tokenRefreshed: 'Token refreshed successfully',
|
tokenRefreshed: 'Token refreshed successfully',
|
||||||
accountDeleted: 'Account deleted successfully',
|
accountDeleted: 'Account deleted successfully',
|
||||||
rateLimitCleared: 'Rate limit cleared 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',
|
resetStatus: 'Reset Status',
|
||||||
statusReset: 'Account status reset successfully',
|
statusReset: 'Account status reset successfully',
|
||||||
failedToResetStatus: 'Failed to reset account status',
|
failedToResetStatus: 'Failed to reset account status',
|
||||||
@@ -753,7 +809,8 @@ export default {
|
|||||||
modelWhitelist: 'Model Whitelist',
|
modelWhitelist: 'Model Whitelist',
|
||||||
modelMapping: 'Model Mapping',
|
modelMapping: 'Model Mapping',
|
||||||
selectAllowedModels: 'Select allowed models. Leave empty to support all models.',
|
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)',
|
selectedModels: 'Selected {count} model(s)',
|
||||||
supportsAllModels: '(supports all models)',
|
supportsAllModels: '(supports all models)',
|
||||||
requestModel: 'Request model',
|
requestModel: 'Request model',
|
||||||
@@ -762,14 +819,16 @@ export default {
|
|||||||
mappingExists: 'Mapping for {model} already exists',
|
mappingExists: 'Mapping for {model} already exists',
|
||||||
customErrorCodes: 'Custom Error Codes',
|
customErrorCodes: 'Custom Error Codes',
|
||||||
customErrorCodesHint: 'Only stop scheduling for selected 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',
|
selectedErrorCodes: 'Selected',
|
||||||
noneSelectedUsesDefault: 'None selected (uses default policy)',
|
noneSelectedUsesDefault: 'None selected (uses default policy)',
|
||||||
enterErrorCode: 'Enter error code (100-599)',
|
enterErrorCode: 'Enter error code (100-599)',
|
||||||
invalidErrorCode: 'Please enter a valid HTTP error code (100-599)',
|
invalidErrorCode: 'Please enter a valid HTTP error code (100-599)',
|
||||||
errorCodeExists: 'This error code is already selected',
|
errorCodeExists: 'This error code is already selected',
|
||||||
interceptWarmupRequests: 'Intercept Warmup Requests',
|
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',
|
proxy: 'Proxy',
|
||||||
noProxy: 'No Proxy',
|
noProxy: 'No Proxy',
|
||||||
concurrency: 'Concurrency',
|
concurrency: 'Concurrency',
|
||||||
@@ -792,11 +851,13 @@ export default {
|
|||||||
authMethod: 'Authorization Method',
|
authMethod: 'Authorization Method',
|
||||||
manualAuth: 'Manual Authorization',
|
manualAuth: 'Manual Authorization',
|
||||||
cookieAutoAuth: 'Cookie Auto-Auth',
|
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',
|
sessionKey: 'sessionKey',
|
||||||
keysCount: '{count} keys',
|
keysCount: '{count} keys',
|
||||||
batchCreateAccounts: 'Will batch create {count} accounts',
|
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...',
|
sessionKeyPlaceholderSingle: 'sk-ant-sid01-xxxxx...',
|
||||||
howToGetSessionKey: 'How to get sessionKey',
|
howToGetSessionKey: 'How to get sessionKey',
|
||||||
step1: 'Login to <strong>claude.ai</strong> in your browser',
|
step1: 'Login to <strong>claude.ai</strong> in your browser',
|
||||||
@@ -814,10 +875,13 @@ export default {
|
|||||||
generating: 'Generating...',
|
generating: 'Generating...',
|
||||||
regenerate: 'Regenerate',
|
regenerate: 'Regenerate',
|
||||||
step2OpenUrl: 'Open the URL in your browser and complete authorization',
|
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.',
|
openUrlDesc:
|
||||||
proxyWarning: '<strong>Note:</strong> If you configured a proxy, make sure your browser uses the same proxy to access the authorization page.',
|
'Open the authorization URL in a new tab, log in to your Claude account and authorize.',
|
||||||
|
proxyWarning:
|
||||||
|
'<strong>Note:</strong> If you configured a proxy, make sure your browser uses the same proxy to access the authorization page.',
|
||||||
step3EnterCode: 'Enter the Authorization Code',
|
step3EnterCode: 'Enter the Authorization Code',
|
||||||
authCodeDesc: 'After authorization is complete, the page will display an <strong>Authorization Code</strong>. Copy and paste it below:',
|
authCodeDesc:
|
||||||
|
'After authorization is complete, the page will display an <strong>Authorization Code</strong>. Copy and paste it below:',
|
||||||
authCode: 'Authorization Code',
|
authCode: 'Authorization Code',
|
||||||
authCodePlaceholder: 'Paste the Authorization Code from Claude page...',
|
authCodePlaceholder: 'Paste the Authorization Code from Claude page...',
|
||||||
authCodeHint: 'Paste the Authorization Code copied from the 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',
|
step1GenerateUrl: 'Click the button below to generate the authorization URL',
|
||||||
generateAuthUrl: 'Generate Auth URL',
|
generateAuthUrl: 'Generate Auth URL',
|
||||||
step2OpenUrl: 'Open the URL in your browser and complete authorization',
|
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.',
|
openUrlDesc:
|
||||||
importantNotice: '<strong>Important:</strong> The page may take a while to load after authorization. Please wait patiently. When the browser address bar changes to <code>http://localhost...</code>, the authorization is complete.',
|
'Open the authorization URL in a new tab, log in to your OpenAI account and authorize.',
|
||||||
|
importantNotice:
|
||||||
|
'<strong>Important:</strong> The page may take a while to load after authorization. Please wait patiently. When the browser address bar changes to <code>http://localhost...</code>, the authorization is complete.',
|
||||||
step3EnterCode: 'Enter Authorization URL or Code',
|
step3EnterCode: 'Enter Authorization URL or Code',
|
||||||
authCodeDesc: 'After authorization is complete, when the page URL becomes <code>http://localhost:xxx/auth/callback?code=...</code>:',
|
authCodeDesc:
|
||||||
|
'After authorization is complete, when the page URL becomes <code>http://localhost:xxx/auth/callback?code=...</code>:',
|
||||||
authCode: 'Authorization URL or 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',
|
authCodePlaceholder:
|
||||||
authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect',
|
'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
|
// Re-Auth Modal
|
||||||
@@ -941,8 +1010,10 @@ export default {
|
|||||||
standardAdd: 'Standard Add',
|
standardAdd: 'Standard Add',
|
||||||
batchAdd: 'Quick Add',
|
batchAdd: 'Quick Add',
|
||||||
batchInput: 'Proxy List',
|
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",
|
batchInputPlaceholder:
|
||||||
batchInputHint: "Supports http, https, socks5 protocols. Format: protocol://[user:pass{'@'}]host:port",
|
"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',
|
parsedCount: '{count} valid',
|
||||||
invalidCount: '{count} invalid',
|
invalidCount: '{count} invalid',
|
||||||
duplicateCount: '{count} duplicate',
|
duplicateCount: '{count} duplicate',
|
||||||
@@ -965,7 +1036,8 @@ export default {
|
|||||||
failedToUpdate: 'Failed to update proxy',
|
failedToUpdate: 'Failed to update proxy',
|
||||||
failedToDelete: 'Failed to delete proxy',
|
failedToDelete: 'Failed to delete proxy',
|
||||||
failedToTest: 'Failed to test 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
|
// Redeem Codes
|
||||||
@@ -994,8 +1066,10 @@ export default {
|
|||||||
exportCsv: 'Export CSV',
|
exportCsv: 'Export CSV',
|
||||||
deleteAllUnused: 'Delete All Unused Codes',
|
deleteAllUnused: 'Delete All Unused Codes',
|
||||||
deleteCode: 'Delete Redeem Code',
|
deleteCode: 'Delete Redeem Code',
|
||||||
deleteCodeConfirm: 'Are you sure you want to delete this redeem code? This action cannot be undone.',
|
deleteCodeConfirm:
|
||||||
deleteAllUnusedConfirm: 'Are you sure you want to delete all unused (active) redeem codes? This action cannot be undone.',
|
'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',
|
deleteAll: 'Delete All',
|
||||||
generateCodesTitle: 'Generate Redeem Codes',
|
generateCodesTitle: 'Generate Redeem Codes',
|
||||||
generatedSuccessfully: 'Generated Successfully',
|
generatedSuccessfully: 'Generated Successfully',
|
||||||
@@ -1075,7 +1149,8 @@ export default {
|
|||||||
siteSubtitle: 'Site Subtitle',
|
siteSubtitle: 'Site Subtitle',
|
||||||
siteSubtitleHint: 'Displayed on login and register pages',
|
siteSubtitleHint: 'Displayed on login and register pages',
|
||||||
apiBaseUrl: 'API Base URL',
|
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',
|
contactInfo: 'Contact Info',
|
||||||
contactInfoPlaceholder: 'e.g., QQ: 123456789',
|
contactInfoPlaceholder: 'e.g., QQ: 123456789',
|
||||||
contactInfoHint: 'Customer support contact info, displayed on redeem page, profile, etc.',
|
contactInfoHint: 'Customer support contact info, displayed on redeem page, profile, etc.',
|
||||||
@@ -1125,7 +1200,8 @@ export default {
|
|||||||
create: 'Create Key',
|
create: 'Create Key',
|
||||||
creating: 'Creating...',
|
creating: 'Creating...',
|
||||||
regenerateConfirm: 'Are you sure? The current key will be immediately invalidated.',
|
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',
|
keyGenerated: 'New admin API key generated',
|
||||||
keyDeleted: 'Admin API key deleted',
|
keyDeleted: 'Admin API key deleted',
|
||||||
copyKey: 'Copy Key',
|
copyKey: 'Copy Key',
|
||||||
@@ -1191,7 +1267,8 @@ export default {
|
|||||||
title: 'My Subscriptions',
|
title: 'My Subscriptions',
|
||||||
description: 'View your subscription plans and usage',
|
description: 'View your subscription plans and usage',
|
||||||
noActiveSubscriptions: 'No Active Subscriptions',
|
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: {
|
status: {
|
||||||
active: 'Active',
|
active: 'Active',
|
||||||
expired: 'Expired',
|
expired: 'Expired',
|
||||||
|
|||||||
@@ -620,7 +620,8 @@ export default {
|
|||||||
editGroup: '编辑分组',
|
editGroup: '编辑分组',
|
||||||
deleteGroup: '删除分组',
|
deleteGroup: '删除分组',
|
||||||
deleteConfirm: "确定要删除分组 '{name}' 吗?所有关联的 API 密钥将不再属于任何分组。",
|
deleteConfirm: "确定要删除分组 '{name}' 吗?所有关联的 API 密钥将不再属于任何分组。",
|
||||||
deleteConfirmSubscription: "确定要删除订阅分组 '{name}' 吗?此操作会让所有绑定此订阅的用户的 API Key 失效,并删除所有相关的订阅记录。此操作无法撤销。",
|
deleteConfirmSubscription:
|
||||||
|
"确定要删除订阅分组 '{name}' 吗?此操作会让所有绑定此订阅的用户的 API Key 失效,并删除所有相关的订阅记录。此操作无法撤销。",
|
||||||
columns: {
|
columns: {
|
||||||
name: '名称',
|
name: '名称',
|
||||||
platform: '平台',
|
platform: '平台',
|
||||||
@@ -780,6 +781,24 @@ export default {
|
|||||||
title: '账号管理',
|
title: '账号管理',
|
||||||
description: '管理 AI 平台账号和 Cookie',
|
description: '管理 AI 平台账号和 Cookie',
|
||||||
createAccount: '添加账号',
|
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: '编辑账号',
|
editAccount: '编辑账号',
|
||||||
deleteAccount: '删除账号',
|
deleteAccount: '删除账号',
|
||||||
deleteConfirmMessage: "确定要删除账号 '{name}' 吗?",
|
deleteConfirmMessage: "确定要删除账号 '{name}' 吗?",
|
||||||
@@ -859,6 +878,31 @@ export default {
|
|||||||
accountCreatedSuccess: '账号添加成功',
|
accountCreatedSuccess: '账号添加成功',
|
||||||
accountUpdatedSuccess: '账号更新成功',
|
accountUpdatedSuccess: '账号更新成功',
|
||||||
accountDeletedSuccess: '账号删除成功',
|
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: '重置状态',
|
resetStatus: '重置状态',
|
||||||
statusReset: '账号状态已重置',
|
statusReset: '账号状态已重置',
|
||||||
failedToResetStatus: '重置账号状态失败',
|
failedToResetStatus: '重置账号状态失败',
|
||||||
@@ -931,7 +975,8 @@ export default {
|
|||||||
sessionKey: 'sessionKey',
|
sessionKey: 'sessionKey',
|
||||||
keysCount: '{count} 个密钥',
|
keysCount: '{count} 个密钥',
|
||||||
batchCreateAccounts: '将批量创建 {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...',
|
sessionKeyPlaceholderSingle: 'sk-ant-sid01-xxxxx...',
|
||||||
howToGetSessionKey: '如何获取 sessionKey',
|
howToGetSessionKey: '如何获取 sessionKey',
|
||||||
step1: '在浏览器中登录 <strong>claude.ai</strong>',
|
step1: '在浏览器中登录 <strong>claude.ai</strong>',
|
||||||
@@ -950,7 +995,8 @@ export default {
|
|||||||
regenerate: '重新生成',
|
regenerate: '重新生成',
|
||||||
step2OpenUrl: '在浏览器中打开 URL 并完成授权',
|
step2OpenUrl: '在浏览器中打开 URL 并完成授权',
|
||||||
openUrlDesc: '在新标签页中打开授权 URL,登录您的 Claude 账号并授权。',
|
openUrlDesc: '在新标签页中打开授权 URL,登录您的 Claude 账号并授权。',
|
||||||
proxyWarning: '<strong>注意:</strong>如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。',
|
proxyWarning:
|
||||||
|
'<strong>注意:</strong>如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。',
|
||||||
step3EnterCode: '输入授权码',
|
step3EnterCode: '输入授权码',
|
||||||
authCodeDesc: '授权完成后,页面会显示一个 <strong>授权码</strong>。复制并粘贴到下方:',
|
authCodeDesc: '授权完成后,页面会显示一个 <strong>授权码</strong>。复制并粘贴到下方:',
|
||||||
authCode: '授权码',
|
authCode: '授权码',
|
||||||
@@ -971,11 +1017,14 @@ export default {
|
|||||||
generateAuthUrl: '生成授权链接',
|
generateAuthUrl: '生成授权链接',
|
||||||
step2OpenUrl: '在浏览器中打开链接并完成授权',
|
step2OpenUrl: '在浏览器中打开链接并完成授权',
|
||||||
openUrlDesc: '请在新标签页中打开授权链接,登录您的 OpenAI 账户并授权。',
|
openUrlDesc: '请在新标签页中打开授权链接,登录您的 OpenAI 账户并授权。',
|
||||||
importantNotice: '<strong>重要提示:</strong>授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 <code>http://localhost...</code> 开头时,表示授权已完成。',
|
importantNotice:
|
||||||
|
'<strong>重要提示:</strong>授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 <code>http://localhost...</code> 开头时,表示授权已完成。',
|
||||||
step3EnterCode: '输入授权链接或 Code',
|
step3EnterCode: '输入授权链接或 Code',
|
||||||
authCodeDesc: '授权完成后,当页面地址变为 <code>http://localhost:xxx/auth/callback?code=...</code> 时:',
|
authCodeDesc:
|
||||||
|
'授权完成后,当页面地址变为 <code>http://localhost:xxx/auth/callback?code=...</code> 时:',
|
||||||
authCode: '授权链接或 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 参数值,系统会自动识别',
|
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1111,7 +1160,8 @@ export default {
|
|||||||
standardAdd: '标准添加',
|
standardAdd: '标准添加',
|
||||||
batchAdd: '快捷添加',
|
batchAdd: '快捷添加',
|
||||||
batchInput: '代理列表',
|
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 协议,格式:协议://[用户名:密码{'@'}]主机:端口",
|
batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码{'@'}]主机:端口",
|
||||||
parsedCount: '有效 {count} 个',
|
parsedCount: '有效 {count} 个',
|
||||||
invalidCount: '无效 {count} 个',
|
invalidCount: '无效 {count} 个',
|
||||||
|
|||||||
@@ -16,6 +16,15 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
@click="showCrsSyncModal = true"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
title="从 CRS 同步"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="showCreateModal = true"
|
@click="showCreateModal = true"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@@ -66,9 +75,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Actions Bar -->
|
||||||
|
<div v-if="selectedAccountIds.length > 0" class="card bg-primary-50 dark:bg-primary-900/20 border-primary-200 dark:border-primary-800 px-4 py-3">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||||
|
{{ t('admin.accounts.bulkActions.selected', { count: selectedAccountIds.length }) }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click="selectCurrentPageAccounts"
|
||||||
|
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.bulkActions.selectCurrentPage') }}
|
||||||
|
</button>
|
||||||
|
<span class="text-gray-300 dark:text-primary-800">•</span>
|
||||||
|
<button
|
||||||
|
@click="selectedAccountIds = []"
|
||||||
|
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.bulkActions.clear') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="handleBulkDelete"
|
||||||
|
class="btn btn-danger btn-sm"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.bulkActions.delete') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="showBulkEditModal = true"
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.bulkActions.edit') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Accounts Table -->
|
<!-- Accounts Table -->
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
<DataTable :columns="columns" :data="accounts" :loading="loading">
|
<DataTable :columns="columns" :data="accounts" :loading="loading">
|
||||||
|
<template #cell-select="{ row }">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="selectedAccountIds.includes(row.id)"
|
||||||
|
@change="toggleAccountSelection(row.id)"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell-name="{ value }">
|
<template #cell-name="{ value }">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -315,6 +378,32 @@
|
|||||||
@confirm="confirmDelete"
|
@confirm="confirmDelete"
|
||||||
@cancel="showDeleteDialog = false"
|
@cancel="showDeleteDialog = false"
|
||||||
/>
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
:show="showBulkDeleteDialog"
|
||||||
|
:title="t('admin.accounts.bulkDeleteTitle')"
|
||||||
|
:message="t('admin.accounts.bulkDeleteConfirm', { count: selectedAccountIds.length })"
|
||||||
|
:confirm-text="t('common.delete')"
|
||||||
|
:cancel-text="t('common.cancel')"
|
||||||
|
:danger="true"
|
||||||
|
@confirm="confirmBulkDelete"
|
||||||
|
@cancel="showBulkDeleteDialog = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SyncFromCrsModal
|
||||||
|
:show="showCrsSyncModal"
|
||||||
|
@close="showCrsSyncModal = false"
|
||||||
|
@synced="handleCrsSynced"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Bulk Edit Account Modal -->
|
||||||
|
<BulkEditAccountModal
|
||||||
|
:show="showBulkEditModal"
|
||||||
|
:account-ids="selectedAccountIds"
|
||||||
|
:proxies="proxies"
|
||||||
|
:groups="groups"
|
||||||
|
@close="showBulkEditModal = false"
|
||||||
|
@updated="handleBulkUpdated"
|
||||||
|
/>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -331,7 +420,7 @@ import Pagination from '@/components/common/Pagination.vue'
|
|||||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||||
import EmptyState from '@/components/common/EmptyState.vue'
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
import Select from '@/components/common/Select.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 AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
|
||||||
import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
|
import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
|
||||||
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
|
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
|
||||||
@@ -345,6 +434,7 @@ const appStore = useAppStore()
|
|||||||
|
|
||||||
// Table columns
|
// Table columns
|
||||||
const columns = computed<Column[]>(() => [
|
const columns = computed<Column[]>(() => [
|
||||||
|
{ key: 'select', label: '', sortable: false },
|
||||||
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
|
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
|
||||||
{ key: 'platform_type', label: t('admin.accounts.columns.platformType'), sortable: false },
|
{ key: 'platform_type', label: t('admin.accounts.columns.platformType'), sortable: false },
|
||||||
{ key: 'concurrency', label: t('admin.accounts.columns.concurrencyStatus'), 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 showEditModal = ref(false)
|
||||||
const showReAuthModal = ref(false)
|
const showReAuthModal = ref(false)
|
||||||
const showDeleteDialog = ref(false)
|
const showDeleteDialog = ref(false)
|
||||||
|
const showBulkDeleteDialog = ref(false)
|
||||||
const showTestModal = ref(false)
|
const showTestModal = ref(false)
|
||||||
const showStatsModal = ref(false)
|
const showStatsModal = ref(false)
|
||||||
|
const showCrsSyncModal = ref(false)
|
||||||
|
const showBulkEditModal = ref(false)
|
||||||
const editingAccount = ref<Account | null>(null)
|
const editingAccount = ref<Account | null>(null)
|
||||||
const reAuthAccount = ref<Account | null>(null)
|
const reAuthAccount = ref<Account | null>(null)
|
||||||
const deletingAccount = ref<Account | null>(null)
|
const deletingAccount = ref<Account | null>(null)
|
||||||
const testingAccount = ref<Account | null>(null)
|
const testingAccount = ref<Account | null>(null)
|
||||||
const statsAccount = ref<Account | null>(null)
|
const statsAccount = ref<Account | null>(null)
|
||||||
const togglingSchedulable = ref<number | null>(null)
|
const togglingSchedulable = ref<number | null>(null)
|
||||||
|
const bulkDeleting = ref(false)
|
||||||
|
|
||||||
|
// Bulk selection
|
||||||
|
const selectedAccountIds = ref<number[]>([])
|
||||||
|
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
|
// Rate limit / Overload helpers
|
||||||
const isRateLimited = (account: Account): boolean => {
|
const isRateLimited = (account: Account): boolean => {
|
||||||
@@ -480,6 +582,11 @@ const handlePageChange = (page: number) => {
|
|||||||
loadAccounts()
|
loadAccounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCrsSynced = () => {
|
||||||
|
showCrsSyncModal.value = false
|
||||||
|
loadAccounts()
|
||||||
|
}
|
||||||
|
|
||||||
// Edit modal
|
// Edit modal
|
||||||
const handleEdit = (account: Account) => {
|
const handleEdit = (account: Account) => {
|
||||||
editingAccount.value = 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
|
// Clear rate limit
|
||||||
const handleClearRateLimit = async (account: Account) => {
|
const handleClearRateLimit = async (account: Account) => {
|
||||||
try {
|
try {
|
||||||
@@ -608,6 +747,23 @@ const closeStatsModal = () => {
|
|||||||
statsAccount.value = null
|
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
|
// Initialize
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
|
|||||||
Reference in New Issue
Block a user