Merge pull request #897 from DaydreamCoding/feat/batch-reset-and-openai-jwt

feat: OpenAI 账号信息增强 & 批量操作支持
This commit is contained in:
Wesley Liddick
2026-03-10 09:03:59 +08:00
committed by GitHub
11 changed files with 418 additions and 72 deletions

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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<BatchOperationResult> {
const { data } = await apiClient.post<BatchOperationResult>('/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<BatchOperationResult> {
const { data } = await apiClient.post<BatchOperationResult>('/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

View File

@@ -20,6 +20,8 @@
</div>
<div class="flex gap-2">
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button>
<button @click="$emit('reset-status')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.resetStatus') }}</button>
<button @click="$emit('refresh-token')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.refreshToken') }}</button>
<button @click="$emit('toggle-schedulable', true)" class="btn btn-success btn-sm">{{ t('admin.accounts.bulkActions.enableScheduling') }}</button>
<button @click="$emit('toggle-schedulable', false)" class="btn btn-warning btn-sm">{{ t('admin.accounts.bulkActions.disableScheduling') }}</button>
<button @click="$emit('edit')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button>
@@ -29,5 +31,5 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
defineProps(['selectedIds']); defineEmits(['delete', 'edit', 'clear', 'select-page', 'toggle-schedulable']); const { t } = useI18n()
</script>
defineProps(['selectedIds']); defineEmits(['delete', 'edit', 'clear', 'select-page', 'toggle-schedulable', 'reset-status', 'refresh-token']); const { t } = useI18n()
</script>

View File

@@ -28,6 +28,10 @@
<Icon v-else name="key" size="xs" />
<span>{{ typeLabel }}</span>
</span>
<!-- Plan type part (optional) -->
<span v-if="planLabel" :class="['inline-flex items-center gap-1 px-1.5 py-1 border-l border-white/20', typeClass]">
<span>{{ planLabel }}</span>
</span>
</div>
</template>
@@ -40,6 +44,7 @@ import Icon from '@/components/icons/Icon.vue'
interface Props {
platform: AccountPlatform
type: AccountType
planType?: string
}
const props = defineProps<Props>()
@@ -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'

View File

@@ -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',

View File

@@ -1983,7 +1983,12 @@ export default {
edit: '批量编辑账号',
delete: '批量删除',
enableScheduling: '批量启用调度',
disableScheduling: '批量停止调度'
disableScheduling: '批量停止调度',
resetStatus: '批量重置状态',
refreshToken: '批量刷新令牌',
resetStatusSuccess: '已成功重置 {count} 个账号状态',
refreshTokenSuccess: '已成功刷新 {count} 个账号令牌',
partialSuccess: '操作部分完成:{success} 成功,{failed} 失败'
},
bulkEdit: {
title: '批量编辑账号',

View File

@@ -131,7 +131,7 @@
</div>
</template>
<template #table>
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="clearSelection" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @reset-status="handleBulkResetStatus" @refresh-token="handleBulkRefreshToken" @edit="showBulkEdit = true" @clear="clearSelection" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
<div ref="accountTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
<DataTable
:columns="cols"
@@ -171,7 +171,7 @@
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
</template>
<template #cell-platform_type="{ row }">
<PlatformTypeBadge :platform="row.platform" :type="row.type" />
<PlatformTypeBadge :platform="row.platform" :type="row.type" :plan-type="row.credentials?.plan_type" />
</template>
<template #cell-capacity="{ row }">
<AccountCapacityCell :account="row" />
@@ -889,6 +889,38 @@ const toggleSelectAllVisible = (event: Event) => {
toggleVisible(target.checked)
}
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); clearSelection(); reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
const handleBulkResetStatus = async () => {
if (!confirm(t('common.confirm'))) return
try {
const result = await adminAPI.accounts.batchClearError(selIds.value)
if (result.failed > 0) {
appStore.showError(t('admin.accounts.bulkActions.partialSuccess', { success: result.success, failed: result.failed }))
} else {
appStore.showSuccess(t('admin.accounts.bulkActions.resetStatusSuccess', { count: result.success }))
clearSelection()
}
reload()
} catch (error) {
console.error('Failed to bulk reset status:', error)
appStore.showError(String(error))
}
}
const handleBulkRefreshToken = async () => {
if (!confirm(t('common.confirm'))) return
try {
const result = await adminAPI.accounts.batchRefresh(selIds.value)
if (result.failed > 0) {
appStore.showError(t('admin.accounts.bulkActions.partialSuccess', { success: result.success, failed: result.failed }))
} else {
appStore.showSuccess(t('admin.accounts.bulkActions.refreshTokenSuccess', { count: result.success }))
clearSelection()
}
reload()
} catch (error) {
console.error('Failed to bulk refresh token:', error)
appStore.showError(String(error))
}
}
const updateSchedulableInList = (accountIds: number[], schedulable: boolean) => {
if (accountIds.length === 0) return
const idSet = new Set(accountIds)