From 90b2b2a757a205cf15b56e49e5c5ce85eea2e569 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 20 May 2026 15:48:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(usage):=20=E7=94=A8=E6=88=B7=20API=20Key?= =?UTF-8?q?=20=E7=94=A8=E9=87=8F=E9=A1=B5=E6=94=AF=E6=8C=81=E6=8C=89?= =?UTF-8?q?=E6=97=A5=E6=98=8E=E7=BB=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/gateway_handler.go | 35 ++- backend/internal/handler/usage_handler.go | 75 +++++++ .../handler/usage_handler_daily_test.go | 195 ++++++++++++++++ .../pkg/usagestats/usage_log_types.go | 13 ++ backend/internal/server/routes/user.go | 1 + backend/internal/service/usage_service.go | 24 ++ frontend/src/api/usage.ts | 37 ++++ frontend/src/i18n/locales/en.ts | 21 ++ frontend/src/i18n/locales/zh.ts | 21 ++ frontend/src/views/KeyUsageView.vue | 122 +++++++++- .../src/views/__tests__/KeyUsageView.spec.ts | 208 ++++++++++++++++++ 11 files changed, 738 insertions(+), 14 deletions(-) create mode 100644 backend/internal/handler/usage_handler_daily_test.go create mode 100644 frontend/src/views/__tests__/KeyUsageView.spec.ts diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 733be95d..3db725f7 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1038,9 +1038,15 @@ func (h *GatewayHandler) Usage(c *gin.Context) { // 解析可选的日期范围参数(用于 model_stats 查询) startTime, endTime := h.parseUsageDateRange(c) + days, ok := parseAPIKeyDailyUsageDays(c.DefaultQuery("days", "")) + if !ok { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Invalid days, allowed range is 1-90") + return + } // Best-effort: 获取用量统计(按当前 API Key 过滤),失败不影响基础响应 usageData := h.buildUsageData(ctx, apiKey.ID) + dailyUsage := h.buildAPIKeyDailyUsage(c, subject.UserID, apiKey.ID, days) // Best-effort: 获取模型统计 var modelStats any @@ -1054,11 +1060,11 @@ func (h *GatewayHandler) Usage(c *gin.Context) { isQuotaLimited := apiKey.Quota > 0 || apiKey.HasRateLimits() if isQuotaLimited { - h.usageQuotaLimited(c, ctx, apiKey, usageData, modelStats) + h.usageQuotaLimited(c, ctx, apiKey, usageData, dailyUsage, modelStats) return } - h.usageUnrestricted(c, ctx, apiKey, subject, usageData, modelStats) + h.usageUnrestricted(c, ctx, apiKey, subject, usageData, dailyUsage, modelStats) } // parseUsageDateRange 解析 start_date / end_date query params,默认返回近 30 天范围 @@ -1116,8 +1122,20 @@ func (h *GatewayHandler) buildUsageData(ctx context.Context, apiKeyID int64) gin } } +func (h *GatewayHandler) buildAPIKeyDailyUsage(c *gin.Context, userID, apiKeyID int64, days int) any { + if h.usageService == nil { + return nil + } + startTime, endTime := apiKeyDailyUsageRange(days, c.Query("timezone")) + stats, err := h.usageService.GetAPIKeyDailyUsage(c.Request.Context(), userID, apiKeyID, startTime, endTime) + if err != nil { + return nil + } + return stats +} + // usageQuotaLimited 处理 quota_limited 模式的响应 -func (h *GatewayHandler) usageQuotaLimited(c *gin.Context, ctx context.Context, apiKey *service.APIKey, usageData gin.H, modelStats any) { +func (h *GatewayHandler) usageQuotaLimited(c *gin.Context, ctx context.Context, apiKey *service.APIKey, usageData gin.H, dailyUsage any, modelStats any) { resp := gin.H{ "mode": "quota_limited", "isValid": apiKey.Status == service.StatusAPIKeyActive || apiKey.Status == service.StatusAPIKeyQuotaExhausted || apiKey.Status == service.StatusAPIKeyExpired, @@ -1199,6 +1217,9 @@ func (h *GatewayHandler) usageQuotaLimited(c *gin.Context, ctx context.Context, if usageData != nil { resp["usage"] = usageData } + if dailyUsage != nil { + resp["daily_usage"] = dailyUsage + } if modelStats != nil { resp["model_stats"] = modelStats } @@ -1207,7 +1228,7 @@ func (h *GatewayHandler) usageQuotaLimited(c *gin.Context, ctx context.Context, } // usageUnrestricted 处理 unrestricted 模式的响应(向后兼容) -func (h *GatewayHandler) usageUnrestricted(c *gin.Context, ctx context.Context, apiKey *service.APIKey, subject middleware2.AuthSubject, usageData gin.H, modelStats any) { +func (h *GatewayHandler) usageUnrestricted(c *gin.Context, ctx context.Context, apiKey *service.APIKey, subject middleware2.AuthSubject, usageData gin.H, dailyUsage any, modelStats any) { // 订阅模式 if apiKey.Group != nil && apiKey.Group.IsSubscriptionType() { resp := gin.H{ @@ -1236,6 +1257,9 @@ func (h *GatewayHandler) usageUnrestricted(c *gin.Context, ctx context.Context, if usageData != nil { resp["usage"] = usageData } + if dailyUsage != nil { + resp["daily_usage"] = dailyUsage + } if modelStats != nil { resp["model_stats"] = modelStats } @@ -1261,6 +1285,9 @@ func (h *GatewayHandler) usageUnrestricted(c *gin.Context, ctx context.Context, if usageData != nil { resp["usage"] = usageData } + if dailyUsage != nil { + resp["daily_usage"] = dailyUsage + } if modelStats != nil { resp["model_stats"] = modelStats } diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index b8506154..daa5695d 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -298,6 +298,29 @@ func parseUserTimeRange(c *gin.Context) (time.Time, time.Time) { return startTime, endTime } +const ( + defaultAPIKeyDailyUsageDays = 30 + maxAPIKeyDailyUsageDays = 90 +) + +func parseAPIKeyDailyUsageDays(raw string) (int, bool) { + if strings.TrimSpace(raw) == "" { + return defaultAPIKeyDailyUsageDays, true + } + days, err := strconv.Atoi(raw) + if err != nil || days <= 0 || days > maxAPIKeyDailyUsageDays { + return 0, false + } + return days, true +} + +func apiKeyDailyUsageRange(days int, userTZ string) (time.Time, time.Time) { + now := timezone.NowInUserLocation(userTZ) + startTime := timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -(days-1)), userTZ) + endTime := timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ) + return startTime, endTime +} + // DashboardStats handles getting user dashboard statistics // GET /api/v1/usage/dashboard/stats func (h *UsageHandler) DashboardStats(c *gin.Context) { @@ -416,3 +439,55 @@ func (h *UsageHandler) DashboardAPIKeysUsage(c *gin.Context) { response.Success(c, gin.H{"stats": stats}) } + +// GetMyAPIKeyDailyUsage handles getting daily usage details for the current user's API key. +// GET /api/v1/user/api-keys/:id/usage/daily?days=30 +func (h *UsageHandler) GetMyAPIKeyDailyUsage(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + apiKeyID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid API key ID") + return + } + + days, ok := parseAPIKeyDailyUsageDays(c.DefaultQuery("days", "")) + if !ok { + response.BadRequest(c, "Invalid days, allowed range is 1-90") + return + } + + if h.apiKeyService == nil { + response.InternalError(c, "API key service is not configured") + return + } + + apiKey, err := h.apiKeyService.GetByID(c.Request.Context(), apiKeyID) + if err != nil { + response.ErrorFrom(c, err) + return + } + if apiKey.UserID != subject.UserID { + response.Forbidden(c, "Not authorized to access this API key's usage") + return + } + + userTZ := c.Query("timezone") + startTime, endTime := apiKeyDailyUsageRange(days, userTZ) + items, err := h.usageService.GetAPIKeyDailyUsage(c.Request.Context(), subject.UserID, apiKeyID, startTime, endTime) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{ + "items": items, + "days": days, + "start_date": startTime.Format("2006-01-02"), + "end_date": endTime.AddDate(0, 0, -1).Format("2006-01-02"), + }) +} diff --git a/backend/internal/handler/usage_handler_daily_test.go b/backend/internal/handler/usage_handler_daily_test.go new file mode 100644 index 00000000..36311fac --- /dev/null +++ b/backend/internal/handler/usage_handler_daily_test.go @@ -0,0 +1,195 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type dailyUsageRepoStub struct { + service.UsageLogRepository + trend []usagestats.TrendDataPoint + + called bool + startTime time.Time + endTime time.Time + granularity string + userID int64 + apiKeyID int64 +} + +func (s *dailyUsageRepoStub) GetUsageTrendWithFilters( + ctx context.Context, + startTime, endTime time.Time, + granularity string, + userID, apiKeyID, accountID, groupID int64, + model string, + requestType *int16, + stream *bool, + billingType *int8, +) ([]usagestats.TrendDataPoint, error) { + s.called = true + s.startTime = startTime + s.endTime = endTime + s.granularity = granularity + s.userID = userID + s.apiKeyID = apiKeyID + return s.trend, nil +} + +type dailyUsageAPIKeyRepoStub struct { + service.APIKeyRepository + keys map[int64]*service.APIKey +} + +func (s *dailyUsageAPIKeyRepoStub) GetByID(ctx context.Context, id int64) (*service.APIKey, error) { + key, ok := s.keys[id] + if !ok { + return nil, service.ErrAPIKeyNotFound + } + clone := *key + return &clone, nil +} + +func newDailyUsageTestRouter(usageRepo *dailyUsageRepoStub, apiKeyRepo *dailyUsageAPIKeyRepoStub, userID int64) *gin.Engine { + gin.SetMode(gin.TestMode) + usageSvc := service.NewUsageService(usageRepo, nil, nil, nil) + apiKeySvc := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, nil) + handler := NewUsageHandler(usageSvc, apiKeySvc) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: userID}) + c.Next() + }) + router.GET("/user/api-keys/:id/usage/daily", handler.GetMyAPIKeyDailyUsage) + return router +} + +type dailyUsageHandlerResponse struct { + Code int `json:"code"` + Data struct { + Items []usagestats.APIKeyDailyUsagePoint `json:"items"` + Days int `json:"days"` + } `json:"data"` +} + +func TestGetMyAPIKeyDailyUsageRejectsCrossUserAccess(t *testing.T) { + usageRepo := &dailyUsageRepoStub{} + apiKeyRepo := &dailyUsageAPIKeyRepoStub{ + keys: map[int64]*service.APIKey{ + 7: {ID: 7, UserID: 99, Status: service.StatusAPIKeyActive}, + }, + } + router := newDailyUsageTestRouter(usageRepo, apiKeyRepo, 42) + + req := httptest.NewRequest(http.MethodGet, "/user/api-keys/7/usage/daily?days=30", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusForbidden, rec.Code) + require.False(t, usageRepo.called) +} + +func TestGetMyAPIKeyDailyUsageRejectsInvalidDays(t *testing.T) { + for _, path := range []string{ + "/user/api-keys/7/usage/daily?days=0", + "/user/api-keys/7/usage/daily?days=91", + } { + t.Run(path, func(t *testing.T) { + usageRepo := &dailyUsageRepoStub{} + apiKeyRepo := &dailyUsageAPIKeyRepoStub{ + keys: map[int64]*service.APIKey{ + 7: {ID: 7, UserID: 42, Status: service.StatusAPIKeyActive}, + }, + } + router := newDailyUsageTestRouter(usageRepo, apiKeyRepo, 42) + + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.False(t, usageRepo.called) + }) + } +} + +func TestGetMyAPIKeyDailyUsageReturnsEmptyData(t *testing.T) { + usageRepo := &dailyUsageRepoStub{trend: []usagestats.TrendDataPoint{}} + apiKeyRepo := &dailyUsageAPIKeyRepoStub{ + keys: map[int64]*service.APIKey{ + 7: {ID: 7, UserID: 42, Status: service.StatusAPIKeyActive}, + }, + } + router := newDailyUsageTestRouter(usageRepo, apiKeyRepo, 42) + + req := httptest.NewRequest(http.MethodGet, "/user/api-keys/7/usage/daily", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var got dailyUsageHandlerResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, 30, got.Data.Days) + require.Empty(t, got.Data.Items) +} + +func TestGetMyAPIKeyDailyUsageAggregatesByDayForOwnedKey(t *testing.T) { + usageRepo := &dailyUsageRepoStub{ + trend: []usagestats.TrendDataPoint{ + { + Date: "2026-05-19", + Requests: 3, + InputTokens: 10, + OutputTokens: 20, + CacheCreationTokens: 4, + CacheReadTokens: 6, + TotalTokens: 40, + Cost: 0.5, + ActualCost: 0.4, + }, + }, + } + apiKeyRepo := &dailyUsageAPIKeyRepoStub{ + keys: map[int64]*service.APIKey{ + 7: {ID: 7, UserID: 42, Status: service.StatusAPIKeyActive}, + }, + } + router := newDailyUsageTestRouter(usageRepo, apiKeyRepo, 42) + + req := httptest.NewRequest(http.MethodGet, "/user/api-keys/7/usage/daily?days=7", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.True(t, usageRepo.called) + require.Equal(t, "day", usageRepo.granularity) + require.Equal(t, int64(42), usageRepo.userID) + require.Equal(t, int64(7), usageRepo.apiKeyID) + require.True(t, usageRepo.startTime.Before(usageRepo.endTime)) + + var got dailyUsageHandlerResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, 7, got.Data.Days) + require.Len(t, got.Data.Items, 1) + require.Equal(t, usagestats.APIKeyDailyUsagePoint{ + Date: "2026-05-19", + Requests: 3, + InputTokens: 10, + OutputTokens: 20, + CacheReadTokens: 6, + CacheWriteTokens: 4, + TotalTokens: 40, + Cost: 0.5, + ActualCost: 0.4, + }, got.Data.Items[0]) +} diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index 39283d22..5307389d 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -198,6 +198,19 @@ type APIKeyUsageTrendPoint struct { Tokens int64 `json:"tokens"` } +// APIKeyDailyUsagePoint represents one day of usage for a single API key. +type APIKeyDailyUsagePoint struct { + Date string `json:"date"` + Requests int64 `json:"requests"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheWriteTokens int64 `json:"cache_write_tokens"` + TotalTokens int64 `json:"total_tokens"` + Cost float64 `json:"cost"` // 标准计费 + ActualCost float64 `json:"actual_cost"` // 实际扣除 +} + // UserDashboardStats 用户仪表盘统计 type UserDashboardStats struct { // API Key 统计 diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index 9976954c..e79d3ee3 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -31,6 +31,7 @@ func RegisterUserRoutes( user.POST("/account-bindings/email", h.User.BindEmailIdentity) user.DELETE("/account-bindings/:provider", h.User.UnbindIdentity) user.POST("/auth-identities/bind/start", h.User.StartIdentityBinding) + user.GET("/api-keys/:id/usage/daily", h.Usage.GetMyAPIKeyDailyUsage) // 通知邮箱管理 notifyEmail := user.Group("/notify-email") diff --git a/backend/internal/service/usage_service.go b/backend/internal/service/usage_service.go index d64f01e0..db572cc3 100644 --- a/backend/internal/service/usage_service.go +++ b/backend/internal/service/usage_service.go @@ -324,6 +324,30 @@ func (s *UsageService) GetAPIKeyModelStats(ctx context.Context, apiKeyID int64, return stats, nil } +// GetAPIKeyDailyUsage returns daily usage stats for a user's API key. +func (s *UsageService) GetAPIKeyDailyUsage(ctx context.Context, userID, apiKeyID int64, startTime, endTime time.Time) ([]usagestats.APIKeyDailyUsagePoint, error) { + trend, err := s.usageRepo.GetUsageTrendWithFilters(ctx, startTime, endTime, "day", userID, apiKeyID, 0, 0, "", nil, nil, nil) + if err != nil { + return nil, fmt.Errorf("get api key daily usage: %w", err) + } + + points := make([]usagestats.APIKeyDailyUsagePoint, 0, len(trend)) + for _, row := range trend { + points = append(points, usagestats.APIKeyDailyUsagePoint{ + Date: row.Date, + Requests: row.Requests, + InputTokens: row.InputTokens, + OutputTokens: row.OutputTokens, + CacheReadTokens: row.CacheReadTokens, + CacheWriteTokens: row.CacheCreationTokens, + TotalTokens: row.TotalTokens, + Cost: row.Cost, + ActualCost: row.ActualCost, + }) + } + return points, nil +} + // GetBatchAPIKeyUsageStats returns today/total actual_cost for given api keys. func (s *UsageService) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) { stats, err := s.usageRepo.GetBatchAPIKeyUsageStats(ctx, apiKeyIDs, startTime, endTime) diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index 7169b698..ee08ee9d 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -69,6 +69,25 @@ export interface ModelStatsResponse { end_date: string } +export interface ApiKeyDailyUsagePoint { + date: string + requests: number + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + total_tokens: number + cost: number + actual_cost: number +} + +export interface ApiKeyDailyUsageResponse { + items: ApiKeyDailyUsagePoint[] + days: number + start_date: string + end_date: string +} + /** * List usage logs with optional filters * @param page - Page number (default: 1) @@ -234,6 +253,23 @@ export async function getDashboardModels(params?: { return data } +/** + * Get daily usage details for one API key owned by the current user. + * @param apiKeyId - API key ID + * @param days - Number of days to include (1-90) + * @returns Daily usage detail rows + */ +export async function getMyApiKeyDailyUsage( + apiKeyId: number, + days: number = 30 +): Promise { + const { data } = await apiClient.get( + `/user/api-keys/${apiKeyId}/usage/daily`, + { params: { days } } + ) + return data +} + export interface BatchApiKeyUsageStats { api_key_id: number today_actual_cost: number @@ -279,6 +315,7 @@ export const usageAPI = { getDashboardStats, getDashboardTrend, getDashboardModels, + getMyApiKeyDailyUsage, getDashboardApiKeysUsage } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 5f0fe6f5..f10ac21e 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -122,19 +122,23 @@ export default { dateRangeToday: 'Today', dateRange7d: '7 Days', dateRange30d: '30 Days', + dateRange90d: '90 Days', dateRangeCustom: 'Custom', apply: 'Apply', used: 'Used', detailInfo: 'Detail Information', tokenStats: 'Token Statistics', + dailyDetail: 'Daily Detail', modelStats: 'Model Usage Statistics', // Table headers + date: 'Date', model: 'Model', requests: 'Requests', inputTokens: 'Input Tokens', outputTokens: 'Output Tokens', cacheCreationTokens: 'Cache Creation', cacheReadTokens: 'Cache Read', + cacheWriteTokens: 'Cache Write', totalTokens: 'Total Tokens', cost: 'Cost', // Status @@ -178,6 +182,7 @@ export default { querySuccess: 'Query successful', queryFailed: 'Query failed', queryFailedRetry: 'Query failed, please try again later', + noDailyUsage: 'No daily usage data', }, // Setup Wizard @@ -4148,6 +4153,22 @@ export default { }, userPrefix: 'User #{id}', exportCsv: 'Export CSV', + batchUpdate: 'Batch Update', + batchUpdateTitle: 'Batch Update Redeem Codes', + selectedCount: '{count} redeem code(s) selected', + clearSelection: 'Clear selection', + selectCodesFirst: 'Select redeem codes first', + noBatchFieldsSelected: 'Select at least one field to update', + batchUpdateSuccess: 'Updated {count} redeem code(s)', + failedToBatchUpdate: 'Failed to batch update redeem codes', + batchFields: { + status: 'Status', + expiresAt: 'Expires At', + notes: 'Notes', + group: 'Group' + }, + batchNotesPlaceholder: 'Enter the new note, or leave blank to clear it', + clearGroup: 'Clear group', deleteAllUnused: 'Delete All Unused Codes', deleteCode: 'Delete Redeem Code', deleteCodeConfirm: diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index a03435db..286fc28d 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -122,19 +122,23 @@ export default { dateRangeToday: '今日', dateRange7d: '7 天', dateRange30d: '30 天', + dateRange90d: '90 天', dateRangeCustom: '自定义', apply: '应用', used: '已使用', detailInfo: '详细信息', tokenStats: 'Token 统计', + dailyDetail: '按日明细', modelStats: '模型用量统计', // Table headers + date: '日期', model: '模型', requests: '请求数', inputTokens: '输入 Tokens', outputTokens: '输出 Tokens', cacheCreationTokens: '缓存创建', cacheReadTokens: '缓存读取', + cacheWriteTokens: '缓存写入', totalTokens: '总 Tokens', cost: '费用', // Status @@ -178,6 +182,7 @@ export default { querySuccess: '查询成功', queryFailed: '查询失败', queryFailedRetry: '查询失败,请稍后重试', + noDailyUsage: '暂无按日用量数据', }, // Setup Wizard @@ -4282,6 +4287,22 @@ export default { used: '已使用', searchCodes: '搜索兑换码或邮箱...', exportCsv: '导出 CSV', + batchUpdate: '批量修改', + batchUpdateTitle: '批量修改兑换码', + selectedCount: '已选择 {count} 个兑换码', + clearSelection: '清空选择', + selectCodesFirst: '请先选择兑换码', + noBatchFieldsSelected: '请至少勾选一个要修改的字段', + batchUpdateSuccess: '成功修改 {count} 个兑换码', + failedToBatchUpdate: '批量修改兑换码失败', + batchFields: { + status: '状态', + expiresAt: '过期时间', + notes: '备注', + group: '分组' + }, + batchNotesPlaceholder: '输入新的备注,留空可清空备注', + clearGroup: '清空分组', deleteAllUnused: '删除全部未使用', deleteCodeConfirm: '确定要删除此兑换码吗?此操作无法撤销。', deleteAllUnusedConfirm: '确定要删除全部未使用的兑换码吗?此操作无法撤销。', diff --git a/frontend/src/views/KeyUsageView.vue b/frontend/src/views/KeyUsageView.vue index 21a35340..c3a303d4 100644 --- a/frontend/src/views/KeyUsageView.vue +++ b/frontend/src/views/KeyUsageView.vue @@ -289,6 +289,62 @@ + +
+
+

{{ t('keyUsage.dailyDetail') }}

+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
{{ t('keyUsage.date') }}{{ t('keyUsage.requests') }}{{ t('keyUsage.inputTokens') }}{{ t('keyUsage.outputTokens') }}{{ t('keyUsage.cacheReadTokens') }}{{ t('keyUsage.cacheWriteTokens') }}{{ t('keyUsage.cost') }}
{{ row.date }}{{ fmtNum(row.requests) }}{{ fmtNum(row.input_tokens) }}{{ fmtNum(row.output_tokens) }}{{ fmtNum(row.cache_read_tokens) }}{{ fmtNum(row.cache_write_tokens) }}{{ usd(row.actual_cost != null ? row.actual_cost : row.cost) }}
+
+
+ {{ t('keyUsage.noDailyUsage') }} +
+
+
('today') const customStartDate = ref('') const customEndDate = ref('') +const dailyUsageDays = ref<7 | 30 | 90>(30) const dateRanges = computed(() => [ { key: 'today' as const, label: t('keyUsage.dateRangeToday') }, @@ -416,6 +473,12 @@ const dateRanges = computed(() => [ { key: 'custom' as const, label: t('keyUsage.dateRangeCustom') }, ]) +const dailyUsageOptions = computed(() => [ + { value: 7 as const, label: t('keyUsage.dateRange7d') }, + { value: 30 as const, label: t('keyUsage.dateRange30d') }, + { value: 90 as const, label: t('keyUsage.dateRange90d') }, +]) + function setDateRange(key: DateRangeKey) { currentRange.value = key if (key !== 'custom') { @@ -426,23 +489,36 @@ function setDateRange(key: DateRangeKey) { function getDateParams(): string { const now = new Date() const fmt = (d: Date) => d.toISOString().split('T')[0] + const params = new URLSearchParams() if (currentRange.value === 'custom') { if (customStartDate.value && customEndDate.value) { - return `start_date=${customStartDate.value}&end_date=${customEndDate.value}` + params.set('start_date', customStartDate.value) + params.set('end_date', customEndDate.value) } - return '' + } else { + const end = fmt(now) + let start: string + switch (currentRange.value) { + case 'today': start = end; break + case '7d': start = fmt(new Date(now.getTime() - 7 * 86400000)); break + case '30d': start = fmt(new Date(now.getTime() - 30 * 86400000)); break + default: start = fmt(new Date(now.getTime() - 30 * 86400000)) + } + params.set('start_date', start) + params.set('end_date', end) } + params.set('days', String(dailyUsageDays.value)) + params.set('timezone', getBrowserTimezone()) + return params.toString() +} - const end = fmt(now) - let start: string - switch (currentRange.value) { - case 'today': start = end; break - case '7d': start = fmt(new Date(now.getTime() - 7 * 86400000)); break - case '30d': start = fmt(new Date(now.getTime() - 30 * 86400000)); break - default: start = fmt(new Date(now.getTime() - 30 * 86400000)) +function setDailyUsageDays(days: 7 | 30 | 90) { + if (dailyUsageDays.value === days) return + dailyUsageDays.value = days + if (resultData.value && apiKey.value.trim()) { + queryKey() } - return `start_date=${start}&end_date=${end}` } // ==================== Ring Animation ==================== @@ -731,6 +807,24 @@ const usageStatCells = computed(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const modelStats = computed(() => resultData.value?.model_stats || []) +interface DailyUsageRow { + date: string + requests: number + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + cost: number + actual_cost?: number +} + +const dailyUsageRows = computed(() => { + const rows = resultData.value?.daily_usage + return Array.isArray(rows) ? rows : [] +}) + +const showDailyUsage = computed(() => Boolean(resultData.value && Array.isArray(resultData.value.daily_usage))) + // ==================== Utility Functions ==================== function usd(value: number | null | undefined): string { @@ -750,6 +844,14 @@ function formatDate(iso: string | null | undefined): string { return d.toLocaleDateString(loc, { year: 'numeric', month: 'long', day: 'numeric' }) } +function getBrowserTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' + } catch { + return 'UTC' + } +} + // ==================== API Query ==================== async function fetchUsage(key: string) { diff --git a/frontend/src/views/__tests__/KeyUsageView.spec.ts b/frontend/src/views/__tests__/KeyUsageView.spec.ts new file mode 100644 index 00000000..c1373bc3 --- /dev/null +++ b/frontend/src/views/__tests__/KeyUsageView.spec.ts @@ -0,0 +1,208 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { nextTick } from 'vue' + +import KeyUsageView from '../KeyUsageView.vue' + +const { showInfo, showSuccess, showError, fetchPublicSettings } = vi.hoisted(() => ({ + showInfo: vi.fn(), + showSuccess: vi.fn(), + showError: vi.fn(), + fetchPublicSettings: vi.fn(), +})) + +const messages: Record = { + 'keyUsage.title': 'API Key Usage', + 'keyUsage.subtitle': 'Usage status', + 'keyUsage.placeholder': 'sk-test', + 'keyUsage.query': 'Query', + 'keyUsage.querying': 'Querying...', + 'keyUsage.privacyNote': 'Privacy note', + 'keyUsage.dateRange': 'Date Range:', + 'keyUsage.dateRangeToday': 'Today', + 'keyUsage.dateRange7d': '7 Days', + 'keyUsage.dateRange30d': '30 Days', + 'keyUsage.dateRange90d': '90 Days', + 'keyUsage.dateRangeCustom': 'Custom', + 'keyUsage.apply': 'Apply', + 'keyUsage.used': 'Used', + 'keyUsage.detailInfo': 'Detail Information', + 'keyUsage.tokenStats': 'Token Statistics', + 'keyUsage.dailyDetail': 'Daily Detail', + 'keyUsage.date': 'Date', + 'keyUsage.requests': 'Requests', + 'keyUsage.inputTokens': 'Input Tokens', + 'keyUsage.outputTokens': 'Output Tokens', + 'keyUsage.cacheReadTokens': 'Cache Read', + 'keyUsage.cacheWriteTokens': 'Cache Write', + 'keyUsage.cost': 'Cost', + 'keyUsage.quotaMode': 'Key Quota Mode', + 'keyUsage.walletBalance': 'Wallet Balance', + 'keyUsage.totalQuota': 'Total Quota', + 'keyUsage.limit5h': '5-Hour Limit', + 'keyUsage.limitDaily': 'Daily Limit', + 'keyUsage.limit7d': '7-Day Limit', + 'keyUsage.limitWeekly': 'Weekly Limit', + 'keyUsage.limitMonthly': 'Monthly Limit', + 'keyUsage.remainingQuota': 'Remaining Quota', + 'keyUsage.usedQuota': 'Used Quota', + 'keyUsage.subscriptionType': 'Subscription Type', + 'keyUsage.todayRequests': 'Today Requests', + 'keyUsage.todayInputTokens': 'Today Input', + 'keyUsage.todayOutputTokens': 'Today Output', + 'keyUsage.todayTokens': 'Today Tokens', + 'keyUsage.todayCacheCreation': 'Today Cache Creation', + 'keyUsage.todayCacheRead': 'Today Cache Read', + 'keyUsage.todayCost': 'Today Cost', + 'keyUsage.rpmTpm': 'RPM / TPM', + 'keyUsage.totalRequests': 'Total Requests', + 'keyUsage.totalInputTokens': 'Total Input', + 'keyUsage.totalOutputTokens': 'Total Output', + 'keyUsage.totalTokensLabel': 'Total Tokens', + 'keyUsage.totalCacheCreation': 'Total Cache Creation', + 'keyUsage.totalCacheRead': 'Total Cache Read', + 'keyUsage.totalCost': 'Total Cost', + 'keyUsage.avgDuration': 'Avg Duration', + 'keyUsage.querySuccess': 'Query successful', + 'keyUsage.queryFailed': 'Query failed', + 'keyUsage.queryFailedRetry': 'Query failed, please try again later', + 'home.viewDocs': 'Docs', + 'home.switchToLight': 'Light', + 'home.switchToDark': 'Dark', + 'home.footer.allRightsReserved': 'All rights reserved.', +} + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => messages[key] ?? key, + locale: { value: 'en' }, + }), + } +}) + +vi.mock('@/stores', () => ({ + useAppStore: () => ({ + cachedPublicSettings: null, + siteName: 'Sub2API', + siteLogo: '', + docUrl: '', + publicSettingsLoaded: true, + fetchPublicSettings, + showInfo, + showSuccess, + showError, + }), +})) + +describe('KeyUsageView daily detail', () => { + beforeEach(() => { + showInfo.mockReset() + showSuccess.mockReset() + showError.mockReset() + fetchPublicSettings.mockReset() + localStorage.clear() + + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockReturnValue({ matches: false }), + }) + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => window.setTimeout(() => cb(0), 0)) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + mode: 'quota_limited', + isValid: true, + status: 'active', + quota: { + limit: 10, + used: 1, + remaining: 9, + unit: 'USD', + }, + usage: { + today: { + requests: 1, + input_tokens: 10, + output_tokens: 20, + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: 30, + actual_cost: 0.01, + }, + total: { + requests: 12, + input_tokens: 100, + output_tokens: 200, + cache_creation_tokens: 10, + cache_read_tokens: 30, + total_tokens: 340, + actual_cost: 0.12, + }, + rpm: 0, + tpm: 0, + }, + daily_usage: [ + { + date: '2026-05-19', + requests: 12, + input_tokens: 100, + output_tokens: 200, + cache_read_tokens: 30, + cache_write_tokens: 10, + total_tokens: 340, + cost: 0.15, + actual_cost: 0.12, + }, + ], + }), + })) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('renders daily usage detail rows after a successful query', async () => { + const wrapper = mount(KeyUsageView, { + global: { + stubs: { + RouterLink: { template: '' }, + LocaleSwitcher: true, + Icon: true, + }, + }, + }) + + await wrapper.find('input').setValue('sk-test-key') + await wrapper.find('input').trigger('keydown.enter') + await flushPromises() + await nextTick() + + const fetchMock = vi.mocked(fetch) + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/v1/usage?'), + expect.objectContaining({ + headers: { Authorization: 'Bearer sk-test-key' }, + }) + ) + expect(String(fetchMock.mock.calls[0][0])).toContain('days=30') + + const text = wrapper.text() + expect(text).toContain('Daily Detail') + expect(text).toContain('Date') + expect(text).toContain('Cache Read') + expect(text).toContain('Cache Write') + expect(text).toContain('2026-05-19') + expect(text).toContain('12') + expect(text).toContain('100') + expect(text).toContain('200') + expect(text).toContain('30') + expect(text).toContain('10') + expect(text).toContain('$0.12') + + wrapper.unmount() + }) +})