diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 47b1e8ac5..07572041b 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -173,7 +173,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { userAttributeValueRepository := repository.NewUserAttributeValueRepository(client) userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository) userAttributeHandler := admin.NewUserAttributeHandler(userAttributeService) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler) + balanceHandler := admin.NewBalanceHandler(usageService) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, balanceHandler) gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, configConfig) openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, configConfig) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo) diff --git a/backend/ent/schema/usage_log.go b/backend/ent/schema/usage_log.go index fc7c71652..426d420f8 100644 --- a/backend/ent/schema/usage_log.go +++ b/backend/ent/schema/usage_log.go @@ -170,5 +170,6 @@ func (UsageLog) Indexes() []ent.Index { // 复合索引用于时间范围查询 index.Fields("user_id", "created_at"), index.Fields("api_key_id", "created_at"), + index.Fields("group_id", "created_at"), } } diff --git a/backend/internal/handler/admin/balance_handler.go b/backend/internal/handler/admin/balance_handler.go new file mode 100644 index 000000000..f7c98d3e8 --- /dev/null +++ b/backend/internal/handler/admin/balance_handler.go @@ -0,0 +1,93 @@ +package admin + +import ( + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// BalanceHandler handles admin balance management +type BalanceHandler struct { + usageService *service.UsageService +} + +// NewBalanceHandler creates a new admin balance handler +func NewBalanceHandler(usageService *service.UsageService) *BalanceHandler { + return &BalanceHandler{ + usageService: usageService, + } +} + +// GetStats handles GET /api/v1/admin/balance/stats +func (h *BalanceHandler) GetStats(c *gin.Context) { + groupIDStr := c.Query("group_id") + if groupIDStr == "" { + response.Error(c, http.StatusBadRequest, "group_id is required") + return + } + groupID, err := strconv.ParseInt(groupIDStr, 10, 64) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalid group_id") + return + } + + startDateStr := c.Query("start_date") + endDateStr := c.Query("end_date") + if startDateStr == "" || endDateStr == "" { + response.Error(c, http.StatusBadRequest, "start_date and end_date are required") + return + } + + startDate, err := time.Parse("2006-01-02", startDateStr) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalid start_date format, expected YYYY-MM-DD") + return + } + endDate, err := time.Parse("2006-01-02", endDateStr) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalid end_date format, expected YYYY-MM-DD") + return + } + // end_date 需要加一天,以包含当天的数据 + endDate = endDate.AddDate(0, 0, 1) + + page, pageSize := response.ParsePagination(c) + sortBy := c.DefaultQuery("sort_by", "total_cost") + sortOrder := c.DefaultQuery("sort_order", "desc") + search := c.Query("search") + + params := &usagestats.BalanceGroupUserStatsParams{ + GroupID: groupID, + StartDate: &startDate, + EndDate: &endDate, + Page: page, + PageSize: pageSize, + SortBy: sortBy, + SortOrder: sortOrder, + Search: search, + } + + result, err := h.usageService.GetBalanceGroupUserStats(c.Request.Context(), params) + if err != nil { + // Service validation errors contain safe messages; internal errors should not be exposed + errMsg := err.Error() + if strings.Contains(errMsg, "is required") || + strings.Contains(errMsg, "must be after") || + strings.Contains(errMsg, "must not exceed") { + response.Error(c, http.StatusBadRequest, errMsg) + } else { + slog.Error("failed to get balance group user stats", "error", err) + response.Error(c, http.StatusInternalServerError, "failed to get balance stats") + } + return + } + + response.Success(c, result) +} diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index b8f7d417e..8e51244ee 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -24,6 +24,7 @@ type AdminHandlers struct { Subscription *admin.SubscriptionHandler Usage *admin.UsageHandler UserAttribute *admin.UserAttributeHandler + Balance *admin.BalanceHandler } // Handlers contains all HTTP handlers diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 48a3794b7..beaacfb57 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -27,6 +27,7 @@ func ProvideAdminHandlers( subscriptionHandler *admin.SubscriptionHandler, usageHandler *admin.UsageHandler, userAttributeHandler *admin.UserAttributeHandler, + balanceHandler *admin.BalanceHandler, ) *AdminHandlers { return &AdminHandlers{ Dashboard: dashboardHandler, @@ -47,6 +48,7 @@ func ProvideAdminHandlers( Subscription: subscriptionHandler, Usage: usageHandler, UserAttribute: userAttributeHandler, + Balance: balanceHandler, } } @@ -125,6 +127,7 @@ var ProviderSet = wire.NewSet( admin.NewSubscriptionHandler, admin.NewUsageHandler, admin.NewUserAttributeHandler, + admin.NewBalanceHandler, // AdminHandlers and Handlers constructors ProvideAdminHandlers, diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index 2f6c7fe0b..4db2b4532 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -226,3 +226,35 @@ type AccountUsageStatsResponse struct { Summary AccountUsageSummary `json:"summary"` Models []ModelStat `json:"models"` } + +// BalanceGroupUserStats represents aggregated usage statistics for a single user in a balance group +type BalanceGroupUserStats struct { + UserID int64 `json:"user_id"` + Email string `json:"email"` + Username string `json:"username"` + Balance float64 `json:"balance"` + TotalCost float64 `json:"total_cost"` + ActualCost float64 `json:"actual_cost"` + TotalRequests int64 `json:"total_requests"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` +} + +// BalanceGroupUserStatsResponse represents the paginated response for balance group user stats +type BalanceGroupUserStatsResponse struct { + Users []BalanceGroupUserStats `json:"users"` + Total int64 `json:"total"` +} + +// BalanceGroupUserStatsParams represents the query parameters for balance group user stats +type BalanceGroupUserStatsParams struct { + GroupID int64 `json:"group_id"` + StartDate *time.Time `json:"start_date"` + EndDate *time.Time `json:"end_date"` + Page int `json:"page"` + PageSize int `json:"page_size"` + SortBy string `json:"sort_by"` + SortOrder string `json:"sort_order"` + Search string `json:"search"` +} diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 2db1764fa..514892d66 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -2386,3 +2386,143 @@ func setToSlice(set map[int64]struct{}) []int64 { } return out } + +// BalanceGroupUserStats type alias +type BalanceGroupUserStats = usagestats.BalanceGroupUserStats +type BalanceGroupUserStatsResponse = usagestats.BalanceGroupUserStatsResponse +type BalanceGroupUserStatsParams = usagestats.BalanceGroupUserStatsParams + +// GetBalanceGroupUserStats returns aggregated usage statistics per user for a balance group within a time range. +func (r *usageLogRepository) GetBalanceGroupUserStats(ctx context.Context, params *BalanceGroupUserStatsParams) (resp *BalanceGroupUserStatsResponse, err error) { + if params.StartDate == nil || params.EndDate == nil { + return nil, fmt.Errorf("start_date and end_date are required") + } + + // Build dynamic WHERE clause for search + args := []any{params.GroupID, *params.StartDate, *params.EndDate} + argPos := 4 + + searchClause := "" + if params.Search != "" { + searchClause = fmt.Sprintf(" AND (u.email ILIKE $%d OR u.username ILIKE $%d)", argPos, argPos+1) + // Escape ILIKE metacharacters to prevent unintended wildcard matching + escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(params.Search) + searchPattern := "%" + escaped + "%" + args = append(args, searchPattern, searchPattern) + argPos += 2 + } + + // Validate and set sort_by + sortColumn := "total_cost" + allowedSorts := map[string]string{ + "total_cost": "total_cost", + "actual_cost": "actual_cost", + "total_requests": "total_requests", + "input_tokens": "input_tokens", + "output_tokens": "output_tokens", + "cache_read_tokens": "cache_read_tokens", + "balance": "balance", + } + if col, ok := allowedSorts[params.SortBy]; ok { + sortColumn = col + } + + sortOrder := "DESC" + if strings.EqualFold(params.SortOrder, "asc") { + sortOrder = "ASC" + } + + // Count query for total + countQuery := fmt.Sprintf(` + WITH usage_agg AS ( + SELECT user_id + FROM usage_logs + WHERE group_id = $1 AND created_at >= $2 AND created_at < $3 + GROUP BY user_id + ) + SELECT COUNT(DISTINCT ua.user_id) + FROM usage_agg ua + JOIN users u ON u.id = ua.user_id + WHERE 1=1%s + `, searchClause) + + var total int64 + if err := scanSingleRow(ctx, r.sql, countQuery, args, &total); err != nil { + return nil, err + } + + // Data query with pagination + limit := params.PageSize + offset := (params.Page - 1) * params.PageSize + + dataArgs := make([]any, len(args)) + copy(dataArgs, args) + limitPos := argPos + offsetPos := argPos + 1 + dataArgs = append(dataArgs, limit, offset) + + dataQuery := fmt.Sprintf(` + WITH usage_agg AS ( + SELECT + user_id, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(actual_cost), 0) as actual_cost, + COUNT(*) as total_requests, + COALESCE(SUM(input_tokens), 0) as input_tokens, + COALESCE(SUM(output_tokens), 0) as output_tokens, + COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens + FROM usage_logs + WHERE group_id = $1 AND created_at >= $2 AND created_at < $3 + GROUP BY user_id + ) + SELECT + ua.user_id, + COALESCE(u.email, '') as email, + COALESCE(u.username, '') as username, + COALESCE(u.balance, 0) as balance, + ua.total_cost, + ua.actual_cost, + ua.total_requests, + ua.input_tokens, + ua.output_tokens, + ua.cache_read_tokens + FROM usage_agg ua + JOIN users u ON u.id = ua.user_id + WHERE 1=1%s + ORDER BY %s %s + LIMIT $%d OFFSET $%d + `, searchClause, sortColumn, sortOrder, limitPos, offsetPos) + + rows, err := r.sql.QueryContext(ctx, dataQuery, dataArgs...) + if err != nil { + return nil, err + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + resp = nil + } + }() + + users := make([]BalanceGroupUserStats, 0) + for rows.Next() { + var s BalanceGroupUserStats + if err = rows.Scan( + &s.UserID, &s.Email, &s.Username, &s.Balance, + &s.TotalCost, &s.ActualCost, &s.TotalRequests, + &s.InputTokens, &s.OutputTokens, &s.CacheReadTokens, + ); err != nil { + return nil, err + } + users = append(users, s) + } + if err = rows.Err(); err != nil { + return nil, err + } + + resp = &BalanceGroupUserStatsResponse{ + Users: users, + Total: total, + } + return resp, nil +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index e197b776d..d9440bf74 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1671,6 +1671,10 @@ func (r *stubUsageLogRepo) GetStatsWithFilters(ctx context.Context, filters usag return nil, errors.New("not implemented") } +func (r *stubUsageLogRepo) GetBalanceGroupUserStats(ctx context.Context, params *usagestats.BalanceGroupUserStatsParams) (*usagestats.BalanceGroupUserStatsResponse, error) { + return nil, errors.New("not implemented") +} + type stubSettingRepo struct { all map[string]string } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index ca9d627e6..a2b611afe 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -65,6 +65,9 @@ func RegisterAdminRoutes( // 使用记录管理 registerUsageRoutes(admin, h) + // 余额管理 + registerBalanceRoutes(admin, h) + // 用户属性管理 registerUserAttributeRoutes(admin, h) } @@ -387,3 +390,10 @@ func registerUserAttributeRoutes(admin *gin.RouterGroup, h *handler.Handlers) { attrs.DELETE("/:id", h.Admin.UserAttribute.DeleteDefinition) } } + +func registerBalanceRoutes(admin *gin.RouterGroup, h *handler.Handlers) { + balance := admin.Group("/balance") + { + balance.GET("/stats", h.Admin.Balance.GetStats) + } +} diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 304c57811..d0ddc3289 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -53,6 +53,9 @@ type UsageLogRepository interface { // Account stats GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error) + // Balance group stats + GetBalanceGroupUserStats(ctx context.Context, params *usagestats.BalanceGroupUserStatsParams) (*usagestats.BalanceGroupUserStatsResponse, error) + // Aggregated stats (optimized) GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) GetAPIKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) diff --git a/backend/internal/service/usage_service.go b/backend/internal/service/usage_service.go index 5594e53f8..df9104a86 100644 --- a/backend/internal/service/usage_service.go +++ b/backend/internal/service/usage_service.go @@ -350,3 +350,41 @@ func (s *UsageService) GetStatsWithFilters(ctx context.Context, filters usagesta } return stats, nil } + +// GetBalanceGroupUserStats returns aggregated usage statistics per user for a balance group. +func (s *UsageService) GetBalanceGroupUserStats(ctx context.Context, params *usagestats.BalanceGroupUserStatsParams) (*usagestats.BalanceGroupUserStatsResponse, error) { + if params.GroupID <= 0 { + return nil, fmt.Errorf("group_id is required") + } + if params.StartDate == nil || params.EndDate == nil { + return nil, fmt.Errorf("start_date and end_date are required") + } + if params.EndDate.Before(*params.StartDate) { + return nil, fmt.Errorf("end_date must be after start_date") + } + // 日期范围不超过 90 天 + if params.EndDate.Sub(*params.StartDate).Hours() > 90*24 { + return nil, fmt.Errorf("date range must not exceed 90 days") + } + if params.Page < 1 { + params.Page = 1 + } + if params.PageSize < 1 || params.PageSize > 100 { + params.PageSize = 20 + } + // sort_by 白名单校验 + allowedSortBy := map[string]bool{ + "total_cost": true, + "actual_cost": true, + "total_requests": true, + "input_tokens": true, + "output_tokens": true, + "cache_read_tokens": true, + "balance": true, + } + if params.SortBy != "" && !allowedSortBy[params.SortBy] { + params.SortBy = "total_cost" + } + + return s.usageRepo.GetBalanceGroupUserStats(ctx, params) +} diff --git a/frontend/src/api/admin/balance.ts b/frontend/src/api/admin/balance.ts new file mode 100644 index 000000000..d5366864e --- /dev/null +++ b/frontend/src/api/admin/balance.ts @@ -0,0 +1,61 @@ +/** + * Admin Balance API endpoints + * Handles balance group user statistics for administrators + */ + +import { apiClient } from '../client' + +/** Aggregated usage statistics for a single user in a balance group */ +export interface BalanceGroupUserStats { + user_id: number + email: string + username: string + balance: number + total_cost: number + actual_cost: number + total_requests: number + input_tokens: number + output_tokens: number + cache_read_tokens: number +} + +/** Paginated response for balance group user stats */ +export interface BalanceGroupUserStatsResponse { + users: BalanceGroupUserStats[] + total: number +} + +/** Query parameters for balance group user stats */ +export interface BalanceGroupUserStatsParams { + group_id: number + start_date: string + end_date: string + page?: number + page_size?: number + sort_by?: string + sort_order?: 'asc' | 'desc' + search?: string +} + +/** + * Get balance group user statistics + * @param params - Query parameters + * @returns Paginated user stats response + */ +export async function getBalanceGroupUserStats( + params: BalanceGroupUserStatsParams, + options?: { signal?: AbortSignal } +): Promise { + const { data } = await apiClient.get( + '/admin/balance/stats', + { + params, + signal: options?.signal + } + ) + return data +} + +export default { + getBalanceGroupUserStats +} diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index 9a8a41958..0dc359314 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -19,6 +19,7 @@ import geminiAPI from './gemini' import antigravityAPI from './antigravity' import userAttributesAPI from './userAttributes' import opsAPI from './ops' +import balanceAPI from './balance' /** * Unified admin API object for convenient access @@ -39,7 +40,8 @@ export const adminAPI = { gemini: geminiAPI, antigravity: antigravityAPI, userAttributes: userAttributesAPI, - ops: opsAPI + ops: opsAPI, + balance: balanceAPI } export { @@ -58,7 +60,8 @@ export { geminiAPI, antigravityAPI, userAttributesAPI, - opsAPI + opsAPI, + balanceAPI } export default adminAPI diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index e0c4212ad..db4b6e67d 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -289,6 +289,26 @@ const CreditCardIcon = { ) } +const WalletIcon = { + render: () => + h( + 'svg', + { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, + [ + h('path', { + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + d: 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 110-6h.008A2.251 2.251 0 0117.25 1.5h.5A2.25 2.25 0 0120 3.75v.443c.572.227 1.072.58 1.462 1.031C22.07 5.93 22.5 6.9 22.5 8.25v7.5c0 1.35-.43 2.32-1.038 3.026A3.733 3.733 0 0120 19.807v.443A2.25 2.25 0 0117.75 22.5h-.5a2.251 2.251 0 01-2.242-2.25H15a3 3 0 110-6h3.75A2.25 2.25 0 0021 12z' + }), + h('path', { + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + d: 'M15.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z' + }) + ] + ) +} + const GlobeIcon = { render: () => h( @@ -484,6 +504,7 @@ const adminNavItems = computed(() => { { path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true }, { path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true }, { path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, + { path: '/admin/balance', label: t('nav.balance'), icon: WalletIcon, hideInSimpleMode: true }, { path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon }, { path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon }, { path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon }, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index fb255c1a2..abb63ce66 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -195,6 +195,7 @@ export default { users: 'Users', groups: 'Groups', subscriptions: 'Subscriptions', + balance: 'Balance', accounts: 'Accounts', proxies: 'Proxies', redeemCodes: 'Redeem Codes', @@ -429,6 +430,15 @@ export default { geminiCli: 'Gemini CLI', codexCli: 'Codex CLI', opencode: 'OpenCode', + ccSwitch: 'CC Switch', + }, + ccSwitch: { + basicConfigTitle: 'Basic Config', + basicConfigHint: 'Fill in the following info when adding a provider in CC Switch:', + baseUrlLabel: 'Base URL', + usageConfigTitle: 'Usage Query Extractor Code', + usageConfigHint: + 'Configure usage query: After adding a provider, select it → click "Configure Usage Query" tab → enable usage query → click "Custom Preset Template" → paste the following code into the "Extractor Code" field.', }, antigravity: { description: 'Configure API access for Antigravity group. Select the configuration method based on your client.', @@ -1175,6 +1185,35 @@ export default { "Are you sure you want to revoke the subscription for '{user}'? This action cannot be undone." }, + // Balance Management + balance: { + title: 'Balance Management', + description: 'View balance group user usage statistics', + selectGroup: 'Select Group', + selectGroupHint: 'Please select a group', + selectGroupHintDesc: 'Select a balance billing group above to view user usage data', + noStandardGroups: 'No Balance Groups', + noStandardGroupsDesc: 'No groups configured with balance billing type (standard)', + searchPlaceholder: 'Search email/username', + noData: 'No Data', + noDataDesc: 'No user usage data found for the current filters', + modelDist: 'Models', + modelDistTitle: 'Model Distribution for {user}', + noModelData: 'No model distribution data', + history: 'History', + columns: { + user: 'User', + balance: 'Balance', + totalCost: 'Standard Cost', + actualCost: 'Actual Cost', + requests: 'Requests', + inputTokens: 'Input Tokens', + outputTokens: 'Output Tokens', + cacheTokens: 'Cache Tokens', + actions: 'Actions' + } + }, + // Accounts accounts: { title: 'Account Management', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index e964aae24..bfd37ca48 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -192,6 +192,7 @@ export default { users: '用户管理', groups: '分组管理', subscriptions: '订阅管理', + balance: '余额管理', accounts: '账号管理', proxies: 'IP管理', redeemCodes: '兑换码', @@ -427,7 +428,16 @@ export default { claudeCode: 'Claude Code', geminiCli: 'Gemini CLI', codexCli: 'Codex CLI', - opencode: 'OpenCode' + opencode: 'OpenCode', + ccSwitch: 'CC Switch' + }, + ccSwitch: { + basicConfigTitle: '基础配置', + basicConfigHint: '在 CC Switch 中添加供应商时,请填写以下信息:', + baseUrlLabel: '请求地址', + usageConfigTitle: '用量查询提取器代码', + usageConfigHint: + '配置用量查询:添加完供应商后,选中该供应商 → 点击「配置用量查询」子标签 → 点击「启用用量查询」→ 点击「自定义预设模板」→ 将以下代码拷贝到「提取器代码」中即可。' }, antigravity: { description: '为 Antigravity 分组配置 API 访问。请根据您使用的客户端选择对应的配置方式。', @@ -1260,7 +1270,34 @@ export default { revokeConfirm: "确定要撤销 '{user}' 的订阅吗?此操作无法撤销。" }, - // Accounts Management + // Balance Management + balance: { + title: '余额管理', + description: '查看余额分组用户用量统计', + selectGroup: '选择分组', + selectGroupHint: '请选择一个分组', + selectGroupHintDesc: '从上方选择一个余额计费分组以查看用户用量数据', + noStandardGroups: '暂无余额计费分组', + noStandardGroupsDesc: '当前没有配置余额计费类型(standard)的分组', + searchPlaceholder: '搜索邮箱/用户名', + noData: '暂无数据', + noDataDesc: '当前条件下没有找到用户用量数据', + modelDist: '模型分布', + modelDistTitle: '{user} 的模型分布', + noModelData: '暂无模型分布数据', + history: '充值历史', + columns: { + user: '用户', + balance: '余额', + totalCost: '标准计费', + actualCost: '实际扣除', + requests: '请求次数', + inputTokens: '输入 Token', + outputTokens: '输出 Token', + cacheTokens: '缓存 Token', + actions: '操作' + } + }, accounts: { title: '账号管理', description: '管理 AI 平台账号和 Cookie', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 4bb46cee5..2f8dabb26 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -253,6 +253,18 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'admin.subscriptions.description' } }, + { + path: '/admin/balance', + name: 'AdminBalance', + component: () => import('@/views/admin/BalanceView.vue'), + meta: { + requiresAuth: true, + requiresAdmin: true, + title: 'Balance Management', + titleKey: 'admin.balance.title', + descriptionKey: 'admin.balance.description' + } + }, { path: '/admin/accounts', name: 'AdminAccounts', diff --git a/frontend/src/views/admin/BalanceView.vue b/frontend/src/views/admin/BalanceView.vue new file mode 100644 index 000000000..0966ba70a --- /dev/null +++ b/frontend/src/views/admin/BalanceView.vue @@ -0,0 +1,539 @@ + + +