feat(account): 添加批量编辑账户凭据功能并优化 CRS 同步
- 新增批量更新账户凭据接口(account_uuid/org_uuid/intercept_warmup_requests) - 新增前端批量编辑模态框组件 - 优化 CRS 同步逻辑,改进 extra 字段处理 - 优化 CRS 同步 UI,添加更详细的结果展示 - 完善国际化文案(中英文)
This commit is contained in:
@@ -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 ==========
|
// ========== OAuth Handlers ==========
|
||||||
|
|
||||||
// GenerateAuthURLRequest represents the request for generating auth URL
|
// GenerateAuthURLRequest represents the request for generating auth URL
|
||||||
|
|||||||
@@ -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.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)
|
||||||
|
|
||||||
// Claude OAuth routes
|
// Claude OAuth routes
|
||||||
accounts.POST("/generate-auth-url", h.Admin.OAuth.GenerateAuthURL)
|
accounts.POST("/generate-auth-url", h.Admin.OAuth.GenerateAuthURL)
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ type crsClaudeAccount struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Proxy *crsProxy `json:"proxy"`
|
Proxy *crsProxy `json:"proxy"`
|
||||||
Credentials map[string]any `json:"credentials"`
|
Credentials map[string]any `json:"credentials"`
|
||||||
|
Extra map[string]any `json:"extra"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type crsConsoleAccount struct {
|
type crsConsoleAccount struct {
|
||||||
@@ -137,6 +138,7 @@ type crsOpenAIOAuthAccount struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Proxy *crsProxy `json:"proxy"`
|
Proxy *crsProxy `json:"proxy"`
|
||||||
Credentials map[string]any `json:"credentials"`
|
Credentials map[string]any `json:"credentials"`
|
||||||
|
Extra map[string]any `json:"extra"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput) (*SyncFromCRSResult, error) {
|
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)
|
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)
|
priority := clampPriority(src.Priority)
|
||||||
concurrency := 3
|
concurrency := 3
|
||||||
status := mapCRSStatus(src.IsActive, src.Status)
|
status := mapCRSStatus(src.IsActive, src.Status)
|
||||||
|
|
||||||
extra := map[string]any{
|
// 🔧 Use CRS extra data directly, add sync metadata
|
||||||
"crs_account_id": src.ID,
|
extra := src.Extra
|
||||||
"crs_kind": src.Kind,
|
if extra == nil {
|
||||||
"crs_synced_at": now,
|
extra = make(map[string]any)
|
||||||
}
|
}
|
||||||
|
extra["crs_synced_at"] = now
|
||||||
|
|
||||||
existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID)
|
existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -260,17 +275,14 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update existing
|
// Update existing
|
||||||
if existing.Extra == nil {
|
existing.Extra = mergeJSONB(existing.Extra, extra)
|
||||||
existing.Extra = make(model.JSONB)
|
|
||||||
}
|
|
||||||
for k, v := range extra {
|
|
||||||
existing.Extra[k] = v
|
|
||||||
}
|
|
||||||
existing.Name = defaultName(src.Name, src.ID)
|
existing.Name = defaultName(src.Name, src.ID)
|
||||||
existing.Platform = model.PlatformAnthropic
|
existing.Platform = model.PlatformAnthropic
|
||||||
existing.Type = targetType
|
existing.Type = targetType
|
||||||
existing.Credentials = model.JSONB(credentials)
|
existing.Credentials = mergeJSONB(existing.Credentials, credentials)
|
||||||
existing.ProxyID = proxyID
|
if proxyID != nil {
|
||||||
|
existing.ProxyID = proxyID
|
||||||
|
}
|
||||||
existing.Concurrency = concurrency
|
existing.Concurrency = concurrency
|
||||||
existing.Priority = priority
|
existing.Priority = priority
|
||||||
existing.Status = status
|
existing.Status = status
|
||||||
@@ -364,17 +376,14 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing.Extra == nil {
|
existing.Extra = mergeJSONB(existing.Extra, extra)
|
||||||
existing.Extra = make(model.JSONB)
|
|
||||||
}
|
|
||||||
for k, v := range extra {
|
|
||||||
existing.Extra[k] = v
|
|
||||||
}
|
|
||||||
existing.Name = defaultName(src.Name, src.ID)
|
existing.Name = defaultName(src.Name, src.ID)
|
||||||
existing.Platform = model.PlatformAnthropic
|
existing.Platform = model.PlatformAnthropic
|
||||||
existing.Type = model.AccountTypeApiKey
|
existing.Type = model.AccountTypeApiKey
|
||||||
existing.Credentials = model.JSONB(credentials)
|
existing.Credentials = mergeJSONB(existing.Credentials, credentials)
|
||||||
existing.ProxyID = proxyID
|
if proxyID != nil {
|
||||||
|
existing.ProxyID = proxyID
|
||||||
|
}
|
||||||
existing.Concurrency = concurrency
|
existing.Concurrency = concurrency
|
||||||
existing.Priority = priority
|
existing.Priority = priority
|
||||||
existing.Status = status
|
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) == "" {
|
if v, ok := credentials["token_type"].(string); !ok || strings.TrimSpace(v) == "" {
|
||||||
credentials["token_type"] = "Bearer"
|
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)
|
priority := clampPriority(src.Priority)
|
||||||
concurrency := 3
|
concurrency := 3
|
||||||
status := mapCRSStatus(src.IsActive, src.Status)
|
status := mapCRSStatus(src.IsActive, src.Status)
|
||||||
|
|
||||||
extra := map[string]any{
|
// 🔧 Use CRS extra data directly, add sync metadata
|
||||||
"crs_account_id": src.ID,
|
extra := src.Extra
|
||||||
"crs_kind": src.Kind,
|
if extra == nil {
|
||||||
"crs_synced_at": now,
|
extra = make(map[string]any)
|
||||||
}
|
}
|
||||||
|
extra["crs_synced_at"] = now
|
||||||
|
|
||||||
existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID)
|
existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -475,17 +491,14 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing.Extra == nil {
|
existing.Extra = mergeJSONB(existing.Extra, extra)
|
||||||
existing.Extra = make(model.JSONB)
|
|
||||||
}
|
|
||||||
for k, v := range extra {
|
|
||||||
existing.Extra[k] = v
|
|
||||||
}
|
|
||||||
existing.Name = defaultName(src.Name, src.ID)
|
existing.Name = defaultName(src.Name, src.ID)
|
||||||
existing.Platform = model.PlatformOpenAI
|
existing.Platform = model.PlatformOpenAI
|
||||||
existing.Type = model.AccountTypeOAuth
|
existing.Type = model.AccountTypeOAuth
|
||||||
existing.Credentials = model.JSONB(credentials)
|
existing.Credentials = mergeJSONB(existing.Credentials, credentials)
|
||||||
existing.ProxyID = proxyID
|
if proxyID != nil {
|
||||||
|
existing.ProxyID = proxyID
|
||||||
|
}
|
||||||
existing.Concurrency = concurrency
|
existing.Concurrency = concurrency
|
||||||
existing.Priority = priority
|
existing.Priority = priority
|
||||||
existing.Status = status
|
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) == "" {
|
if baseURL, ok := src.Credentials["base_url"].(string); !ok || strings.TrimSpace(baseURL) == "" {
|
||||||
src.Credentials["base_url"] = "https://api.openai.com"
|
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(
|
proxyID, err := s.mapOrCreateProxy(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -586,17 +601,14 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing.Extra == nil {
|
existing.Extra = mergeJSONB(existing.Extra, extra)
|
||||||
existing.Extra = make(model.JSONB)
|
|
||||||
}
|
|
||||||
for k, v := range extra {
|
|
||||||
existing.Extra[k] = v
|
|
||||||
}
|
|
||||||
existing.Name = defaultName(src.Name, src.ID)
|
existing.Name = defaultName(src.Name, src.ID)
|
||||||
existing.Platform = model.PlatformOpenAI
|
existing.Platform = model.PlatformOpenAI
|
||||||
existing.Type = model.AccountTypeApiKey
|
existing.Type = model.AccountTypeApiKey
|
||||||
existing.Credentials = model.JSONB(credentials)
|
existing.Credentials = mergeJSONB(existing.Credentials, credentials)
|
||||||
existing.ProxyID = proxyID
|
if proxyID != nil {
|
||||||
|
existing.ProxyID = proxyID
|
||||||
|
}
|
||||||
existing.Concurrency = concurrency
|
existing.Concurrency = concurrency
|
||||||
existing.Priority = priority
|
existing.Priority = priority
|
||||||
existing.Status = status
|
existing.Status = status
|
||||||
@@ -618,6 +630,18 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
|||||||
return result, nil
|
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) {
|
func (s *CRSSyncService) mapOrCreateProxy(ctx context.Context, enabled bool, cached *[]model.Proxy, src *crsProxy, defaultName string) (*int64, error) {
|
||||||
if !enabled || src == nil {
|
if !enabled || src == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -731,6 +755,17 @@ func normalizeBaseURL(raw string) (string, error) {
|
|||||||
return strings.TrimRight(u.String(), "/"), nil
|
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) {
|
func crsLogin(ctx context.Context, client *http.Client, baseURL, username, password string) (string, error) {
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"username": username,
|
"username": username,
|
||||||
|
|||||||
@@ -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:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
ports:
|
||||||
|
- 5433:5432
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Redis Cache
|
# Redis Cache
|
||||||
|
|||||||
@@ -213,6 +213,53 @@ export async function batchCreate(accounts: CreateAccountRequest[]): Promise<{
|
|||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get account today statistics
|
* Get account today statistics
|
||||||
* @param id - Account ID
|
* @param id - Account ID
|
||||||
@@ -285,6 +332,8 @@ export const accountsAPI = {
|
|||||||
generateAuthUrl,
|
generateAuthUrl,
|
||||||
exchangeCode,
|
exchangeCode,
|
||||||
batchCreate,
|
batchCreate,
|
||||||
|
batchUpdateCredentials,
|
||||||
|
bulkUpdate,
|
||||||
syncFromCrs,
|
syncFromCrs,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
790
frontend/src/components/account/BulkEditAccountModal.vue
Normal file
790
frontend/src/components/account/BulkEditAccountModal.vue
Normal file
@@ -0,0 +1,790 @@
|
|||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:show="show"
|
||||||
|
:title="t('admin.accounts.bulkEdit.title')"
|
||||||
|
size="lg"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-5">
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4">
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||||
|
<svg class="w-5 h-5 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.bulkEdit.selectionInfo', { count: accountIds.length }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Base URL (API Key only) -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.baseUrl') }}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="enableBaseUrl"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="baseUrl"
|
||||||
|
type="text"
|
||||||
|
:disabled="!enableBaseUrl"
|
||||||
|
class="input"
|
||||||
|
:class="!enableBaseUrl && 'opacity-50 cursor-not-allowed'"
|
||||||
|
:placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')"
|
||||||
|
/>
|
||||||
|
<p class="input-hint">{{ t('admin.accounts.bulkEdit.baseUrlNotice') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model restriction -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="enableModelRestriction"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="!enableModelRestriction && 'opacity-50 pointer-events-none'">
|
||||||
|
<!-- Mode Toggle -->
|
||||||
|
<div class="flex gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="modelRestrictionMode = 'whitelist'"
|
||||||
|
:class="[
|
||||||
|
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||||
|
modelRestrictionMode === 'whitelist'
|
||||||
|
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.modelWhitelist') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="modelRestrictionMode = 'mapping'"
|
||||||
|
:class="[
|
||||||
|
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||||
|
modelRestrictionMode === 'mapping'
|
||||||
|
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.modelMapping') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Whitelist Mode -->
|
||||||
|
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||||
|
<div class="mb-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-3">
|
||||||
|
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||||
|
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.selectAllowedModels') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model Checkbox List -->
|
||||||
|
<div class="grid grid-cols-2 gap-2 mb-3">
|
||||||
|
<label
|
||||||
|
v-for="model in allModels"
|
||||||
|
:key="model.value"
|
||||||
|
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
|
||||||
|
:class="allowedModels.includes(model.value) ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200'"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="model.value"
|
||||||
|
v-model="allowedModels"
|
||||||
|
class="mr-2 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ model.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||||
|
<span v-if="allowedModels.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mapping Mode -->
|
||||||
|
<div v-else>
|
||||||
|
<div class="mb-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 p-3">
|
||||||
|
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||||||
|
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.mapRequestModels') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model Mapping List -->
|
||||||
|
<div v-if="modelMappings.length > 0" class="space-y-2 mb-3">
|
||||||
|
<div
|
||||||
|
v-for="(mapping, index) in modelMappings"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="mapping.from"
|
||||||
|
type="text"
|
||||||
|
class="input flex-1"
|
||||||
|
:placeholder="t('admin.accounts.requestModel')"
|
||||||
|
/>
|
||||||
|
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
v-model="mapping.to"
|
||||||
|
type="text"
|
||||||
|
class="input flex-1"
|
||||||
|
:placeholder="t('admin.accounts.actualModel')"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="removeModelMapping(index)"
|
||||||
|
class="p-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="addModelMapping"
|
||||||
|
class="w-full rounded-lg border-2 border-dashed border-gray-300 dark:border-dark-500 px-4 py-2 text-gray-600 dark:text-gray-400 transition-colors hover:border-gray-400 hover:text-gray-700 dark:hover:border-dark-400 dark:hover:text-gray-300 mb-3"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.addMapping') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Quick Add Buttons -->
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="preset in presetMappings"
|
||||||
|
:key="preset.label"
|
||||||
|
type="button"
|
||||||
|
@click="addPresetMapping(preset.from, preset.to)"
|
||||||
|
:class="[
|
||||||
|
'rounded-lg px-3 py-1 text-xs transition-colors',
|
||||||
|
preset.color
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
+ {{ preset.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom error codes -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.customErrorCodesHint') }}</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="enableCustomErrorCodes"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="enableCustomErrorCodes" class="space-y-3">
|
||||||
|
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/20 p-3">
|
||||||
|
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||||||
|
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.customErrorCodesWarning') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Code Buttons -->
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="code in commonErrorCodes"
|
||||||
|
:key="code.value"
|
||||||
|
type="button"
|
||||||
|
@click="toggleErrorCode(code.value)"
|
||||||
|
:class="[
|
||||||
|
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
|
||||||
|
selectedErrorCodes.includes(code.value)
|
||||||
|
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 ring-1 ring-red-500'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ code.value }} {{ code.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual input -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="customErrorCodeInput"
|
||||||
|
type="number"
|
||||||
|
min="100"
|
||||||
|
max="599"
|
||||||
|
class="input flex-1"
|
||||||
|
:placeholder="t('admin.accounts.enterErrorCode')"
|
||||||
|
@keyup.enter="addCustomErrorCode"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="addCustomErrorCode"
|
||||||
|
class="btn btn-secondary px-3"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected codes summary -->
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
|
||||||
|
:key="code"
|
||||||
|
class="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/30 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:text-red-400"
|
||||||
|
>
|
||||||
|
{{ code }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="removeErrorCode(code)"
|
||||||
|
class="hover:text-red-900 dark:hover:text-red-300"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400">
|
||||||
|
{{ t('admin.accounts.noneSelectedUsesDefault') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Intercept warmup requests (Anthropic only) -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1 pr-4">
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.interceptWarmupRequests') }}</label>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.interceptWarmupRequestsDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="enableInterceptWarmup"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="enableInterceptWarmup" class="mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="interceptWarmupRequests = !interceptWarmupRequests"
|
||||||
|
:class="[
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||||
|
interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||||
|
interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Proxy -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.proxy') }}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="enableProxy"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div :class="!enableProxy && 'opacity-50 pointer-events-none'">
|
||||||
|
<ProxySelector
|
||||||
|
v-model="proxyId"
|
||||||
|
:proxies="proxies"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Concurrency & Priority -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.concurrency') }}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="enableConcurrency"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model.number="concurrency"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:disabled="!enableConcurrency"
|
||||||
|
class="input"
|
||||||
|
:class="!enableConcurrency && 'opacity-50 cursor-not-allowed'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.priority') }}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="enablePriority"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model.number="priority"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:disabled="!enablePriority"
|
||||||
|
class="input"
|
||||||
|
:class="!enablePriority && 'opacity-50 cursor-not-allowed'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<label class="input-label mb-0">{{ t('common.status') }}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="enableStatus"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div :class="!enableStatus && 'opacity-50 pointer-events-none'">
|
||||||
|
<Select
|
||||||
|
v-model="status"
|
||||||
|
:options="statusOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Groups -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<label class="input-label mb-0">{{ t('nav.groups') }}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="enableGroups"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div :class="!enableGroups && 'opacity-50 pointer-events-none'">
|
||||||
|
<GroupSelector
|
||||||
|
v-model="groupIds"
|
||||||
|
:groups="groups"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
@click="handleClose"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="submitting"
|
||||||
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="submitting"
|
||||||
|
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ submitting ? t('admin.accounts.bulkEdit.updating') : t('admin.accounts.bulkEdit.submit') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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 Modal from '@/components/common/Modal.vue'
|
||||||
|
import Select from '@/components/common/Select.vue'
|
||||||
|
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||||
|
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
show: boolean
|
||||||
|
accountIds: number[]
|
||||||
|
proxies: Proxy[]
|
||||||
|
groups: Group[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
updated: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
// Model mapping type
|
||||||
|
interface ModelMapping {
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// State - field enable flags
|
||||||
|
const enableBaseUrl = ref(false)
|
||||||
|
const enableModelRestriction = ref(false)
|
||||||
|
const enableCustomErrorCodes = ref(false)
|
||||||
|
const enableInterceptWarmup = ref(false)
|
||||||
|
const enableProxy = ref(false)
|
||||||
|
const enableConcurrency = ref(false)
|
||||||
|
const enablePriority = ref(false)
|
||||||
|
const enableStatus = ref(false)
|
||||||
|
const enableGroups = ref(false)
|
||||||
|
|
||||||
|
// State - field values
|
||||||
|
const submitting = ref(false)
|
||||||
|
const baseUrl = ref('')
|
||||||
|
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||||
|
const allowedModels = ref<string[]>([])
|
||||||
|
const modelMappings = ref<ModelMapping[]>([])
|
||||||
|
const selectedErrorCodes = ref<number[]>([])
|
||||||
|
const customErrorCodeInput = ref<number | null>(null)
|
||||||
|
const interceptWarmupRequests = ref(false)
|
||||||
|
const proxyId = ref<number | null>(null)
|
||||||
|
const concurrency = ref(1)
|
||||||
|
const priority = ref(1)
|
||||||
|
const status = ref<'active' | 'inactive'>('active')
|
||||||
|
const groupIds = ref<number[]>([])
|
||||||
|
const accountCache = ref<Record<number, Account>>({})
|
||||||
|
|
||||||
|
// All models list (combined Anthropic + OpenAI)
|
||||||
|
const allModels = [
|
||||||
|
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
|
||||||
|
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
|
||||||
|
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
|
||||||
|
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' },
|
||||||
|
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
|
||||||
|
{ value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
|
||||||
|
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
|
||||||
|
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' },
|
||||||
|
{ value: 'gpt-5.2-2025-12-11', label: 'GPT-5.2' },
|
||||||
|
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
||||||
|
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||||
|
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||||
|
{ value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' },
|
||||||
|
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
|
||||||
|
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Preset mappings (combined Anthropic + OpenAI)
|
||||||
|
const presetMappings = [
|
||||||
|
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||||
|
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||||
|
{ label: 'Opus 4.5', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-5-20251101', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||||
|
{ label: 'Opus->Sonnet', from: 'claude-opus-4-5-20251101', to: 'claude-sonnet-4-5-20250929', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||||
|
{ label: 'GPT-5.2', from: 'gpt-5.2-2025-12-11', to: 'gpt-5.2-2025-12-11', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
|
||||||
|
{ label: 'GPT-5.2 Codex', from: 'gpt-5.2-codex', to: 'gpt-5.2-codex', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||||
|
{ label: 'Max->Codex', from: 'gpt-5.1-codex-max', to: 'gpt-5.1-codex', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Common HTTP error codes
|
||||||
|
const commonErrorCodes = [
|
||||||
|
{ value: 401, label: 'Unauthorized' },
|
||||||
|
{ value: 403, label: 'Forbidden' },
|
||||||
|
{ value: 429, label: 'Rate Limit' },
|
||||||
|
{ value: 500, label: 'Server Error' },
|
||||||
|
{ value: 502, label: 'Bad Gateway' },
|
||||||
|
{ value: 503, label: 'Unavailable' },
|
||||||
|
{ value: 529, label: 'Overloaded' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusOptions = computed(() => [
|
||||||
|
{ value: 'active', label: t('common.active') },
|
||||||
|
{ value: 'inactive', label: t('common.inactive') }
|
||||||
|
])
|
||||||
|
|
||||||
|
// Model mapping helpers
|
||||||
|
const addModelMapping = () => {
|
||||||
|
modelMappings.value.push({ from: '', to: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeModelMapping = (index: number) => {
|
||||||
|
modelMappings.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addPresetMapping = (from: string, to: string) => {
|
||||||
|
const exists = modelMappings.value.some(m => m.from === from)
|
||||||
|
if (exists) {
|
||||||
|
appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelMappings.value.push({ from, to })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error code helpers
|
||||||
|
const toggleErrorCode = (code: number) => {
|
||||||
|
const index = selectedErrorCodes.value.indexOf(code)
|
||||||
|
if (index === -1) {
|
||||||
|
selectedErrorCodes.value.push(code)
|
||||||
|
} else {
|
||||||
|
selectedErrorCodes.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomErrorCode = () => {
|
||||||
|
const code = customErrorCodeInput.value
|
||||||
|
if (code === null || code < 100 || code > 599) {
|
||||||
|
appStore.showError(t('admin.accounts.invalidErrorCode'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedErrorCodes.value.includes(code)) {
|
||||||
|
appStore.showInfo(t('admin.accounts.errorCodeExists'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedErrorCodes.value.push(code)
|
||||||
|
customErrorCodeInput.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeErrorCode = (code: number) => {
|
||||||
|
const index = selectedErrorCodes.value.indexOf(code)
|
||||||
|
if (index !== -1) {
|
||||||
|
selectedErrorCodes.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildModelMappingObject = (): Record<string, string> | null => {
|
||||||
|
const mapping: Record<string, string> = {}
|
||||||
|
|
||||||
|
if (modelRestrictionMode.value === 'whitelist') {
|
||||||
|
for (const model of allowedModels.value) {
|
||||||
|
mapping[model] = model
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const m of modelMappings.value) {
|
||||||
|
const from = m.from.trim()
|
||||||
|
const to = m.to.trim()
|
||||||
|
if (from && to) {
|
||||||
|
mapping[from] = to
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Account> => {
|
||||||
|
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<string, unknown> | null => {
|
||||||
|
const updates: Record<string, unknown> = {}
|
||||||
|
let credentials: Record<string, unknown> | null = null
|
||||||
|
let credentialsChanged = false
|
||||||
|
const isAnthropic = account.platform === 'anthropic'
|
||||||
|
|
||||||
|
if (enableProxy.value) {
|
||||||
|
updates.proxy_id = proxyId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableConcurrency.value) {
|
||||||
|
updates.concurrency = concurrency.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enablePriority.value) {
|
||||||
|
updates.priority = priority.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableStatus.value) {
|
||||||
|
updates.status = status.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableGroups.value) {
|
||||||
|
updates.group_ids = groupIds.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.type === 'apikey') {
|
||||||
|
const baseCredentials = (account.credentials || {}) as Record<string, unknown>
|
||||||
|
credentials = { ...baseCredentials }
|
||||||
|
|
||||||
|
if (enableBaseUrl.value) {
|
||||||
|
credentials.base_url = baseUrl.value.trim() || getDefaultBaseUrl(account.platform)
|
||||||
|
credentialsChanged = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableModelRestriction.value) {
|
||||||
|
const modelMapping = buildModelMappingObject()
|
||||||
|
if (modelMapping) {
|
||||||
|
credentials.model_mapping = modelMapping
|
||||||
|
} else {
|
||||||
|
delete credentials.model_mapping
|
||||||
|
}
|
||||||
|
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<string, unknown>
|
||||||
|
credentials = { ...baseCredentials }
|
||||||
|
if (interceptWarmupRequests.value) {
|
||||||
|
credentials.intercept_warmup_requests = true
|
||||||
|
} else {
|
||||||
|
delete credentials.intercept_warmup_requests
|
||||||
|
}
|
||||||
|
credentialsChanged = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentials && credentialsChanged) {
|
||||||
|
updates.credentials = credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(updates).length > 0 ? updates : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (props.accountIds.length === 0) {
|
||||||
|
appStore.showError(t('admin.accounts.bulkEdit.noSelection'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAnyFieldEnabled = enableBaseUrl.value
|
||||||
|
|| enableModelRestriction.value
|
||||||
|
|| enableCustomErrorCodes.value
|
||||||
|
|| enableInterceptWarmup.value
|
||||||
|
|| enableProxy.value
|
||||||
|
|| enableConcurrency.value
|
||||||
|
|| enablePriority.value
|
||||||
|
|| enableStatus.value
|
||||||
|
|| enableGroups.value
|
||||||
|
|
||||||
|
if (!hasAnyFieldEnabled) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form when modal closes
|
||||||
|
watch(() => props.show, (newShow) => {
|
||||||
|
if (!newShow) {
|
||||||
|
// Reset all enable flags
|
||||||
|
enableBaseUrl.value = false
|
||||||
|
enableModelRestriction.value = false
|
||||||
|
enableCustomErrorCodes.value = false
|
||||||
|
enableInterceptWarmup.value = false
|
||||||
|
enableProxy.value = false
|
||||||
|
enableConcurrency.value = false
|
||||||
|
enablePriority.value = false
|
||||||
|
enableStatus.value = false
|
||||||
|
enableGroups.value = false
|
||||||
|
|
||||||
|
// Reset all values
|
||||||
|
baseUrl.value = ''
|
||||||
|
modelRestrictionMode.value = 'whitelist'
|
||||||
|
allowedModels.value = []
|
||||||
|
modelMappings.value = []
|
||||||
|
selectedErrorCodes.value = []
|
||||||
|
customErrorCodeInput.value = null
|
||||||
|
interceptWarmupRequests.value = false
|
||||||
|
proxyId.value = null
|
||||||
|
concurrency.value = 1
|
||||||
|
priority.value = 1
|
||||||
|
status.value = 'active'
|
||||||
|
groupIds.value = []
|
||||||
|
accountCache.value = {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
<div class="text-sm text-gray-600 dark:text-dark-300">
|
<div class="text-sm text-gray-600 dark:text-dark-300">
|
||||||
{{ t('admin.accounts.syncFromCrsDesc') }}
|
{{ t('admin.accounts.syncFromCrsDesc') }}
|
||||||
</div>
|
</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 class="grid grid-cols-1 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -162,4 +165,3 @@ const handleSync = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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'
|
||||||
|
|||||||
@@ -745,6 +745,31 @@ 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 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',
|
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',
|
||||||
|
|||||||
@@ -876,6 +876,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: '重置账号状态失败',
|
||||||
|
|||||||
@@ -19,11 +19,11 @@
|
|||||||
<button
|
<button
|
||||||
@click="showCrsSyncModal = true"
|
@click="showCrsSyncModal = true"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
|
title="从 CRS 同步"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<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="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="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>
|
</svg>
|
||||||
{{ t('admin.accounts.syncFromCrs') }}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="showCreateModal = true"
|
@click="showCreateModal = true"
|
||||||
@@ -75,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>
|
||||||
@@ -324,12 +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
|
<SyncFromCrsModal
|
||||||
:show="showCrsSyncModal"
|
:show="showCrsSyncModal"
|
||||||
@close="showCrsSyncModal = false"
|
@close="showCrsSyncModal = false"
|
||||||
@synced="handleCrsSynced"
|
@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>
|
||||||
|
|
||||||
@@ -346,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, SyncFromCrsModal } 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'
|
||||||
@@ -360,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 },
|
||||||
@@ -417,15 +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 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 => {
|
||||||
@@ -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
|
// Clear rate limit
|
||||||
const handleClearRateLimit = async (account: Account) => {
|
const handleClearRateLimit = async (account: Account) => {
|
||||||
try {
|
try {
|
||||||
@@ -629,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()
|
||||||
|
|||||||
Reference in New Issue
Block a user