diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 89855616..34c795e7 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -434,6 +434,94 @@ 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, + }) +} + // ========== OAuth Handlers ========== // GenerateAuthURLRequest represents the request for generating auth URL diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 128ab36a..dfeadd11 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -193,6 +193,7 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable) accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels) accounts.POST("/batch", h.Admin.Account.BatchCreate) + accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials) // Claude OAuth routes accounts.POST("/generate-auth-url", h.Admin.OAuth.GenerateAuthURL) diff --git a/backend/internal/service/crs_sync_service.go b/backend/internal/service/crs_sync_service.go index 590a28d8..427fea16 100644 --- a/backend/internal/service/crs_sync_service.go +++ b/backend/internal/service/crs_sync_service.go @@ -93,6 +93,7 @@ type crsClaudeAccount struct { Status string `json:"status"` Proxy *crsProxy `json:"proxy"` Credentials map[string]any `json:"credentials"` + Extra map[string]any `json:"extra"` } type crsConsoleAccount struct { @@ -137,6 +138,7 @@ type crsOpenAIOAuthAccount struct { Status string `json:"status"` Proxy *crsProxy `json:"proxy"` Credentials map[string]any `json:"credentials"` + Extra map[string]any `json:"extra"` } func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput) (*SyncFromCRSResult, error) { @@ -214,15 +216,28 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput } credentials := sanitizeCredentialsMap(src.Credentials) + // 🔧 Remove /v1 suffix from base_url for Claude accounts + cleanBaseURL(credentials, "/v1") + // 🔧 Convert expires_at from ISO string to Unix timestamp + if expiresAtStr, ok := credentials["expires_at"].(string); ok && expiresAtStr != "" { + if t, err := time.Parse(time.RFC3339, expiresAtStr); err == nil { + credentials["expires_at"] = t.Unix() + } + } + // 🔧 Add intercept_warmup_requests if not present (defaults to false) + if _, exists := credentials["intercept_warmup_requests"]; !exists { + credentials["intercept_warmup_requests"] = false + } priority := clampPriority(src.Priority) concurrency := 3 status := mapCRSStatus(src.IsActive, src.Status) - extra := map[string]any{ - "crs_account_id": src.ID, - "crs_kind": src.Kind, - "crs_synced_at": now, + // 🔧 Use CRS extra data directly, add sync metadata + extra := src.Extra + if extra == nil { + extra = make(map[string]any) } + extra["crs_synced_at"] = now existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID) if err != nil { @@ -260,17 +275,14 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput } // Update existing - if existing.Extra == nil { - existing.Extra = make(model.JSONB) - } - for k, v := range extra { - existing.Extra[k] = v - } + existing.Extra = mergeJSONB(existing.Extra, extra) existing.Name = defaultName(src.Name, src.ID) existing.Platform = model.PlatformAnthropic existing.Type = targetType - existing.Credentials = model.JSONB(credentials) - existing.ProxyID = proxyID + existing.Credentials = mergeJSONB(existing.Credentials, credentials) + if proxyID != nil { + existing.ProxyID = proxyID + } existing.Concurrency = concurrency existing.Priority = priority existing.Status = status @@ -364,17 +376,14 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput continue } - if existing.Extra == nil { - existing.Extra = make(model.JSONB) - } - for k, v := range extra { - existing.Extra[k] = v - } + existing.Extra = mergeJSONB(existing.Extra, extra) existing.Name = defaultName(src.Name, src.ID) existing.Platform = model.PlatformAnthropic existing.Type = model.AccountTypeApiKey - existing.Credentials = model.JSONB(credentials) - existing.ProxyID = proxyID + existing.Credentials = mergeJSONB(existing.Credentials, credentials) + if proxyID != nil { + existing.ProxyID = proxyID + } existing.Concurrency = concurrency existing.Priority = priority existing.Status = status @@ -430,15 +439,22 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput if v, ok := credentials["token_type"].(string); !ok || strings.TrimSpace(v) == "" { credentials["token_type"] = "Bearer" } + // 🔧 Convert expires_at from ISO string to Unix timestamp + if expiresAtStr, ok := credentials["expires_at"].(string); ok && expiresAtStr != "" { + if t, err := time.Parse(time.RFC3339, expiresAtStr); err == nil { + credentials["expires_at"] = t.Unix() + } + } priority := clampPriority(src.Priority) concurrency := 3 status := mapCRSStatus(src.IsActive, src.Status) - extra := map[string]any{ - "crs_account_id": src.ID, - "crs_kind": src.Kind, - "crs_synced_at": now, + // 🔧 Use CRS extra data directly, add sync metadata + extra := src.Extra + if extra == nil { + extra = make(map[string]any) } + extra["crs_synced_at"] = now existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID) if err != nil { @@ -475,17 +491,14 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput continue } - if existing.Extra == nil { - existing.Extra = make(model.JSONB) - } - for k, v := range extra { - existing.Extra[k] = v - } + existing.Extra = mergeJSONB(existing.Extra, extra) existing.Name = defaultName(src.Name, src.ID) existing.Platform = model.PlatformOpenAI existing.Type = model.AccountTypeOAuth - existing.Credentials = model.JSONB(credentials) - existing.ProxyID = proxyID + existing.Credentials = mergeJSONB(existing.Credentials, credentials) + if proxyID != nil { + existing.ProxyID = proxyID + } existing.Concurrency = concurrency existing.Priority = priority existing.Status = status @@ -524,6 +537,8 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput if baseURL, ok := src.Credentials["base_url"].(string); !ok || strings.TrimSpace(baseURL) == "" { src.Credentials["base_url"] = "https://api.openai.com" } + // 🔧 Remove /v1 suffix from base_url for OpenAI accounts + cleanBaseURL(src.Credentials, "/v1") proxyID, err := s.mapOrCreateProxy( ctx, @@ -586,17 +601,14 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput continue } - if existing.Extra == nil { - existing.Extra = make(model.JSONB) - } - for k, v := range extra { - existing.Extra[k] = v - } + existing.Extra = mergeJSONB(existing.Extra, extra) existing.Name = defaultName(src.Name, src.ID) existing.Platform = model.PlatformOpenAI existing.Type = model.AccountTypeApiKey - existing.Credentials = model.JSONB(credentials) - existing.ProxyID = proxyID + existing.Credentials = mergeJSONB(existing.Credentials, credentials) + if proxyID != nil { + existing.ProxyID = proxyID + } existing.Concurrency = concurrency existing.Priority = priority existing.Status = status @@ -618,6 +630,18 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput return result, nil } +// mergeJSONB merges two JSONB maps without removing keys that are absent in updates. +func mergeJSONB(existing model.JSONB, updates map[string]any) model.JSONB { + out := make(model.JSONB) + for k, v := range existing { + out[k] = v + } + for k, v := range updates { + out[k] = v + } + return out +} + func (s *CRSSyncService) mapOrCreateProxy(ctx context.Context, enabled bool, cached *[]model.Proxy, src *crsProxy, defaultName string) (*int64, error) { if !enabled || src == nil { return nil, nil @@ -731,6 +755,17 @@ func normalizeBaseURL(raw string) (string, error) { return strings.TrimRight(u.String(), "/"), nil } +// cleanBaseURL removes trailing suffix from base_url in credentials +// Used for both Claude and OpenAI accounts to remove /v1 +func cleanBaseURL(credentials map[string]any, suffixToRemove string) { + if baseURL, ok := credentials["base_url"].(string); ok && baseURL != "" { + trimmed := strings.TrimSpace(baseURL) + if strings.HasSuffix(trimmed, suffixToRemove) { + credentials["base_url"] = strings.TrimSuffix(trimmed, suffixToRemove) + } + } +} + func crsLogin(ctx context.Context, client *http.Client, baseURL, username, password string) (string, error) { payload := map[string]any{ "username": username, diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index fa8e8823..59a91969 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -16,7 +16,7 @@ services: # Sub2API Application # =========================================================================== sub2api: - image: weishaw/sub2api:latest + image: sub2api:latest container_name: sub2api restart: unless-stopped ports: @@ -114,6 +114,8 @@ services: timeout: 5s retries: 5 start_period: 10s + ports: + - 5433:5432 # =========================================================================== # Redis Cache diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 6f5dd8f3..60de2492 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -213,6 +213,53 @@ export async function batchCreate(accounts: CreateAccountRequest[]): Promise<{ return data; } +/** + * Batch update credentials fields for multiple accounts + * @param request - Batch update request containing account IDs, field name, and value + * @returns Results of batch update + */ +export async function batchUpdateCredentials(request: { + account_ids: number[]; + field: string; + value: any; +}): Promise<{ + success: number; + failed: number; + results: Array<{ account_id: number; success: boolean; error?: string }>; +}> { + const { data} = await apiClient.post<{ + success: number; + failed: number; + results: Array<{ account_id: number; success: boolean; error?: string }>; + }>('/admin/accounts/batch-update-credentials', request); + return data; +} + +/** + * Bulk update multiple accounts + * @param accountIds - Array of account IDs + * @param updates - Fields to update + * @returns Success confirmation + */ +export async function bulkUpdate( + accountIds: number[], + updates: Record +): Promise<{ + success: number; + failed: number; + results: Array<{ account_id: number; success: boolean; error?: string }>; +}> { + const { data } = await apiClient.post<{ + success: number; + failed: number; + results: Array<{ account_id: number; success: boolean; error?: string }>; + }>('/admin/accounts/bulk-update', { + account_ids: accountIds, + updates + }); + return data; +} + /** * Get account today statistics * @param id - Account ID @@ -285,6 +332,8 @@ export const accountsAPI = { generateAuthUrl, exchangeCode, batchCreate, + batchUpdateCredentials, + bulkUpdate, syncFromCrs, }; diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue new file mode 100644 index 00000000..e24bd04f --- /dev/null +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -0,0 +1,790 @@ + + + diff --git a/frontend/src/components/account/SyncFromCrsModal.vue b/frontend/src/components/account/SyncFromCrsModal.vue index 5cb9b00b..d1f75fc7 100644 --- a/frontend/src/components/account/SyncFromCrsModal.vue +++ b/frontend/src/components/account/SyncFromCrsModal.vue @@ -10,6 +10,9 @@
{{ t('admin.accounts.syncFromCrsDesc') }}
+
+ 已有账号仅同步 CRS 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选“同步代理”时保留原有代理。 +
@@ -162,4 +165,3 @@ const handleSync = async () => { } } - diff --git a/frontend/src/components/account/index.ts b/frontend/src/components/account/index.ts index e0463d02..d2e0493a 100644 --- a/frontend/src/components/account/index.ts +++ b/frontend/src/components/account/index.ts @@ -1,5 +1,6 @@ export { default as CreateAccountModal } from './CreateAccountModal.vue' export { default as EditAccountModal } from './EditAccountModal.vue' +export { default as BulkEditAccountModal } from './BulkEditAccountModal.vue' export { default as ReAuthAccountModal } from './ReAuthAccountModal.vue' export { default as OAuthAuthorizationFlow } from './OAuthAuthorizationFlow.vue' export { default as AccountStatusIndicator } from './AccountStatusIndicator.vue' diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 03427645..a408733a 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -745,6 +745,31 @@ export default { tokenRefreshed: 'Token refreshed successfully', accountDeleted: 'Account deleted successfully', rateLimitCleared: 'Rate limit cleared successfully', + bulkActions: { + selected: '{count} account(s) selected', + selectCurrentPage: 'Select this page', + clear: 'Clear selection', + edit: 'Bulk Edit', + delete: 'Bulk Delete', + }, + bulkEdit: { + title: 'Bulk Edit Accounts', + selectionInfo: '{count} account(s) selected. Only checked or filled fields will be updated; others stay unchanged.', + baseUrlPlaceholder: 'https://api.anthropic.com or https://api.openai.com', + baseUrlNotice: 'Applies to API Key accounts only; leave empty to use the platform default', + submit: 'Update Accounts', + updating: 'Updating...', + success: 'Updated {count} account(s)', + partialSuccess: 'Partially updated: {success} succeeded, {failed} failed', + failed: 'Bulk update failed', + noSelection: 'Please select accounts to edit', + noFieldsSelected: 'Select at least one field to update', + }, + bulkDeleteTitle: 'Bulk Delete Accounts', + bulkDeleteConfirm: 'Delete the selected {count} account(s)? This action cannot be undone.', + bulkDeleteSuccess: 'Deleted {count} account(s)', + bulkDeletePartial: 'Partially deleted: {success} succeeded, {failed} failed', + bulkDeleteFailed: 'Bulk delete failed', resetStatus: 'Reset Status', statusReset: 'Account status reset successfully', failedToResetStatus: 'Failed to reset account status', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index b406f709..bf128856 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -876,6 +876,31 @@ export default { accountCreatedSuccess: '账号添加成功', accountUpdatedSuccess: '账号更新成功', accountDeletedSuccess: '账号删除成功', + bulkActions: { + selected: '已选择 {count} 个账号', + selectCurrentPage: '本页全选', + clear: '清除选择', + edit: '批量编辑账号', + delete: '批量删除', + }, + bulkEdit: { + title: '批量编辑账号', + selectionInfo: '已选择 {count} 个账号。只更新您勾选或填写的字段,未勾选的字段保持不变。', + baseUrlPlaceholder: 'https://api.anthropic.com 或 https://api.openai.com', + baseUrlNotice: '仅适用于 API Key 账号,留空使用对应平台默认地址', + submit: '批量更新', + updating: '更新中...', + success: '成功更新 {count} 个账号', + partialSuccess: '部分更新成功:成功 {success} 个,失败 {failed} 个', + failed: '批量更新失败', + noSelection: '请选择要编辑的账号', + noFieldsSelected: '请至少选择一个要更新的字段', + }, + bulkDeleteTitle: '批量删除账号', + bulkDeleteConfirm: '确定要删除选中的 {count} 个账号吗?此操作无法撤销。', + bulkDeleteSuccess: '成功删除 {count} 个账号', + bulkDeletePartial: '部分删除成功:成功 {success} 个,失败 {failed} 个', + bulkDeleteFailed: '批量删除失败', resetStatus: '重置状态', statusReset: '账号状态已重置', failedToResetStatus: '重置账号状态失败', diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 5980b379..664e0c15 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -19,11 +19,11 @@
+ +
+
+
+ + {{ t('admin.accounts.bulkActions.selected', { count: selectedAccountIds.length }) }} + + + + +
+
+ + +
+
+
+
+ + @@ -324,12 +378,32 @@ @confirm="confirmDelete" @cancel="showDeleteDialog = false" /> + + + + @@ -346,7 +420,7 @@ import Pagination from '@/components/common/Pagination.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import EmptyState from '@/components/common/EmptyState.vue' import Select from '@/components/common/Select.vue' -import { CreateAccountModal, EditAccountModal, ReAuthAccountModal, AccountStatsModal, SyncFromCrsModal } from '@/components/account' +import { CreateAccountModal, EditAccountModal, BulkEditAccountModal, ReAuthAccountModal, AccountStatsModal, SyncFromCrsModal } from '@/components/account' import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue' import AccountUsageCell from '@/components/account/AccountUsageCell.vue' import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue' @@ -360,6 +434,7 @@ const appStore = useAppStore() // Table columns const columns = computed(() => [ + { key: 'select', label: '', sortable: false }, { key: 'name', label: t('admin.accounts.columns.name'), sortable: true }, { key: 'platform_type', label: t('admin.accounts.columns.platformType'), sortable: false }, { key: 'concurrency', label: t('admin.accounts.columns.concurrencyStatus'), sortable: false }, @@ -417,15 +492,26 @@ const showCreateModal = ref(false) const showEditModal = ref(false) const showReAuthModal = ref(false) const showDeleteDialog = ref(false) +const showBulkDeleteDialog = ref(false) const showTestModal = ref(false) const showStatsModal = ref(false) const showCrsSyncModal = ref(false) +const showBulkEditModal = ref(false) const editingAccount = ref(null) const reAuthAccount = ref(null) const deletingAccount = ref(null) const testingAccount = ref(null) const statsAccount = ref(null) const togglingSchedulable = ref(null) +const bulkDeleting = ref(false) + +// Bulk selection +const selectedAccountIds = ref([]) +const selectCurrentPageAccounts = () => { + const pageIds = accounts.value.map(account => account.id) + const merged = new Set([...selectedAccountIds.value, ...pageIds]) + selectedAccountIds.value = Array.from(merged) +} // Rate limit / Overload helpers const isRateLimited = (account: Account): boolean => { @@ -556,6 +642,38 @@ const confirmDelete = async () => { } } +const handleBulkDelete = () => { + if (selectedAccountIds.value.length === 0) return + showBulkDeleteDialog.value = true +} + +const confirmBulkDelete = async () => { + if (bulkDeleting.value || selectedAccountIds.value.length === 0) return + + bulkDeleting.value = true + const ids = [...selectedAccountIds.value] + try { + const results = await Promise.allSettled(ids.map(id => adminAPI.accounts.delete(id))) + const success = results.filter(result => result.status === 'fulfilled').length + const failed = results.length - success + + if (failed === 0) { + appStore.showSuccess(t('admin.accounts.bulkDeleteSuccess', { count: success })) + } else { + appStore.showError(t('admin.accounts.bulkDeletePartial', { success, failed })) + } + + showBulkDeleteDialog.value = false + selectedAccountIds.value = [] + loadAccounts() + } catch (error: any) { + appStore.showError(error.response?.data?.detail || t('admin.accounts.bulkDeleteFailed')) + console.error('Error deleting accounts:', error) + } finally { + bulkDeleting.value = false + } +} + // Clear rate limit const handleClearRateLimit = async (account: Account) => { try { @@ -629,6 +747,23 @@ const closeStatsModal = () => { statsAccount.value = null } +// Bulk selection toggle +const toggleAccountSelection = (accountId: number) => { + const index = selectedAccountIds.value.indexOf(accountId) + if (index === -1) { + selectedAccountIds.value.push(accountId) + } else { + selectedAccountIds.value.splice(index, 1) + } +} + +// Bulk update handler +const handleBulkUpdated = () => { + showBulkEditModal.value = false + selectedAccountIds.value = [] + loadAccounts() +} + // Initialize onMounted(() => { loadAccounts()