diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index ff1c1b8865..c34c71bf6d 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -15,7 +15,7 @@ var ( {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, - {Name: "key", Type: field.TypeString, Unique: true, Size: 128}, + {Name: "key", Type: field.TypeString, Size: 128}, {Name: "name", Type: field.TypeString, Size: 100}, {Name: "status", Type: field.TypeString, Size: 20, Default: "active"}, {Name: "last_used_at", Type: field.TypeTime, Nullable: true}, diff --git a/backend/ent/schema/api_key.go b/backend/ent/schema/api_key.go index 5db51270b1..e13e6e9f73 100644 --- a/backend/ent/schema/api_key.go +++ b/backend/ent/schema/api_key.go @@ -36,8 +36,7 @@ func (APIKey) Fields() []ent.Field { field.Int64("user_id"), field.String("key"). MaxLen(128). - NotEmpty(). - Unique(), + NotEmpty(), field.String("name"). MaxLen(100). NotEmpty(), diff --git a/backend/internal/handler/sora_gateway_handler_test.go b/backend/internal/handler/sora_gateway_handler_test.go index 688c5d1298..bd94cc7ebd 100644 --- a/backend/internal/handler/sora_gateway_handler_test.go +++ b/backend/internal/handler/sora_gateway_handler_test.go @@ -388,6 +388,12 @@ func (s *stubUsageLogRepo) GetModelStatsAggregated(ctx context.Context, modelNam func (s *stubUsageLogRepo) GetDailyStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) ([]map[string]any, error) { return nil, nil } +func (s *stubUsageLogRepo) GetAPIKeyModelDistribution(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.APIKeyModelDistributionItem, error) { + return nil, nil +} +func (s *stubUsageLogRepo) GetUserAPIKeyTrend(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.APIKeyTrendItem, error) { + return nil, nil +} func TestSoraGatewayHandler_ChatCompletions(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index 2bd0e0d7b5..4a604f25aa 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -361,6 +361,54 @@ func (h *UsageHandler) DashboardModels(c *gin.Context) { }) } +// DashboardAPIKeyModelDistribution handles getting model distribution per API Key +// GET /api/v1/usage/dashboard/api-key-model-distribution +func (h *UsageHandler) DashboardAPIKeyModelDistribution(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + startTime, endTime := parseUserTimeRange(c) + + distribution, err := h.usageService.GetUserAPIKeyModelDistribution(c.Request.Context(), subject.UserID, startTime, endTime) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{ + "distribution": distribution, + }) +} + +// DashboardAPIKeyTrend handles getting per-API-Key trend data +// GET /api/v1/usage/dashboard/api-key-trend +func (h *UsageHandler) DashboardAPIKeyTrend(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + startTime, endTime := parseUserTimeRange(c) + granularity := c.DefaultQuery("granularity", "day") + + trends, err := h.usageService.GetUserAPIKeyTrend(c.Request.Context(), subject.UserID, startTime, endTime, granularity) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{ + "trends": trends, + "start_date": startTime.Format("2006-01-02"), + "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"), + "granularity": granularity, + }) +} + // BatchAPIKeysUsageRequest represents the request for batch API keys usage type BatchAPIKeysUsageRequest struct { APIKeyIDs []int64 `json:"api_key_ids" binding:"required"` diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index 8826c048cd..8db1904e85 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -111,6 +111,29 @@ type APIKeyUsageTrendPoint struct { Tokens int64 `json:"tokens"` } +// APIKeyModelDistributionItem represents model usage for a single API Key. +type APIKeyModelDistributionItem struct { + APIKeyID int64 `json:"api_key_id"` + APIKeyName string `json:"api_key_name"` + Models []ModelStat `json:"models"` +} + +// APIKeyTrendDataPoint represents a single trend data point for an API Key. +type APIKeyTrendDataPoint struct { + Date string `json:"date"` + Requests int64 `json:"requests"` + TotalTokens int64 `json:"total_tokens"` + Cost float64 `json:"cost"` + ActualCost float64 `json:"actual_cost"` +} + +// APIKeyTrendItem represents trend data for a single API Key. +type APIKeyTrendItem struct { + APIKeyID int64 `json:"api_key_id"` + APIKeyName string `json:"api_key_name"` + Data []APIKeyTrendDataPoint `json:"data"` +} + // UserDashboardStats 用户仪表盘统计 type UserDashboardStats struct { // API Key 统计 diff --git a/backend/internal/repository/api_key_repo_integration_test.go b/backend/internal/repository/api_key_repo_integration_test.go index 807146147b..b8db843ccf 100644 --- a/backend/internal/repository/api_key_repo_integration_test.go +++ b/backend/internal/repository/api_key_repo_integration_test.go @@ -230,6 +230,32 @@ func (s *APIKeyRepoSuite) TestExistsByKey() { s.Require().False(notExists) } +func (s *APIKeyRepoSuite) TestCreateAfterDelete_SameKey() { + // 回归测试:key 软删除后应允许重新创建相同 key(部分唯一索引) + user := s.mustCreateUser("recreate@test.com") + key := s.mustCreateApiKey(user.ID, "sk-recreate", "Original", nil) + + // 软删除 + err := s.repo.Delete(s.ctx, key.ID) + s.Require().NoError(err) + + // 删除后 ExistsByKey 应返回 false + exists, err := s.repo.ExistsByKey(s.ctx, "sk-recreate") + s.Require().NoError(err) + s.Require().False(exists, "deleted key should not be reported as existing") + + // 重新创建同一 key,不应报 409 冲突 + newKey := &service.APIKey{ + UserID: user.ID, + Key: "sk-recreate", + Name: "Recreated", + Status: service.StatusActive, + } + err = s.repo.Create(s.ctx, newKey) + s.Require().NoError(err, "should be able to recreate a previously deleted key") + s.Require().Greater(newKey.ID, key.ID, "new record should have a different ID") +} + // --- SearchAPIKeys --- func (s *APIKeyRepoSuite) TestSearchAPIKeys() { diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index c91a68e514..8caefe4ac0 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -1436,6 +1436,131 @@ func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64 return results, nil } +// GetAPIKeyModelDistribution returns model usage distribution grouped by API Key for a user. +func (r *usageLogRepository) GetAPIKeyModelDistribution(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.APIKeyModelDistributionItem, error) { + query := ` + SELECT ul.api_key_id, COALESCE(ak.name, '') as api_key_name, ul.model, + COUNT(*) as requests, + COALESCE(SUM(ul.input_tokens), 0) as input_tokens, + COALESCE(SUM(ul.output_tokens), 0) as output_tokens, + COALESCE(SUM(ul.cache_creation_tokens), 0) as cache_creation_tokens, + COALESCE(SUM(ul.cache_read_tokens), 0) as cache_read_tokens, + COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens, + COALESCE(SUM(ul.total_cost), 0) as cost, + COALESCE(SUM(ul.actual_cost), 0) as actual_cost + FROM usage_logs ul + LEFT JOIN api_keys ak ON ul.api_key_id = ak.id + WHERE ul.user_id = $1 AND ul.created_at >= $2 AND ul.created_at < $3 + GROUP BY ul.api_key_id, ak.name, ul.model + ORDER BY ul.api_key_id, requests DESC + ` + + rows, err := r.sql.QueryContext(ctx, query, userID, startTime, endTime) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + // Scan flat rows and group by api_key_id + itemMap := make(map[int64]*usagestats.APIKeyModelDistributionItem) + var orderedKeys []int64 + + for rows.Next() { + var apiKeyID int64 + var apiKeyName string + var stat ModelStat + if err := rows.Scan( + &apiKeyID, &apiKeyName, &stat.Model, + &stat.Requests, &stat.InputTokens, &stat.OutputTokens, + &stat.CacheCreationTokens, &stat.CacheReadTokens, &stat.TotalTokens, + &stat.Cost, &stat.ActualCost, + ); err != nil { + return nil, err + } + item, ok := itemMap[apiKeyID] + if !ok { + item = &usagestats.APIKeyModelDistributionItem{ + APIKeyID: apiKeyID, + APIKeyName: apiKeyName, + Models: make([]ModelStat, 0), + } + itemMap[apiKeyID] = item + orderedKeys = append(orderedKeys, apiKeyID) + } + item.Models = append(item.Models, stat) + } + if err := rows.Err(); err != nil { + return nil, err + } + + results := make([]usagestats.APIKeyModelDistributionItem, 0, len(orderedKeys)) + for _, key := range orderedKeys { + results = append(results, *itemMap[key]) + } + return results, nil +} + +// GetUserAPIKeyTrend returns per-API-Key trend data for a user. +func (r *usageLogRepository) GetUserAPIKeyTrend(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.APIKeyTrendItem, error) { + dateFormat := safeDateFormat(granularity) + + query := fmt.Sprintf(` + SELECT TO_CHAR(ul.created_at, '%s') as date, + ul.api_key_id, COALESCE(ak.name, '') as api_key_name, + COUNT(*) as requests, + COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens, + COALESCE(SUM(ul.total_cost), 0) as cost, + COALESCE(SUM(ul.actual_cost), 0) as actual_cost + FROM usage_logs ul + LEFT JOIN api_keys ak ON ul.api_key_id = ak.id + WHERE ul.user_id = $1 AND ul.created_at >= $2 AND ul.created_at < $3 + GROUP BY date, ul.api_key_id, ak.name + ORDER BY ul.api_key_id, date + `, dateFormat) + + rows, err := r.sql.QueryContext(ctx, query, userID, startTime, endTime) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + itemMap := make(map[int64]*usagestats.APIKeyTrendItem) + var orderedKeys []int64 + + for rows.Next() { + var date string + var apiKeyID int64 + var apiKeyName string + var point usagestats.APIKeyTrendDataPoint + + if err := rows.Scan(&date, &apiKeyID, &apiKeyName, &point.Requests, &point.TotalTokens, &point.Cost, &point.ActualCost); err != nil { + return nil, err + } + point.Date = date + + item, ok := itemMap[apiKeyID] + if !ok { + item = &usagestats.APIKeyTrendItem{ + APIKeyID: apiKeyID, + APIKeyName: apiKeyName, + Data: make([]usagestats.APIKeyTrendDataPoint, 0), + } + itemMap[apiKeyID] = item + orderedKeys = append(orderedKeys, apiKeyID) + } + item.Data = append(item.Data, point) + } + if err := rows.Err(); err != nil { + return nil, err + } + + results := make([]usagestats.APIKeyTrendItem, 0, len(orderedKeys)) + for _, key := range orderedKeys { + results = append(results, *itemMap[key]) + } + return results, nil +} + // UsageLogFilters represents filters for usage log queries type UsageLogFilters = usagestats.UsageLogFilters diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 236bd65863..54eb4d423a 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1769,6 +1769,10 @@ func (r *stubUsageLogRepo) GetStatsWithFilters(ctx context.Context, filters usag return nil, errors.New("not implemented") } +func (r *stubUsageLogRepo) GetAPIKeyModelDistribution(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.APIKeyModelDistributionItem, error) { + return nil, errors.New("not implemented") +} + type stubSettingRepo struct { all map[string]string } diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index d0ed248990..41c9a4d386 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -62,6 +62,8 @@ func RegisterUserRoutes( usage.GET("/dashboard/stats", h.Usage.DashboardStats) usage.GET("/dashboard/trend", h.Usage.DashboardTrend) usage.GET("/dashboard/models", h.Usage.DashboardModels) + usage.GET("/dashboard/api-key-model-distribution", h.Usage.DashboardAPIKeyModelDistribution) + usage.GET("/dashboard/api-key-trend", h.Usage.DashboardAPIKeyTrend) usage.POST("/dashboard/api-keys-usage", h.Usage.DashboardAPIKeysUsage) } diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 7c00111876..c9512e0445 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -55,6 +55,8 @@ type UsageLogRepository interface { GetAPIKeyDashboardStats(ctx context.Context, apiKeyID int64) (*usagestats.UserDashboardStats, error) GetUserUsageTrendByUserID(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.TrendDataPoint, error) GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.ModelStat, error) + GetAPIKeyModelDistribution(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.APIKeyModelDistributionItem, error) + GetUserAPIKeyTrend(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.APIKeyTrendItem, error) // Admin usage listing/stats ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]UsageLog, *pagination.PaginationResult, error) diff --git a/backend/internal/service/usage_service.go b/backend/internal/service/usage_service.go index d64f01e086..d3aa6d4289 100644 --- a/backend/internal/service/usage_service.go +++ b/backend/internal/service/usage_service.go @@ -315,6 +315,15 @@ func (s *UsageService) GetUserModelStats(ctx context.Context, userID int64, star return stats, nil } +// GetUserAPIKeyModelDistribution returns model distribution grouped by API Key for a user. +func (s *UsageService) GetUserAPIKeyModelDistribution(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.APIKeyModelDistributionItem, error) { + distribution, err := s.usageRepo.GetAPIKeyModelDistribution(ctx, userID, startTime, endTime) + if err != nil { + return nil, fmt.Errorf("get api key model distribution: %w", err) + } + return distribution, nil +} + // GetAPIKeyModelStats returns per-model usage stats for a specific API Key. func (s *UsageService) GetAPIKeyModelStats(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]usagestats.ModelStat, error) { stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, startTime, endTime, 0, apiKeyID, 0, 0, nil, nil, nil) @@ -333,6 +342,15 @@ func (s *UsageService) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs [ return stats, nil } +// GetUserAPIKeyTrend returns per-API-Key trend data for a user. +func (s *UsageService) GetUserAPIKeyTrend(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.APIKeyTrendItem, error) { + items, err := s.usageRepo.GetUserAPIKeyTrend(ctx, userID, startTime, endTime, granularity) + if err != nil { + return nil, fmt.Errorf("get user api key trend: %w", err) + } + return items, nil +} + // ListWithFilters lists usage logs with admin filters. func (s *UsageService) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]UsageLog, *pagination.PaginationResult, error) { logs, result, err := s.usageRepo.ListWithFilters(ctx, params, filters) diff --git a/backend/migrations/071_fix_api_key_partial_unique_index.sql b/backend/migrations/071_fix_api_key_partial_unique_index.sql new file mode 100644 index 0000000000..54a47a605d --- /dev/null +++ b/backend/migrations/071_fix_api_key_partial_unique_index.sql @@ -0,0 +1,13 @@ +-- 071_fix_api_key_partial_unique_index.sql +-- 修复 api_keys.key 软删除后无法重新创建相同 key 的问题 +-- 将全局唯一约束替换为部分唯一索引(WHERE deleted_at IS NULL) +-- 软删除的 key 不占用唯一约束位置,允许删除后重新创建同一 key + +-- 删除旧的全局唯一约束 +ALTER TABLE api_keys DROP CONSTRAINT IF EXISTS api_keys_key_key; +DROP INDEX IF EXISTS api_keys_key_key; + +-- 创建部分唯一索引:只对未删除的记录建立唯一约束 +CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_unique_active + ON api_keys(key) + WHERE deleted_at IS NULL; diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index 6efd7657fc..5be2679390 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -42,7 +42,7 @@ export interface UserDashboardStats { export interface TrendParams { start_date?: string end_date?: string - granularity?: 'day' | 'hour' + granularity?: 'hour' | 'day' | 'month' } export interface TrendResponse { @@ -257,6 +257,87 @@ export async function getDashboardApiKeysUsage( return data } +// ==================== API Key Model Distribution Types ==================== + +export interface APIKeyModelDistributionModel { + model: string + requests: number + input_tokens: number + output_tokens: number + cache_creation_tokens: number + cache_read_tokens: number + total_tokens: number + cost: number + actual_cost: number +} + +export interface APIKeyModelDistributionItem { + api_key_id: number + api_key_name: string + models: APIKeyModelDistributionModel[] +} + +export interface APIKeyModelDistributionResponse { + distribution: APIKeyModelDistributionItem[] +} + +/** + * Get API key model distribution data + * @param params - Query parameters for filtering + * @returns API key model distribution for current user + */ +export async function getDashboardApiKeyModelDistribution(params?: { + start_date?: string + end_date?: string + timezone?: string +}): Promise { + const { data } = await apiClient.get( + '/usage/dashboard/api-key-model-distribution', + { params } + ) + return data +} + +// ==================== API Key Trend Types ==================== + +export interface APIKeyTrendDataPoint { + date: string + requests: number + total_tokens: number + cost: number + actual_cost: number +} + +export interface APIKeyTrendItem { + api_key_id: number + api_key_name: string + data: APIKeyTrendDataPoint[] +} + +export interface APIKeyTrendResponse { + trends: APIKeyTrendItem[] + start_date: string + end_date: string + granularity: string +} + +/** + * Get per-API-Key trend data + * @param params - Query parameters for filtering + * @returns Per-API-Key trend data for current user + */ +export async function getDashboardApiKeyTrend(params?: { + start_date?: string + end_date?: string + granularity?: 'hour' | 'day' | 'month' +}): Promise { + const { data } = await apiClient.get( + '/usage/dashboard/api-key-trend', + { params } + ) + return data +} + export const usageAPI = { list, query, @@ -268,7 +349,9 @@ export const usageAPI = { getDashboardStats, getDashboardTrend, getDashboardModels, - getDashboardApiKeysUsage + getDashboardApiKeysUsage, + getDashboardApiKeyModelDistribution, + getDashboardApiKeyTrend } export default usageAPI diff --git a/frontend/src/components/charts/ApiKeyModelDistribution.vue b/frontend/src/components/charts/ApiKeyModelDistribution.vue new file mode 100644 index 0000000000..51be4c8521 --- /dev/null +++ b/frontend/src/components/charts/ApiKeyModelDistribution.vue @@ -0,0 +1,190 @@ + + + diff --git a/frontend/src/components/charts/ApiKeyTrendChart.vue b/frontend/src/components/charts/ApiKeyTrendChart.vue new file mode 100644 index 0000000000..997b8fec3c --- /dev/null +++ b/frontend/src/components/charts/ApiKeyTrendChart.vue @@ -0,0 +1,184 @@ + + + diff --git a/frontend/src/components/layout/TablePageLayout.vue b/frontend/src/components/layout/TablePageLayout.vue index 7b8c82ae8a..6a00f2e58d 100644 --- a/frontend/src/components/layout/TablePageLayout.vue +++ b/frontend/src/components/layout/TablePageLayout.vue @@ -10,6 +10,11 @@ + +
+ +
+
diff --git a/frontend/src/components/user/usage/UsageChartsPanel.vue b/frontend/src/components/user/usage/UsageChartsPanel.vue new file mode 100644 index 0000000000..8d032a02de --- /dev/null +++ b/frontend/src/components/user/usage/UsageChartsPanel.vue @@ -0,0 +1,125 @@ + + + diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 81688ca4ad..53f9531b80 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -746,7 +746,29 @@ export default { exportExcelSuccess: 'Usage data exported successfully (Excel format)', exportExcelFailed: 'Failed to export usage data', imageUnit: ' images', - userAgent: 'User-Agent' + userAgent: 'User-Agent', + // Charts section + charts: { + title: 'Usage Statistics Charts', + expand: 'Expand Charts', + collapse: 'Collapse Charts', + requestsTrend: 'Requests Trend', + tokenUsageTrend: 'Token Usage Trend', + costTrend: 'Cost Trend', + apiKeyModelDistribution: 'Model Usage Distribution', + noData: 'No data available', + totalRequests: 'Total', + requests: 'Requests', + cost: 'Cost', + granularity: 'Granularity', + granularityHour: 'Hour', + granularityDay: 'Day', + granularityMonth: 'Month' + }, + // Records section + records: { + title: 'Usage Records' + } }, // Redeem diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index c7fcb95655..a435e63f67 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -751,7 +751,29 @@ export default { exportExcelSuccess: '使用数据导出成功(Excel格式)', exportExcelFailed: '使用数据导出失败', imageUnit: '张', - userAgent: 'User-Agent' + userAgent: 'User-Agent', + // Charts section + charts: { + title: '使用统计', + expand: '展开图表', + collapse: '收起图表', + requestsTrend: '请求次数趋势', + tokenUsageTrend: 'Token 使用趋势', + costTrend: '费用趋势', + apiKeyModelDistribution: '模型使用分布', + noData: '暂无数据', + totalRequests: '总请求', + requests: '请求', + cost: '费用', + granularity: '粒度', + granularityHour: '小时', + granularityDay: '日', + granularityMonth: '月' + }, + // Records section + records: { + title: '使用记录' + } }, // Redeem diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue index 2e3e04417f..dd971d174e 100644 --- a/frontend/src/views/user/UsageView.vue +++ b/frontend/src/views/user/UsageView.vue @@ -148,6 +148,16 @@
+ +