Commit 2e76302a authored by ianshaw's avatar ianshaw
Browse files

feat(account): 添加批量编辑账户凭据功能并优化 CRS 同步

- 新增批量更新账户凭据接口(account_uuid/org_uuid/intercept_warmup_requests)
- 新增前端批量编辑模态框组件
- 优化 CRS 同步逻辑,改进 extra 字段处理
- 优化 CRS 同步 UI,添加更详细的结果展示
- 完善国际化文案(中英文)
parent 65538280
...@@ -434,6 +434,94 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) { ...@@ -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 ...@@ -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 { ...@@ -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 { ...@@ -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 ...@@ -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 ...@@ -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)
if proxyID != nil {
existing.ProxyID = proxyID 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 ...@@ -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)
if proxyID != nil {
existing.ProxyID = proxyID 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 ...@@ -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 ...@@ -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)
if proxyID != nil {
existing.ProxyID = proxyID 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 ...@@ -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 ...@@ -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)
if proxyID != nil {
existing.ProxyID = proxyID 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 ...@@ -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) { ...@@ -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: ...@@ -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: ...@@ -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<{ ...@@ -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 = { ...@@ -285,6 +332,8 @@ export const accountsAPI = {
generateAuthUrl, generateAuthUrl,
exchangeCode, exchangeCode,
batchCreate, batchCreate,
batchUpdateCredentials,
bulkUpdate,
syncFromCrs, syncFromCrs,
}; };
......
This diff is collapsed.
...@@ -10,6 +10,9 @@ ...@@ -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 () => { ...@@ -162,4 +165,3 @@ const handleSync = async () => {
} }
} }
</script> </script>
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 { ...@@ -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 { ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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' ...@@ -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() ...@@ -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) ...@@ -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 () => { ...@@ -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 = () => { ...@@ -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()
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment