Commit c19a393b authored by shaw's avatar shaw
Browse files

Merge PR #24: feat: 添加账户同步与批量编辑功能

- 添加从 CRS 同步账户功能 (Claude OAuth/API Key, OpenAI OAuth/Responses)
- 添加批量编辑账户功能,支持 JSONB 字段智能合并
- 新增 CRSSyncService、BulkUpdate 仓储方法
- 前端新增 SyncFromCrsModal 和 BulkEditAccountModal 组件
parents 876e85e7 938ffb00
...@@ -92,6 +92,13 @@ backend/internal/web/dist/* ...@@ -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) { ...@@ -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)
......
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 { ...@@ -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. ...@@ -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 { ...@@ -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 { ...@@ -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) { ...@@ -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) { ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 { ...@@ -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 { ...@@ -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 ...@@ -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)
} }
......
This diff is collapsed.
...@@ -11,6 +11,9 @@ import ( ...@@ -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 { ...@@ -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( ...@@ -75,6 +75,7 @@ var ProviderSet = wire.NewSet(
NewSubscriptionService, NewSubscriptionService,
NewConcurrencyService, NewConcurrencyService,
NewIdentityService, NewIdentityService,
NewCRSSyncService,
ProvideUpdateService, ProvideUpdateService,
ProvideTokenRefreshService, ProvideTokenRefreshService,
......
...@@ -16,7 +16,7 @@ services: ...@@ -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: ...@@ -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 @@ ...@@ -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 { ...@@ -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( ...@@ -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( ...@@ -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( ...@@ -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> { ...@@ -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 ...@@ -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 ...@@ -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 }> { ...@@ -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( ...@@ -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<{ ...@@ -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> { ...@@ -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 ...@@ -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> { ...@@ -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> { ...@@ -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( ...@@ -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( ...@@ -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( ...@@ -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<{ ...@@ -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> { ...@@ -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< ...@@ -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 = { ...@@ -263,6 +333,9 @@ export const accountsAPI = {
generateAuthUrl, generateAuthUrl,
exchangeCode, exchangeCode,
batchCreate, batchCreate,
}; batchUpdateCredentials,
bulkUpdate,
syncFromCrs,
}
export default accountsAPI; export default accountsAPI
This diff is collapsed.
<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>
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' ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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' ...@@ -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() ...@@ -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) ...@@ -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) => { ...@@ -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 () => { ...@@ -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 = () => { ...@@ -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()
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment