diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 34c795e7..25f69588 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -87,6 +87,19 @@ type UpdateAccountRequest struct { GroupIDs *[]int64 `json:"group_ids"` } +// BulkUpdateAccountsRequest represents the payload for bulk editing accounts +type BulkUpdateAccountsRequest struct { + AccountIDs []int64 `json:"account_ids" binding:"required,min=1"` + Name string `json:"name"` + ProxyID *int64 `json:"proxy_id"` + Concurrency *int `json:"concurrency"` + Priority *int `json:"priority"` + Status string `json:"status" binding:"omitempty,oneof=active inactive error"` + GroupIDs *[]int64 `json:"group_ids"` + Credentials map[string]any `json:"credentials"` + Extra map[string]any `json:"extra"` +} + // AccountWithConcurrency extends Account with real-time concurrency info type AccountWithConcurrency struct { *model.Account @@ -522,6 +535,48 @@ func (h *AccountHandler) BatchUpdateCredentials(c *gin.Context) { }) } +// BulkUpdate handles bulk updating accounts with selected fields/credentials. +// POST /api/v1/admin/accounts/bulk-update +func (h *AccountHandler) BulkUpdate(c *gin.Context) { + var req BulkUpdateAccountsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + hasUpdates := req.Name != "" || + req.ProxyID != nil || + req.Concurrency != nil || + req.Priority != nil || + req.Status != "" || + req.GroupIDs != nil || + len(req.Credentials) > 0 || + len(req.Extra) > 0 + + if !hasUpdates { + response.BadRequest(c, "No updates provided") + return + } + + result, err := h.adminService.BulkUpdateAccounts(c.Request.Context(), &service.BulkUpdateAccountsInput{ + AccountIDs: req.AccountIDs, + Name: req.Name, + ProxyID: req.ProxyID, + Concurrency: req.Concurrency, + Priority: req.Priority, + Status: req.Status, + GroupIDs: req.GroupIDs, + Credentials: req.Credentials, + Extra: req.Extra, + }) + if err != nil { + response.InternalError(c, "Failed to bulk update accounts: "+err.Error()) + return + } + + response.Success(c, result) +} + // ========== OAuth Handlers ========== // GenerateAuthURLRequest represents the request for generating auth URL diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 26deaf7b..424dcfd5 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -5,9 +5,11 @@ import ( "errors" "github.com/Wei-Shaw/sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/service/ports" "time" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type AccountRepository struct { @@ -352,3 +354,47 @@ func (r *AccountRepository) UpdateExtra(ctx context.Context, id int64, updates m return r.db.WithContext(ctx).Model(&model.Account{}).Where("id = ?", id). Update("extra", account.Extra).Error } + +// BulkUpdate updates multiple accounts with the provided fields. +// It merges credentials/extra JSONB fields instead of overwriting them. +func (r *AccountRepository) BulkUpdate(ctx context.Context, ids []int64, updates ports.AccountBulkUpdate) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + + updateMap := map[string]any{} + + if updates.Name != nil { + updateMap["name"] = *updates.Name + } + if updates.ProxyID != nil { + updateMap["proxy_id"] = updates.ProxyID + } + if updates.Concurrency != nil { + updateMap["concurrency"] = *updates.Concurrency + } + if updates.Priority != nil { + updateMap["priority"] = *updates.Priority + } + if updates.Status != nil { + updateMap["status"] = *updates.Status + } + if len(updates.Credentials) > 0 { + updateMap["credentials"] = gorm.Expr("COALESCE(credentials,'{}') || ?", updates.Credentials) + } + if len(updates.Extra) > 0 { + updateMap["extra"] = gorm.Expr("COALESCE(extra,'{}') || ?", updates.Extra) + } + + if len(updateMap) == 0 { + return 0, nil + } + + result := r.db.WithContext(ctx). + Model(&model.Account{}). + Where("id IN ?", ids). + Clauses(clause.Returning{}). + Updates(updateMap) + + return result.RowsAffected, result.Error +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index dfeadd11..61fc4146 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -194,6 +194,7 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels) accounts.POST("/batch", h.Admin.Account.BatchCreate) accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials) + accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate) // Claude OAuth routes accounts.POST("/generate-auth-url", h.Admin.OAuth.GenerateAuthURL) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index ec150425..737c6a50 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -45,6 +45,7 @@ type AdminService interface { RefreshAccountCredentials(ctx context.Context, id int64) (*model.Account, error) ClearAccountError(ctx context.Context, id int64) (*model.Account, error) SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*model.Account, error) + BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) // Proxy management ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string) ([]model.Proxy, int64, error) @@ -140,6 +141,33 @@ type UpdateAccountInput struct { GroupIDs *[]int64 } +// BulkUpdateAccountsInput describes the payload for bulk updating accounts. +type BulkUpdateAccountsInput struct { + AccountIDs []int64 + Name string + ProxyID *int64 + Concurrency *int + Priority *int + Status string + GroupIDs *[]int64 + Credentials map[string]any + Extra map[string]any +} + +// BulkUpdateAccountResult captures the result for a single account update. +type BulkUpdateAccountResult struct { + AccountID int64 `json:"account_id"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// BulkUpdateAccountsResult is the aggregated response for bulk updates. +type BulkUpdateAccountsResult struct { + Success int `json:"success"` + Failed int `json:"failed"` + Results []BulkUpdateAccountResult `json:"results"` +} + type CreateProxyInput struct { Name string Protocol string @@ -694,6 +722,65 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U return account, nil } +// BulkUpdateAccounts updates multiple accounts in one request. +// It merges credentials/extra keys instead of overwriting the whole object. +func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) { + result := &BulkUpdateAccountsResult{ + Results: make([]BulkUpdateAccountResult, 0, len(input.AccountIDs)), + } + + if len(input.AccountIDs) == 0 { + return result, nil + } + + // Prepare bulk updates for columns and JSONB fields. + repoUpdates := ports.AccountBulkUpdate{ + Credentials: input.Credentials, + Extra: input.Extra, + } + if input.Name != "" { + repoUpdates.Name = &input.Name + } + if input.ProxyID != nil { + repoUpdates.ProxyID = input.ProxyID + } + if input.Concurrency != nil { + repoUpdates.Concurrency = input.Concurrency + } + if input.Priority != nil { + repoUpdates.Priority = input.Priority + } + if input.Status != "" { + repoUpdates.Status = &input.Status + } + + // Run bulk update for column/jsonb fields first. + if _, err := s.accountRepo.BulkUpdate(ctx, input.AccountIDs, repoUpdates); err != nil { + return nil, err + } + + // Handle group bindings per account (requires individual operations). + for _, accountID := range input.AccountIDs { + entry := BulkUpdateAccountResult{AccountID: accountID} + + if input.GroupIDs != nil { + if err := s.accountRepo.BindGroups(ctx, accountID, *input.GroupIDs); err != nil { + entry.Success = false + entry.Error = err.Error() + result.Failed++ + result.Results = append(result.Results, entry) + continue + } + } + + entry.Success = true + result.Success++ + result.Results = append(result.Results, entry) + } + + return result, nil +} + func (s *adminServiceImpl) DeleteAccount(ctx context.Context, id int64) error { return s.accountRepo.Delete(ctx, id) } diff --git a/backend/internal/service/ports/account.go b/backend/internal/service/ports/account.go index 73be0005..2d0e979d 100644 --- a/backend/internal/service/ports/account.go +++ b/backend/internal/service/ports/account.go @@ -38,4 +38,17 @@ type AccountRepository interface { ClearRateLimit(ctx context.Context, id int64) error UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error UpdateExtra(ctx context.Context, id int64, updates map[string]any) error + BulkUpdate(ctx context.Context, ids []int64, updates AccountBulkUpdate) (int64, error) +} + +// AccountBulkUpdate describes the fields that can be updated in a bulk operation. +// Nil pointers mean "do not change". +type AccountBulkUpdate struct { + Name *string + ProxyID *int64 + Concurrency *int + Priority *int + Status *string + Credentials map[string]any + Extra map[string]any } diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 60de2492..329fe74a 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -255,7 +255,7 @@ export async function bulkUpdate( results: Array<{ account_id: number; success: boolean; error?: string }>; }>('/admin/accounts/bulk-update', { account_ids: accountIds, - updates + ...updates }); return data; } diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index e24bd04f..fe85204a 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -443,7 +443,7 @@ import { ref, watch, computed } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { adminAPI } from '@/api/admin' -import type { Proxy, Group, Account } from '@/types' +import type { Proxy, Group } from '@/types' import Modal from '@/components/common/Modal.vue' import Select from '@/components/common/Select.vue' import ProxySelector from '@/components/common/ProxySelector.vue' @@ -496,7 +496,6 @@ const concurrency = ref(1) const priority = ref(1) const status = ref<'active' | 'inactive'>('active') const groupIds = ref([]) -const accountCache = ref>({}) // All models list (combined Anthropic + OpenAI) const allModels = [ @@ -613,22 +612,10 @@ const buildModelMappingObject = (): Record | null => { return Object.keys(mapping).length > 0 ? mapping : null } -const getDefaultBaseUrl = (platform: string) => { - return platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com' -} - -const getAccountDetails = async (accountId: number): Promise => { - if (accountCache.value[accountId]) return accountCache.value[accountId] - const account = await adminAPI.accounts.getById(accountId) - accountCache.value[accountId] = account - return account -} - -const buildUpdatePayload = (account: Account): Record | null => { +const buildUpdatePayload = (): Record | null => { const updates: Record = {} - let credentials: Record | null = null + const credentials: Record = {} let credentialsChanged = false - const isAnthropic = account.platform === 'anthropic' if (enableProxy.value) { updates.proxy_id = proxyId.value @@ -650,47 +637,34 @@ const buildUpdatePayload = (account: Account): Record | null => updates.group_ids = groupIds.value } - if (account.type === 'apikey') { - const baseCredentials = (account.credentials || {}) as Record - credentials = { ...baseCredentials } - - if (enableBaseUrl.value) { - credentials.base_url = baseUrl.value.trim() || getDefaultBaseUrl(account.platform) + if (enableBaseUrl.value) { + const baseUrlValue = baseUrl.value.trim() + if (baseUrlValue) { + credentials.base_url = baseUrlValue credentialsChanged = true } + } - if (enableModelRestriction.value) { - const modelMapping = buildModelMappingObject() - if (modelMapping) { - credentials.model_mapping = modelMapping - } else { - delete credentials.model_mapping - } + if (enableModelRestriction.value) { + const modelMapping = buildModelMappingObject() + if (modelMapping) { + credentials.model_mapping = modelMapping credentialsChanged = true } + } - if (enableCustomErrorCodes.value) { - credentials.custom_error_codes_enabled = true - credentials.custom_error_codes = [...selectedErrorCodes.value] - credentialsChanged = true - } - - if (enableInterceptWarmup.value && isAnthropic) { - credentials.intercept_warmup_requests = interceptWarmupRequests.value - credentialsChanged = true - } - } else if (enableInterceptWarmup.value && isAnthropic) { - const baseCredentials = (account.credentials || {}) as Record - credentials = { ...baseCredentials } - if (interceptWarmupRequests.value) { - credentials.intercept_warmup_requests = true - } else { - delete credentials.intercept_warmup_requests - } + if (enableCustomErrorCodes.value) { + credentials.custom_error_codes_enabled = true + credentials.custom_error_codes = [...selectedErrorCodes.value] credentialsChanged = true } - if (credentials && credentialsChanged) { + if (enableInterceptWarmup.value) { + credentials.intercept_warmup_requests = interceptWarmupRequests.value + credentialsChanged = true + } + + if (credentialsChanged) { updates.credentials = credentials } @@ -722,39 +696,37 @@ const handleSubmit = async () => { return } + const updates = buildUpdatePayload() + if (!updates) { + appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected')) + return + } + submitting.value = true - let success = 0 - let failed = 0 - for (const accountId of props.accountIds) { - try { - const account = await getAccountDetails(accountId) - const updates = buildUpdatePayload(account) - if (!updates) { - continue - } - await adminAPI.accounts.update(accountId, updates) - success++ - } catch (error: any) { - failed++ - console.error(`Error bulk updating account ${accountId}:`, error) + try { + const res = await adminAPI.accounts.bulkUpdate(props.accountIds, updates) + const success = res.success || 0 + const failed = res.failed || 0 + + if (success > 0 && failed === 0) { + appStore.showSuccess(t('admin.accounts.bulkEdit.success', { count: success })) + } else if (success > 0) { + appStore.showError(t('admin.accounts.bulkEdit.partialSuccess', { success, failed })) + } else { + appStore.showError(t('admin.accounts.bulkEdit.failed')) } - } - if (success > 0 && failed === 0) { - appStore.showSuccess(t('admin.accounts.bulkEdit.success', { count: success })) - } else if (success > 0) { - appStore.showError(t('admin.accounts.bulkEdit.partialSuccess', { success, failed })) - } else { - appStore.showError(t('admin.accounts.bulkEdit.failed')) + if (success > 0) { + emit('updated') + handleClose() + } + } catch (error: any) { + appStore.showError(error.response?.data?.detail || t('admin.accounts.bulkEdit.failed')) + console.error('Error bulk updating accounts:', error) + } finally { + submitting.value = false } - - if (success > 0) { - emit('updated') - handleClose() - } - - submitting.value = false } // Reset form when modal closes @@ -784,7 +756,6 @@ watch(() => props.show, (newShow) => { priority.value = 1 status.value = 'active' groupIds.value = [] - accountCache.value = {} } }) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index a408733a..c4f6af71 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -756,7 +756,7 @@ export default { title: 'Bulk Edit Accounts', selectionInfo: '{count} account(s) selected. Only checked or filled fields will be updated; others stay unchanged.', baseUrlPlaceholder: 'https://api.anthropic.com or https://api.openai.com', - baseUrlNotice: 'Applies to API Key accounts only; leave empty to use the platform default', + baseUrlNotice: 'Applies to API Key accounts only; leave empty to keep existing value', submit: 'Update Accounts', updating: 'Updating...', success: 'Updated {count} account(s)', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index bf128856..04e9446e 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -887,7 +887,7 @@ export default { title: '批量编辑账号', selectionInfo: '已选择 {count} 个账号。只更新您勾选或填写的字段,未勾选的字段保持不变。', baseUrlPlaceholder: 'https://api.anthropic.com 或 https://api.openai.com', - baseUrlNotice: '仅适用于 API Key 账号,留空使用对应平台默认地址', + baseUrlNotice: '仅适用于 API Key 账号,留空则不修改', submit: '批量更新', updating: '更新中...', success: '成功更新 {count} 个账号',