Merge pull request #2613 from wucm667/feat/api-key-usage-daily-detail

feat(usage): 用户 API Key 用量页支持按日明细
This commit is contained in:
Wesley Liddick 2026-05-20 16:55:42 +08:00 committed by GitHub
commit 51f72186a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 738 additions and 14 deletions

View File

@ -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
}

View File

@ -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"),
})
}

View File

@ -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])
}

View File

@ -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 统计

View File

@ -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")

View File

@ -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)

View File

@ -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<ApiKeyDailyUsageResponse> {
const { data } = await apiClient.get<ApiKeyDailyUsageResponse>(
`/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
}

View File

@ -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:

View File

@ -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: '确定要删除全部未使用的兑换码吗?此操作无法撤销。',

View File

@ -289,6 +289,62 @@
</div>
</div>
<!-- Daily Usage Table -->
<div
v-if="showDailyUsage"
class="fade-up fade-up-delay-4 rounded-2xl border border-gray-200 bg-white/90 backdrop-blur-sm overflow-hidden dark:border-dark-700 dark:bg-dark-900/90"
>
<div class="flex flex-col gap-3 px-8 py-5 border-b border-gray-200 dark:border-dark-700 sm:flex-row sm:items-center sm:justify-between">
<h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.dailyDetail') }}</h3>
<div class="inline-flex rounded-lg border border-gray-200 bg-white p-0.5 dark:border-dark-700 dark:bg-dark-950">
<button
v-for="option in dailyUsageOptions"
:key="option.value"
@click="setDailyUsageDays(option.value)"
class="min-w-12 rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
:class="dailyUsageDays === option.value
? 'bg-primary-500 text-white'
: 'text-gray-600 hover:bg-gray-100 dark:text-dark-300 dark:hover:bg-dark-800'"
>
{{ option.label }}
</button>
</div>
</div>
<div v-if="dailyUsageRows.length > 0" class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-200 bg-gray-50 dark:border-dark-700 dark:bg-dark-950">
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.date') }}</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.requests') }}</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.inputTokens') }}</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.outputTokens') }}</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.cacheReadTokens') }}</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.cacheWriteTokens') }}</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.cost') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in dailyUsageRows"
:key="row.date"
class="border-b border-gray-100 last:border-b-0 dark:border-dark-800"
>
<td class="px-4 py-3 text-sm font-medium whitespace-nowrap text-gray-900 dark:text-white">{{ row.date }}</td>
<td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(row.requests) }}</td>
<td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(row.input_tokens) }}</td>
<td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(row.output_tokens) }}</td>
<td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(row.cache_read_tokens) }}</td>
<td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(row.cache_write_tokens) }}</td>
<td class="px-4 py-3 text-sm tabular-nums text-right font-medium text-gray-900 dark:text-white">{{ usd(row.actual_cost != null ? row.actual_cost : row.cost) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="px-8 py-8 text-center text-sm text-gray-500 dark:text-dark-400">
{{ t('keyUsage.noDailyUsage') }}
</div>
</div>
<!-- Model Stats Table -->
<div
v-if="modelStats.length > 0"
@ -408,6 +464,7 @@ type DateRangeKey = 'today' | '7d' | '30d' | 'custom'
const currentRange = ref<DateRangeKey>('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<StatCell[]>(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const modelStats = computed<any[]>(() => 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<DailyUsageRow[]>(() => {
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) {

View File

@ -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<string, string> = {
'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<typeof import('vue-i18n')>('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: '<a><slot /></a>' },
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()
})
})