diff --git a/backend/internal/handler/admin/account_data.go b/backend/internal/handler/admin/account_data.go index 4ce17219..fbac73d3 100644 --- a/backend/internal/handler/admin/account_data.go +++ b/backend/internal/handler/admin/account_data.go @@ -8,6 +8,9 @@ import ( "strings" "time" + "log/slog" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -292,6 +295,8 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest) } } + enrichCredentialsFromIDToken(&item) + accountInput := &service.CreateAccountInput{ Name: item.Name, Notes: item.Notes, @@ -535,6 +540,57 @@ func defaultProxyName(name string) string { return name } +// enrichCredentialsFromIDToken performs best-effort extraction of user info fields +// (email, plan_type, chatgpt_account_id, etc.) from id_token in credentials. +// Only applies to OpenAI/Sora OAuth accounts. Skips expired token errors silently. +// Existing credential values are never overwritten — only missing fields are filled. +func enrichCredentialsFromIDToken(item *DataAccount) { + if item.Credentials == nil { + return + } + // Only enrich OpenAI/Sora OAuth accounts + platform := strings.ToLower(strings.TrimSpace(item.Platform)) + if platform != service.PlatformOpenAI && platform != service.PlatformSora { + return + } + if strings.ToLower(strings.TrimSpace(item.Type)) != service.AccountTypeOAuth { + return + } + + idToken, _ := item.Credentials["id_token"].(string) + if strings.TrimSpace(idToken) == "" { + return + } + + // DecodeIDToken skips expiry validation — safe for imported data + claims, err := openai.DecodeIDToken(idToken) + if err != nil { + slog.Debug("import_enrich_id_token_decode_failed", "account", item.Name, "error", err) + return + } + + userInfo := claims.GetUserInfo() + if userInfo == nil { + return + } + + // Fill missing fields only (never overwrite existing values) + setIfMissing := func(key, value string) { + if value == "" { + return + } + if existing, _ := item.Credentials[key].(string); existing == "" { + item.Credentials[key] = value + } + } + + setIfMissing("email", userInfo.Email) + setIfMissing("plan_type", userInfo.PlanType) + setIfMissing("chatgpt_account_id", userInfo.ChatGPTAccountID) + setIfMissing("chatgpt_user_id", userInfo.ChatGPTUserID) + setIfMissing("organization_id", userInfo.OrganizationID) +} + func normalizeProxyStatus(status string) string { normalized := strings.TrimSpace(strings.ToLower(status)) switch normalized { diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 3431b6dd..fad8a33c 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net/http" "strconv" "strings" @@ -18,6 +19,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/response" @@ -751,52 +753,31 @@ func (h *AccountHandler) PreviewFromCRS(c *gin.Context) { response.Success(c, result) } -// Refresh handles refreshing account credentials -// POST /api/v1/admin/accounts/:id/refresh -func (h *AccountHandler) Refresh(c *gin.Context) { - accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) - if err != nil { - response.BadRequest(c, "Invalid account ID") - return - } - - // Get account - account, err := h.adminService.GetAccount(c.Request.Context(), accountID) - if err != nil { - response.NotFound(c, "Account not found") - return - } - - // Only refresh OAuth-based accounts (oauth and setup-token) +// refreshSingleAccount refreshes credentials for a single OAuth account. +// Returns (updatedAccount, warning, error) where warning is used for Antigravity ProjectIDMissing scenario. +func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *service.Account) (*service.Account, string, error) { if !account.IsOAuth() { - response.BadRequest(c, "Cannot refresh non-OAuth account credentials") - return + return nil, "", infraerrors.BadRequest("NOT_OAUTH", "cannot refresh non-OAuth account") } var newCredentials map[string]any if account.IsOpenAI() { - // Use OpenAI OAuth service to refresh token - tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(c.Request.Context(), account) + tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(ctx, account) if err != nil { - response.ErrorFrom(c, err) - return + return nil, "", err } - // Build new credentials from token info newCredentials = h.openaiOAuthService.BuildAccountCredentials(tokenInfo) - - // Preserve non-token settings from existing credentials for k, v := range account.Credentials { if _, exists := newCredentials[k]; !exists { newCredentials[k] = v } } } else if account.Platform == service.PlatformGemini { - tokenInfo, err := h.geminiOAuthService.RefreshAccountToken(c.Request.Context(), account) + tokenInfo, err := h.geminiOAuthService.RefreshAccountToken(ctx, account) if err != nil { - response.InternalError(c, "Failed to refresh credentials: "+err.Error()) - return + return nil, "", fmt.Errorf("failed to refresh credentials: %w", err) } newCredentials = h.geminiOAuthService.BuildAccountCredentials(tokenInfo) @@ -806,10 +787,9 @@ func (h *AccountHandler) Refresh(c *gin.Context) { } } } else if account.Platform == service.PlatformAntigravity { - tokenInfo, err := h.antigravityOAuthService.RefreshAccountToken(c.Request.Context(), account) + tokenInfo, err := h.antigravityOAuthService.RefreshAccountToken(ctx, account) if err != nil { - response.ErrorFrom(c, err) - return + return nil, "", err } newCredentials = h.antigravityOAuthService.BuildAccountCredentials(tokenInfo) @@ -828,37 +808,27 @@ func (h *AccountHandler) Refresh(c *gin.Context) { } // 如果 project_id 获取失败,更新凭证但不标记为 error - // LoadCodeAssist 失败可能是临时网络问题,给它机会在下次自动刷新时重试 if tokenInfo.ProjectIDMissing { - // 先更新凭证(token 本身刷新成功了) - _, updateErr := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{ + updatedAccount, updateErr := h.adminService.UpdateAccount(ctx, account.ID, &service.UpdateAccountInput{ Credentials: newCredentials, }) if updateErr != nil { - response.InternalError(c, "Failed to update credentials: "+updateErr.Error()) - return + return nil, "", fmt.Errorf("failed to update credentials: %w", updateErr) } - // 不标记为 error,只返回警告信息 - response.Success(c, gin.H{ - "message": "Token refreshed successfully, but project_id could not be retrieved (will retry automatically)", - "warning": "missing_project_id_temporary", - }) - return + return updatedAccount, "missing_project_id_temporary", nil } // 成功获取到 project_id,如果之前是 missing_project_id 错误则清除 if account.Status == service.StatusError && strings.Contains(account.ErrorMessage, "missing_project_id:") { - if _, clearErr := h.adminService.ClearAccountError(c.Request.Context(), accountID); clearErr != nil { - response.InternalError(c, "Failed to clear account error: "+clearErr.Error()) - return + if _, clearErr := h.adminService.ClearAccountError(ctx, account.ID); clearErr != nil { + return nil, "", fmt.Errorf("failed to clear account error: %w", clearErr) } } } else { // Use Anthropic/Claude OAuth service to refresh token - tokenInfo, err := h.oauthService.RefreshAccountToken(c.Request.Context(), account) + tokenInfo, err := h.oauthService.RefreshAccountToken(ctx, account) if err != nil { - response.ErrorFrom(c, err) - return + return nil, "", err } // Copy existing credentials to preserve non-token settings (e.g., intercept_warmup_requests) @@ -880,20 +850,51 @@ func (h *AccountHandler) Refresh(c *gin.Context) { } } - updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{ + updatedAccount, err := h.adminService.UpdateAccount(ctx, account.ID, &service.UpdateAccountInput{ Credentials: newCredentials, }) + if err != nil { + return nil, "", err + } + + // 刷新成功后,清除 token 缓存,确保下次请求使用新 token + if h.tokenCacheInvalidator != nil { + if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(ctx, updatedAccount); invalidateErr != nil { + log.Printf("[WARN] Failed to invalidate token cache for account %d: %v", updatedAccount.ID, invalidateErr) + } + } + + return updatedAccount, "", nil +} + +// Refresh handles refreshing account credentials +// POST /api/v1/admin/accounts/:id/refresh +func (h *AccountHandler) Refresh(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + + // Get account + account, err := h.adminService.GetAccount(c.Request.Context(), accountID) + if err != nil { + response.NotFound(c, "Account not found") + return + } + + updatedAccount, warning, err := h.refreshSingleAccount(c.Request.Context(), account) if err != nil { response.ErrorFrom(c, err) return } - // 刷新成功后,清除 token 缓存,确保下次请求使用新 token - if h.tokenCacheInvalidator != nil { - if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(c.Request.Context(), updatedAccount); invalidateErr != nil { - // 缓存失效失败只记录日志,不影响主流程 - _ = c.Error(invalidateErr) - } + if warning == "missing_project_id_temporary" { + response.Success(c, gin.H{ + "message": "Token refreshed successfully, but project_id could not be retrieved (will retry automatically)", + "warning": "missing_project_id_temporary", + }) + return } response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), updatedAccount)) @@ -949,14 +950,175 @@ func (h *AccountHandler) ClearError(c *gin.Context) { // 这解决了管理员重置账号状态后,旧的失效 token 仍在缓存中导致立即再次 401 的问题 if h.tokenCacheInvalidator != nil && account.IsOAuth() { if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(c.Request.Context(), account); invalidateErr != nil { - // 缓存失效失败只记录日志,不影响主流程 - _ = c.Error(invalidateErr) + log.Printf("[WARN] Failed to invalidate token cache for account %d: %v", accountID, invalidateErr) } } response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) } +// BatchClearError handles batch clearing account errors +// POST /api/v1/admin/accounts/batch-clear-error +func (h *AccountHandler) BatchClearError(c *gin.Context) { + var req struct { + AccountIDs []int64 `json:"account_ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if len(req.AccountIDs) == 0 { + response.BadRequest(c, "account_ids is required") + return + } + + ctx := c.Request.Context() + + const maxConcurrency = 10 + g, gctx := errgroup.WithContext(ctx) + g.SetLimit(maxConcurrency) + + var mu sync.Mutex + var successCount, failedCount int + var errors []gin.H + + // 注意:所有 goroutine 必须 return nil,避免 errgroup cancel 其他并发任务 + for _, id := range req.AccountIDs { + accountID := id // 闭包捕获 + g.Go(func() error { + account, err := h.adminService.ClearAccountError(gctx, accountID) + if err != nil { + mu.Lock() + failedCount++ + errors = append(errors, gin.H{ + "account_id": accountID, + "error": err.Error(), + }) + mu.Unlock() + return nil + } + + // 清除错误后,同时清除 token 缓存 + if h.tokenCacheInvalidator != nil && account.IsOAuth() { + if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(gctx, account); invalidateErr != nil { + log.Printf("[WARN] Failed to invalidate token cache for account %d: %v", accountID, invalidateErr) + } + } + + mu.Lock() + successCount++ + mu.Unlock() + return nil + }) + } + + if err := g.Wait(); err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{ + "total": len(req.AccountIDs), + "success": successCount, + "failed": failedCount, + "errors": errors, + }) +} + +// BatchRefresh handles batch refreshing account credentials +// POST /api/v1/admin/accounts/batch-refresh +func (h *AccountHandler) BatchRefresh(c *gin.Context) { + var req struct { + AccountIDs []int64 `json:"account_ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if len(req.AccountIDs) == 0 { + response.BadRequest(c, "account_ids is required") + return + } + + ctx := c.Request.Context() + + accounts, err := h.adminService.GetAccountsByIDs(ctx, req.AccountIDs) + if err != nil { + response.ErrorFrom(c, err) + return + } + + // 建立已获取账号的 ID 集合,检测缺失的 ID + foundIDs := make(map[int64]bool, len(accounts)) + for _, acc := range accounts { + if acc != nil { + foundIDs[acc.ID] = true + } + } + + const maxConcurrency = 10 + g, gctx := errgroup.WithContext(ctx) + g.SetLimit(maxConcurrency) + + var mu sync.Mutex + var successCount, failedCount int + var errors []gin.H + var warnings []gin.H + + // 将不存在的账号 ID 标记为失败 + for _, id := range req.AccountIDs { + if !foundIDs[id] { + failedCount++ + errors = append(errors, gin.H{ + "account_id": id, + "error": "account not found", + }) + } + } + + // 注意:所有 goroutine 必须 return nil,避免 errgroup cancel 其他并发任务 + for _, account := range accounts { + acc := account // 闭包捕获 + if acc == nil { + continue + } + g.Go(func() error { + _, warning, err := h.refreshSingleAccount(gctx, acc) + mu.Lock() + if err != nil { + failedCount++ + errors = append(errors, gin.H{ + "account_id": acc.ID, + "error": err.Error(), + }) + } else { + successCount++ + if warning != "" { + warnings = append(warnings, gin.H{ + "account_id": acc.ID, + "warning": warning, + }) + } + } + mu.Unlock() + return nil + }) + } + + if err := g.Wait(); err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{ + "total": len(req.AccountIDs), + "success": successCount, + "failed": failedCount, + "errors": errors, + "warnings": warnings, + }) +} + // BatchCreate handles batch creating accounts // POST /api/v1/admin/accounts/batch func (h *AccountHandler) BatchCreate(c *gin.Context) { diff --git a/backend/internal/pkg/openai/oauth.go b/backend/internal/pkg/openai/oauth.go index 8bdcbe16..a35a5ea6 100644 --- a/backend/internal/pkg/openai/oauth.go +++ b/backend/internal/pkg/openai/oauth.go @@ -268,6 +268,7 @@ type IDTokenClaims struct { type OpenAIAuthClaims struct { ChatGPTAccountID string `json:"chatgpt_account_id"` ChatGPTUserID string `json:"chatgpt_user_id"` + ChatGPTPlanType string `json:"chatgpt_plan_type"` UserID string `json:"user_id"` Organizations []OrganizationClaim `json:"organizations"` } @@ -325,12 +326,9 @@ func (r *RefreshTokenRequest) ToFormData() string { return params.Encode() } -// ParseIDToken parses the ID Token JWT and extracts claims. -// 注意:当前仅解码 payload 并校验 exp,未验证 JWT 签名。 -// 生产环境如需用 ID Token 做授权决策,应通过 OpenAI 的 JWKS 端点验证签名: -// -// https://auth.openai.com/.well-known/jwks.json -func ParseIDToken(idToken string) (*IDTokenClaims, error) { +// DecodeIDToken decodes the ID Token JWT payload without validating expiration. +// Use this for best-effort extraction (e.g., during data import) where the token may be expired. +func DecodeIDToken(idToken string) (*IDTokenClaims, error) { parts := strings.Split(idToken, ".") if len(parts) != 3 { return nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts)) @@ -360,6 +358,20 @@ func ParseIDToken(idToken string) (*IDTokenClaims, error) { return nil, fmt.Errorf("failed to parse JWT claims: %w", err) } + return &claims, nil +} + +// ParseIDToken parses the ID Token JWT and extracts claims. +// 注意:当前仅解码 payload 并校验 exp,未验证 JWT 签名。 +// 生产环境如需用 ID Token 做授权决策,应通过 OpenAI 的 JWKS 端点验证签名: +// +// https://auth.openai.com/.well-known/jwks.json +func ParseIDToken(idToken string) (*IDTokenClaims, error) { + claims, err := DecodeIDToken(idToken) + if err != nil { + return nil, err + } + // 校验 ID Token 是否已过期(允许 2 分钟时钟偏差,防止因服务器时钟略有差异误判刚颁发的令牌) const clockSkewTolerance = 120 // 秒 now := time.Now().Unix() @@ -367,7 +379,7 @@ func ParseIDToken(idToken string) (*IDTokenClaims, error) { return nil, fmt.Errorf("id_token has expired (exp: %d, now: %d, skew_tolerance: %ds)", claims.Exp, now, clockSkewTolerance) } - return &claims, nil + return claims, nil } // UserInfo represents user information extracted from ID Token claims. @@ -375,6 +387,7 @@ type UserInfo struct { Email string ChatGPTAccountID string ChatGPTUserID string + PlanType string UserID string OrganizationID string Organizations []OrganizationClaim @@ -389,6 +402,7 @@ func (c *IDTokenClaims) GetUserInfo() *UserInfo { if c.OpenAIAuth != nil { info.ChatGPTAccountID = c.OpenAIAuth.ChatGPTAccountID info.ChatGPTUserID = c.OpenAIAuth.ChatGPTUserID + info.PlanType = c.OpenAIAuth.ChatGPTPlanType info.UserID = c.OpenAIAuth.UserID info.Organizations = c.OpenAIAuth.Organizations diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index a7962c2b..5f4a0784 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -264,6 +264,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials) accounts.POST("/batch-refresh-tier", h.Admin.Account.BatchRefreshTier) accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate) + accounts.POST("/batch-clear-error", h.Admin.Account.BatchClearError) + accounts.POST("/batch-refresh", h.Admin.Account.BatchRefresh) // Antigravity 默认模型映射 accounts.GET("/antigravity/default-model-mapping", h.Admin.Account.GetAntigravityDefaultModelMapping) diff --git a/backend/internal/service/openai_oauth_service.go b/backend/internal/service/openai_oauth_service.go index 72f4bbb0..bd82e107 100644 --- a/backend/internal/service/openai_oauth_service.go +++ b/backend/internal/service/openai_oauth_service.go @@ -130,6 +130,7 @@ type OpenAITokenInfo struct { ChatGPTAccountID string `json:"chatgpt_account_id,omitempty"` ChatGPTUserID string `json:"chatgpt_user_id,omitempty"` OrganizationID string `json:"organization_id,omitempty"` + PlanType string `json:"plan_type,omitempty"` } // ExchangeCode exchanges authorization code for tokens @@ -202,6 +203,7 @@ func (s *OpenAIOAuthService) ExchangeCode(ctx context.Context, input *OpenAIExch tokenInfo.ChatGPTAccountID = userInfo.ChatGPTAccountID tokenInfo.ChatGPTUserID = userInfo.ChatGPTUserID tokenInfo.OrganizationID = userInfo.OrganizationID + tokenInfo.PlanType = userInfo.PlanType } return tokenInfo, nil @@ -246,6 +248,7 @@ func (s *OpenAIOAuthService) RefreshTokenWithClientID(ctx context.Context, refre tokenInfo.ChatGPTAccountID = userInfo.ChatGPTAccountID tokenInfo.ChatGPTUserID = userInfo.ChatGPTUserID tokenInfo.OrganizationID = userInfo.OrganizationID + tokenInfo.PlanType = userInfo.PlanType } return tokenInfo, nil @@ -510,6 +513,9 @@ func (s *OpenAIOAuthService) BuildAccountCredentials(tokenInfo *OpenAITokenInfo) if tokenInfo.OrganizationID != "" { creds["organization_id"] = tokenInfo.OrganizationID } + if tokenInfo.PlanType != "" { + creds["plan_type"] = tokenInfo.PlanType + } if strings.TrimSpace(tokenInfo.ClientID) != "" { creds["client_id"] = strings.TrimSpace(tokenInfo.ClientID) } diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 017963a0..23d50d3a 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -581,6 +581,43 @@ export async function validateSoraSessionToken( return data } +/** + * Batch operation result type + */ +export interface BatchOperationResult { + total: number + success: number + failed: number + errors?: Array<{ account_id: number; error: string }> + warnings?: Array<{ account_id: number; warning: string }> +} + +/** + * Batch clear account errors + * @param accountIds - Array of account IDs + * @returns Batch operation result + */ +export async function batchClearError(accountIds: number[]): Promise { + const { data } = await apiClient.post('/admin/accounts/batch-clear-error', { + account_ids: accountIds + }) + return data +} + +/** + * Batch refresh account credentials + * @param accountIds - Array of account IDs + * @returns Batch operation result + */ +export async function batchRefresh(accountIds: number[]): Promise { + const { data } = await apiClient.post('/admin/accounts/batch-refresh', { + account_ids: accountIds, + }, { + timeout: 120000 // 120s timeout for large batch refreshes + }) + return data +} + export const accountsAPI = { list, listWithEtag, @@ -615,7 +652,9 @@ export const accountsAPI = { syncFromCrs, exportData, importData, - getAntigravityDefaultModelMapping + getAntigravityDefaultModelMapping, + batchClearError, + batchRefresh } export default accountsAPI diff --git a/frontend/src/components/admin/account/AccountBulkActionsBar.vue b/frontend/src/components/admin/account/AccountBulkActionsBar.vue index 41111484..3b987bd0 100644 --- a/frontend/src/components/admin/account/AccountBulkActionsBar.vue +++ b/frontend/src/components/admin/account/AccountBulkActionsBar.vue @@ -20,6 +20,8 @@
+ + @@ -29,5 +31,5 @@ \ No newline at end of file +defineProps(['selectedIds']); defineEmits(['delete', 'edit', 'clear', 'select-page', 'toggle-schedulable', 'reset-status', 'refresh-token']); const { t } = useI18n() + diff --git a/frontend/src/components/common/PlatformTypeBadge.vue b/frontend/src/components/common/PlatformTypeBadge.vue index d0f0a6b2..fd035a5c 100644 --- a/frontend/src/components/common/PlatformTypeBadge.vue +++ b/frontend/src/components/common/PlatformTypeBadge.vue @@ -28,6 +28,10 @@ {{ typeLabel }} + + + {{ planLabel }} +
@@ -40,6 +44,7 @@ import Icon from '@/components/icons/Icon.vue' interface Props { platform: AccountPlatform type: AccountType + planType?: string } const props = defineProps() @@ -65,6 +70,24 @@ const typeLabel = computed(() => { } }) +const planLabel = computed(() => { + if (!props.planType) return '' + const lower = props.planType.toLowerCase() + switch (lower) { + case 'plus': + return 'Plus' + case 'team': + return 'Team' + case 'chatgptpro': + case 'pro': + return 'Pro' + case 'free': + return 'Free' + default: + return props.planType + } +}) + const platformClass = computed(() => { if (props.platform === 'anthropic') { return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 81688ca4..be6aff35 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1836,7 +1836,12 @@ export default { edit: 'Bulk Edit', delete: 'Bulk Delete', enableScheduling: 'Enable Scheduling', - disableScheduling: 'Disable Scheduling' + disableScheduling: 'Disable Scheduling', + resetStatus: 'Reset Status', + refreshToken: 'Refresh Token', + resetStatusSuccess: 'Successfully reset {count} account(s) status', + refreshTokenSuccess: 'Successfully refreshed {count} account(s) token', + partialSuccess: 'Partially completed: {success} succeeded, {failed} failed' }, bulkEdit: { title: 'Bulk Edit Accounts', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index c7fcb956..949d51ea 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1983,7 +1983,12 @@ export default { edit: '批量编辑账号', delete: '批量删除', enableScheduling: '批量启用调度', - disableScheduling: '批量停止调度' + disableScheduling: '批量停止调度', + resetStatus: '批量重置状态', + refreshToken: '批量刷新令牌', + resetStatusSuccess: '已成功重置 {count} 个账号状态', + refreshTokenSuccess: '已成功刷新 {count} 个账号令牌', + partialSuccess: '操作部分完成:{success} 成功,{failed} 失败' }, bulkEdit: { title: '批量编辑账号', diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index fa757579..f5aff935 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -131,7 +131,7 @@