Merge pull request #2613 from wucm667/feat/api-key-usage-daily-detail
feat(usage): 用户 API Key 用量页支持按日明细
This commit is contained in:
commit
51f72186a5
@ -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
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
})
|
||||
}
|
||||
|
||||
195
backend/internal/handler/usage_handler_daily_test.go
Normal file
195
backend/internal/handler/usage_handler_daily_test.go
Normal 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])
|
||||
}
|
||||
@ -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 统计
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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: '确定要删除全部未使用的兑换码吗?此操作无法撤销。',
|
||||
|
||||
@ -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) {
|
||||
|
||||
208
frontend/src/views/__tests__/KeyUsageView.spec.ts
Normal file
208
frontend/src/views/__tests__/KeyUsageView.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user