diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index fb2c5b2a..4a9185eb 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -2,8 +2,13 @@ package admin import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" "errors" "fmt" + "net/http" "strconv" "strings" "sync" @@ -143,6 +148,44 @@ type AccountWithConcurrency struct { ActiveSessions *int `json:"active_sessions,omitempty"` // 当前活跃会话数 } +func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency { + item := AccountWithConcurrency{ + Account: dto.AccountFromService(account), + CurrentConcurrency: 0, + } + if account == nil { + return item + } + + if h.concurrencyService != nil { + if counts, err := h.concurrencyService.GetAccountConcurrencyBatch(ctx, []int64{account.ID}); err == nil { + item.CurrentConcurrency = counts[account.ID] + } + } + + if account.IsAnthropicOAuthOrSetupToken() { + if h.accountUsageService != nil && account.GetWindowCostLimit() > 0 { + startTime := account.GetCurrentWindowStartTime() + if stats, err := h.accountUsageService.GetAccountWindowStats(ctx, account.ID, startTime); err == nil && stats != nil { + cost := stats.StandardCost + item.CurrentWindowCost = &cost + } + } + + if h.sessionLimitCache != nil && account.GetMaxSessions() > 0 { + idleTimeout := time.Duration(account.GetSessionIdleTimeoutMinutes()) * time.Minute + idleTimeouts := map[int64]time.Duration{account.ID: idleTimeout} + if sessions, err := h.sessionLimitCache.GetActiveSessionCountBatch(ctx, []int64{account.ID}, idleTimeouts); err == nil { + if count, ok := sessions[account.ID]; ok { + item.ActiveSessions = &count + } + } + } + } + + return item +} + // List handles listing all accounts with pagination // GET /api/v1/admin/accounts func (h *AccountHandler) List(c *gin.Context) { @@ -258,9 +301,71 @@ func (h *AccountHandler) List(c *gin.Context) { result[i] = item } + etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search) + if etag != "" { + c.Header("ETag", etag) + c.Header("Vary", "If-None-Match") + if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) { + c.Status(http.StatusNotModified) + return + } + } + response.Paginated(c, result, total, page, pageSize) } +func buildAccountsListETag( + items []AccountWithConcurrency, + total int64, + page, pageSize int, + platform, accountType, status, search string, +) string { + payload := struct { + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Platform string `json:"platform"` + AccountType string `json:"type"` + Status string `json:"status"` + Search string `json:"search"` + Items []AccountWithConcurrency `json:"items"` + }{ + Total: total, + Page: page, + PageSize: pageSize, + Platform: platform, + AccountType: accountType, + Status: status, + Search: search, + Items: items, + } + raw, err := json.Marshal(payload) + if err != nil { + return "" + } + sum := sha256.Sum256(raw) + return "\"" + hex.EncodeToString(sum[:]) + "\"" +} + +func ifNoneMatchMatched(ifNoneMatch, etag string) bool { + if etag == "" || ifNoneMatch == "" { + return false + } + for _, token := range strings.Split(ifNoneMatch, ",") { + candidate := strings.TrimSpace(token) + if candidate == "*" { + return true + } + if candidate == etag { + return true + } + if strings.HasPrefix(candidate, "W/") && strings.TrimPrefix(candidate, "W/") == etag { + return true + } + } + return false +} + // GetByID handles getting an account by ID // GET /api/v1/admin/accounts/:id func (h *AccountHandler) GetByID(c *gin.Context) { @@ -276,7 +381,7 @@ func (h *AccountHandler) GetByID(c *gin.Context) { return } - response.Success(c, dto.AccountFromService(account)) + response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) } // Create handles creating a new account @@ -334,7 +439,7 @@ func (h *AccountHandler) Create(c *gin.Context) { return } - response.Success(c, dto.AccountFromService(account)) + response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) } // Update handles updating an account @@ -398,7 +503,7 @@ func (h *AccountHandler) Update(c *gin.Context) { return } - response.Success(c, dto.AccountFromService(account)) + response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) } // Delete handles deleting an account @@ -656,7 +761,7 @@ func (h *AccountHandler) Refresh(c *gin.Context) { } } - response.Success(c, dto.AccountFromService(updatedAccount)) + response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), updatedAccount)) } // GetStats handles getting account statistics @@ -714,7 +819,7 @@ func (h *AccountHandler) ClearError(c *gin.Context) { } } - response.Success(c, dto.AccountFromService(account)) + response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) } // BatchCreate handles batch creating accounts @@ -1112,7 +1217,7 @@ func (h *AccountHandler) ClearRateLimit(c *gin.Context) { return } - response.Success(c, dto.AccountFromService(account)) + response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) } // GetTempUnschedulable handles getting temporary unschedulable status @@ -1202,7 +1307,7 @@ func (h *AccountHandler) SetSchedulable(c *gin.Context) { return } - response.Success(c, dto.AccountFromService(account)) + response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) } // GetAvailableModels handles getting available models for an account diff --git a/backend/internal/server/middleware/cors.go b/backend/internal/server/middleware/cors.go index 14a09cc2..704b0907 100644 --- a/backend/internal/server/middleware/cors.go +++ b/backend/internal/server/middleware/cors.go @@ -70,6 +70,7 @@ func CORS(cfg config.CORSConfig) gin.HandlerFunc { } c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, X-API-Key") c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH") + c.Writer.Header().Set("Access-Control-Expose-Headers", "ETag") c.Writer.Header().Set("Access-Control-Max-Age", "86400") } diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 65f2090c..0c4856a9 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -49,6 +49,58 @@ export async function list( return data } +export interface AccountListWithEtagResult { + notModified: boolean + etag: string | null + data: PaginatedResponse | null +} + +export async function listWithEtag( + page: number = 1, + pageSize: number = 20, + filters?: { + platform?: string + type?: string + status?: string + search?: string + }, + options?: { + signal?: AbortSignal + etag?: string | null + } +): Promise { + const headers: Record = {} + if (options?.etag) { + headers['If-None-Match'] = options.etag + } + + const response = await apiClient.get>('/admin/accounts', { + params: { + page, + page_size: pageSize, + ...filters + }, + headers, + signal: options?.signal, + validateStatus: (status) => (status >= 200 && status < 300) || status === 304 + }) + + const etagHeader = typeof response.headers?.etag === 'string' ? response.headers.etag : null + if (response.status === 304) { + return { + notModified: true, + etag: etagHeader, + data: null + } + } + + return { + notModified: false, + etag: etagHeader, + data: response.data + } +} + /** * Get account by ID * @param id - Account ID @@ -455,6 +507,7 @@ export async function refreshOpenAIToken( export const accountsAPI = { list, + listWithEtag, getById, create, update, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 46c40b5e..967f22c9 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1280,6 +1280,8 @@ export default { refreshInterval15s: '15 seconds', refreshInterval30s: '30 seconds', autoRefreshCountdown: 'Auto refresh: {seconds}s', + listPendingSyncHint: 'List changes are pending sync. Click sync to load latest rows.', + listPendingSyncAction: 'Sync now', syncFromCrs: 'Sync from CRS', dataExport: 'Export', dataExportSelected: 'Export Selected', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 805efcc8..5e05f0bf 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1368,6 +1368,8 @@ export default { refreshInterval15s: '15 秒', refreshInterval30s: '30 秒', autoRefreshCountdown: '自动刷新:{seconds}s', + listPendingSyncHint: '列表存在待同步变更,点击同步可补齐最新数据。', + listPendingSyncAction: '立即同步', syncFromCrs: '从 CRS 同步', dataExport: '导出', dataExportSelected: '导出选中', diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 9994144b..dc135f4a 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -12,7 +12,7 @@ /> @@ -116,6 +116,18 @@ +
+ {{ t('admin.accounts.listPendingSyncHint') }} + +